using Couchbase.Lite; using WorkTracker.Domain; using WorkTracker.Services.Festivities; using WorkTracker.Services.Settings; using WorkTracker.Services.Storage; namespace WorkTracker.Services.WorkDays; public sealed class CouchbaseLiteWorkDayService : IWorkDayService { private readonly Collection workDaysCollection; private readonly IAppSettingsService appSettingsService; private readonly IItalianFestivitySource festivitySource; public CouchbaseLiteWorkDayService( CouchbaseLiteDatabaseProvider databaseProvider, IAppSettingsService appSettingsService, IItalianFestivitySource festivitySource) { workDaysCollection = databaseProvider.WorkDays; this.appSettingsService = appSettingsService; this.festivitySource = festivitySource; } public Task GetAsync(DateOnly date, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var id = date.ToString("yyyy-MM-dd"); var doc = workDaysCollection.GetDocument(id); return Task.FromResult(doc is not null ? Map(doc) : null); } public async Task GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal)); } public async Task GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); } public async Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var settings = await appSettingsService.GetAsync(cancellationToken); var day = await GetOrCreateDayAsync(date, cancellationToken); var now = DateTimeOffset.UtcNow; var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal)); var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now; workUnit.Id = string.IsNullOrWhiteSpace(workUnit.Id) ? Guid.NewGuid().ToString("N") : workUnit.Id; workUnit.Label = string.IsNullOrWhiteSpace(workUnit.Label) ? "Work unit" : workUnit.Label.Trim(); workUnit.ManualWorkedHours = Math.Max(0m, workUnit.ManualWorkedHours); workUnit.CoeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay, HourlyGrossRate = settings.HourlyGrossRate, ProfitabilityCoefficient = settings.ProfitabilityCoefficient, InpsRate = settings.InpsRate, SubstituteTaxRate = settings.SubstituteTaxRate }; workUnit.CreatedAtUtc = existingCreatedAt; workUnit.UpdatedAtUtc = now; Compute(workUnit); if (existingIndex >= 0) { day.WorkUnits[existingIndex] = workUnit; } else { day.WorkUnits.Add(workUnit); } day.UpdatedAtUtc = now; SortEntries(day); SaveDocument(day); return workUnit; } public async Task SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var day = await GetOrCreateDayAsync(date, cancellationToken); var now = DateTimeOffset.UtcNow; var existingIndex = day.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal)); var existingCreatedAt = existingIndex >= 0 ? day.CalendarEvents[existingIndex].CreatedAtUtc : now; calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id; calendarEvent.Description = string.IsNullOrWhiteSpace(calendarEvent.Description) ? "Calendar entry" : calendarEvent.Description.Trim(); calendarEvent.CreatedAtUtc = existingCreatedAt; calendarEvent.UpdatedAtUtc = now; Compute(calendarEvent); if (existingIndex >= 0) { day.CalendarEvents[existingIndex] = calendarEvent; } else { day.CalendarEvents.Add(calendarEvent); } day.UpdatedAtUtc = now; SortEntries(day); SaveDocument(day); return calendarEvent; } public async Task DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); if (day is null) { return false; } var removed = day.WorkUnits.RemoveAll(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal)); if (removed == 0) { return false; } return DeleteOrSaveDay(day); } public async Task DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); if (day is null) { return false; } var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); if (removed == 0) { return false; } return DeleteOrSaveDay(day); } public Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var results = new List(); for (var date = from; date <= to; date = date.AddDays(1)) { var id = date.ToString("yyyy-MM-dd"); var doc = workDaysCollection.GetDocument(id); if (doc is not null) { results.Add(Map(doc)); } } return Task.FromResult>(results); } public async Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) { var from = new DateOnly(year, month, 1); return await BuildMonthlySummaryAsync(from, includePreview, cancellationToken); } public async Task> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default) { var summaries = new List(12); for (var month = 1; month <= 12; month++) { cancellationToken.ThrowIfCancellationRequested(); summaries.Add(await BuildMonthlySummaryAsync(new DateOnly(year, month, 1), includePreview, cancellationToken)); } return summaries; } public async Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) { var from = new DateOnly(year, month, 1); var to = from.AddMonths(1).AddDays(-1); var days = await GetRangeAsync(from, to, cancellationToken); var dayLookup = days.ToDictionary(day => day.Date); var settings = await appSettingsService.GetAsync(cancellationToken); var daySummaries = new List(); for (var date = from; date <= to; date = date.AddDays(1)) { dayLookup.TryGetValue(date, out var day); daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay)); } return new MonthlyTimesheetModel { Year = year, Month = month, Days = daySummaries.Select(summary => new MonthlyTimesheetDayModel { Date = summary.Date, IsWeekend = summary.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, IsHoliday = summary.HolidayDays > 0m || dayLookup.GetValueOrDefault(summary.Date)?.IsItalianFestivity == true, IsClosure = summary.VacationDays > 0m && HasEventType(dayLookup.GetValueOrDefault(summary.Date), CalendarEventType.Closure), WorkUnitSummaries = dayLookup.GetValueOrDefault(summary.Date)?.WorkUnits .Where(unit => includePreview || !unit.IsPreview) .Select(FormatTimesheetWorkUnitSummary) .ToList() ?? [], EventSummaries = dayLookup.GetValueOrDefault(summary.Date)?.CalendarEvents .Select(FormatTimesheetEventSummary) .ToList() ?? [] }).ToList(), Rows = [ CreateTimesheetRow("office", "Ore lavorative in presenza", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OfficeHours)), CreateTimesheetRow("home", "Ore lavorative in smart working", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.HomeHours)), CreateTimesheetRow("overtime", "Straordinari", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OvertimeHours)), CreateTimesheetRow("weekend", "Weekend", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.WeekendHours)), CreateTimesheetRow("night", "Notturni (22-06)", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.NightHours)), CreateTimesheetRow("vacation", "Giorni di ferie", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.VacationDays)), CreateTimesheetRow("permit", "Ore di permesso", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.PermitHours)), CreateTimesheetRow("compensatory-rest", "Riposo compensativo", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.CompensatoryRestDays), includeZeroTotal: false), CreateTimesheetRow("sick", "Giorni di malattia", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.SickDays)), CreateTimesheetRow("holiday", "Festività", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.HolidayDays)) ] }; } public async Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var settings = await appSettingsService.GetAsync(cancellationToken); var festivities = festivitySource.GetFestivities(year); var from = new DateOnly(year, month, 1); var to = from.AddMonths(1).AddDays(-1); var createdDays = 0; for (var date = from; date <= to; date = date.AddDays(1)) { cancellationToken.ThrowIfCancellationRequested(); if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || festivities.Contains(date)) { continue; } var day = await GetOrCreateDayAsync(date, cancellationToken); if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType))) { continue; } day.WorkUnits.Add(CreatePreviewWorkUnit("Morning", new TimeOnly(8, 30), new TimeOnly(13, 0), settings)); day.WorkUnits.Add(CreatePreviewWorkUnit("Afternoon", new TimeOnly(14, 0), new TimeOnly(17, 30), settings)); day.UpdatedAtUtc = DateTimeOffset.UtcNow; SortEntries(day); SaveDocument(day); createdDays++; } return createdDays; } private void SaveDocument(WorkDayDocument day) { var doc = new MutableDocument(day.Id); doc.SetString("date", day.Date.ToString("yyyy-MM-dd")); doc.SetBoolean("isWeekend", day.IsWeekend); doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity); var workUnits = new MutableArrayObject(); foreach (var unit in day.WorkUnits) { var entry = new MutableDictionaryObject(); entry.SetString("id", unit.Id); entry.SetString("label", unit.Label); entry.SetInt("location", (int)unit.Location); entry.SetString("startTime", unit.StartTime?.ToString("HH:mm")); entry.SetString("endTime", unit.EndTime?.ToString("HH:mm")); entry.SetBoolean("isPreview", unit.IsPreview); entry.SetDouble("manualWorkedHours", decimal.ToDouble(unit.ManualWorkedHours)); entry.SetDouble("calculatedWorkedHours", decimal.ToDouble(unit.CalculatedWorkedHours)); entry.SetDouble("workedHoursDelta", decimal.ToDouble(unit.WorkedHoursDelta)); entry.SetDouble("grossIncome", decimal.ToDouble(unit.GrossIncome)); entry.SetDouble("netIncome", decimal.ToDouble(unit.NetIncome)); entry.SetString("notes", unit.Notes); entry.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(unit.CoeffSnapshot.StandardWorkHoursPerDay)); entry.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(unit.CoeffSnapshot.HourlyGrossRate)); entry.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(unit.CoeffSnapshot.ProfitabilityCoefficient)); entry.SetDouble("coeff_inpsRate", decimal.ToDouble(unit.CoeffSnapshot.InpsRate)); entry.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(unit.CoeffSnapshot.SubstituteTaxRate)); entry.SetString("createdAtUtc", unit.CreatedAtUtc.ToString("O")); entry.SetString("updatedAtUtc", unit.UpdatedAtUtc.ToString("O")); workUnits.AddDictionary(entry); } var calendarEvents = new MutableArrayObject(); foreach (var calendarEvent in day.CalendarEvents) { var entry = new MutableDictionaryObject(); entry.SetString("id", calendarEvent.Id); entry.SetInt("eventType", (int)calendarEvent.EventType); entry.SetString("description", calendarEvent.Description); entry.SetString("startTime", calendarEvent.StartTime?.ToString("HH:mm")); entry.SetString("endTime", calendarEvent.EndTime?.ToString("HH:mm")); if (calendarEvent.DurationHours.HasValue) { entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value)); } entry.SetString("createdAtUtc", calendarEvent.CreatedAtUtc.ToString("O")); entry.SetString("updatedAtUtc", calendarEvent.UpdatedAtUtc.ToString("O")); calendarEvents.AddDictionary(entry); } doc.SetArray("workUnits", workUnits); doc.SetArray("calendarEvents", calendarEvents); doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O")); doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O")); workDaysCollection.Save(doc); } private bool DeleteOrSaveDay(WorkDayDocument day) { if (day.WorkUnits.Count == 0 && day.CalendarEvents.Count == 0) { var existing = workDaysCollection.GetDocument(day.Id); if (existing is null) { return false; } workDaysCollection.Delete(existing); return true; } day.UpdatedAtUtc = DateTimeOffset.UtcNow; SortEntries(day); SaveDocument(day); return true; } private static WorkDayDocument Map(Document doc) { if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents")) { return MapLegacy(doc); } var workUnits = new List(); var workUnitsArray = doc.GetArray("workUnits"); if (workUnitsArray is not null) { for (var i = 0; i < workUnitsArray.Count; i++) { var unit = workUnitsArray.GetDictionary(i); if (unit is not null) { workUnits.Add(MapWorkUnit(unit)); } } } var calendarEvents = new List(); var calendarEventsArray = doc.GetArray("calendarEvents"); if (calendarEventsArray is not null) { for (var i = 0; i < calendarEventsArray.Count; i++) { var calendarEvent = calendarEventsArray.GetDictionary(i); if (calendarEvent is not null) { calendarEvents.Add(MapCalendarEvent(calendarEvent)); } } } return new WorkDayDocument { Id = doc.Id, Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"), IsWeekend = doc.GetBoolean("isWeekend"), IsItalianFestivity = doc.GetBoolean("isItalianFestivity"), WorkUnits = workUnits, CalendarEvents = calendarEvents, CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"), UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc") }; } private async Task GetOrCreateDayAsync(DateOnly date, CancellationToken cancellationToken) { var existing = await GetAsync(date, cancellationToken); if (existing is not null) { existing.IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; existing.IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date); existing.Id = date.ToString("yyyy-MM-dd"); existing.Date = date; return existing; } return new WorkDayDocument { Id = date.ToString("yyyy-MM-dd"), Date = date, IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date), CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow }; } private static void Compute(WorkUnitDocument unit) { unit.CalculatedWorkedHours = CalculateDuration(unit.StartTime, unit.EndTime) ?? 0m; unit.WorkedHoursDelta = unit.ManualWorkedHours - unit.CalculatedWorkedHours; var coeff = unit.CoeffSnapshot; unit.GrossIncome = unit.ManualWorkedHours * coeff.HourlyGrossRate; var taxableBase = unit.GrossIncome * coeff.ProfitabilityCoefficient; unit.NetIncome = unit.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate); } private static void Compute(CalendarEventDocument calendarEvent) { calendarEvent.DurationHours = CalculateDuration(calendarEvent.StartTime, calendarEvent.EndTime); } private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime) { if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime) { return null; } return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero); } private static int CountDaysWithEvent(IEnumerable days, CalendarEventType eventType) { return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType)); } private async Task BuildMonthlySummaryAsync(DateOnly from, bool includePreview, CancellationToken cancellationToken) { var to = from.AddMonths(1).AddDays(-1); var days = await GetRangeAsync(from, to, cancellationToken); var includedUnits = days .SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit })) .ToList(); var previewUnits = days .SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit })) .ToList(); return new MonthlySummaryModel { Year = from.Year, Month = from.Month, TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours), TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours), CountedWorkUnits = includedUnits.Count, PreviewWorkUnits = previewUnits.Count, OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(), HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(), HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday), SickDays = CountDaysWithEvent(days, CalendarEventType.Illness), DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff), ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure), TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)), TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome), TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome), TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count() }; } private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours) { var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? []; var totalHours = includedUnits.Sum(unit => unit.ManualWorkedHours); var explicitHoliday = HasEventType(day, CalendarEventType.Holiday); var illness = HasEventType(day, CalendarEventType.Illness); var dayOff = HasEventType(day, CalendarEventType.DayOff); var closure = HasEventType(day, CalendarEventType.Closure); var isWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; var isAutomaticHoliday = day?.IsItalianFestivity ?? false; var standardHours = includedUnits.FirstOrDefault()?.CoeffSnapshot.StandardWorkHoursPerDay ?? defaultStandardHours; var nightHours = includedUnits.Sum(GetNightHours); var weekdayDaytimeHours = isWeekend ? 0m : Math.Max(0m, totalHours - nightHours); var suppressVacation = isWeekend || explicitHoliday || isAutomaticHoliday || illness; var hasNonWorkingEvent = explicitHoliday || illness || dayOff || closure; var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && totalHours < standardHours ? standardHours - totalHours : 0m; return new MonthlyTimesheetDaySummary { Date = date, OfficeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Office).Sum(unit => unit.ManualWorkedHours), HomeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Home).Sum(unit => unit.ManualWorkedHours), OvertimeHours = Math.Max(0m, weekdayDaytimeHours - standardHours), WeekendHours = isWeekend ? totalHours : 0m, NightHours = nightHours, VacationDays = (dayOff || closure) && !suppressVacation ? 1m : 0m, PermitHours = Math.Max(0m, permitHours), CompensatoryRestDays = 0m, SickDays = illness ? 1m : 0m, HolidayDays = explicitHoliday && !isWeekend ? 1m : 0m }; } private static MonthlyTimesheetRowModel CreateTimesheetRow( string key, string label, MonthlyTimesheetValueFormat valueFormat, IEnumerable values, bool includeZeroTotal = true) { var dailyValues = values .Select(value => value > 0m ? value : (decimal?)null) .ToList(); var total = dailyValues.Where(value => value.HasValue).Sum(value => value ?? 0m); return new MonthlyTimesheetRowModel { Key = key, Label = label, ValueFormat = valueFormat, DailyValues = dailyValues, Total = includeZeroTotal || total > 0m ? total : null }; } private static decimal GetHoursOff(WorkDayDocument day, bool includePreview) { var includedUnits = day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList(); if (includedUnits.Count == 0) { return 0m; } var standardHours = includedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay; var countedHours = includedUnits.Sum(unit => unit.ManualWorkedHours); return Math.Max(0m, standardHours - countedHours); } private static bool IsNonWorkingEvent(CalendarEventType eventType) { return eventType is CalendarEventType.DayOff or CalendarEventType.Closure or CalendarEventType.Holiday or CalendarEventType.Illness; } private static bool HasEventType(WorkDayDocument? day, CalendarEventType eventType) { return day?.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType) == true; } private static string FormatTimesheetWorkUnitSummary(WorkUnitDocument unit) { var prefix = unit.Location == WorkUnitLocation.Home ? "SW" : "Pres"; var hours = FormatCompactHours(unit.ManualWorkedHours); if (unit.StartTime.HasValue && unit.EndTime.HasValue) { return $"{prefix}: {unit.Label} ({unit.StartTime:HH:mm}-{unit.EndTime:HH:mm}, {hours}h{(unit.IsPreview ? ", preview" : string.Empty)})"; } return $"{prefix}: {unit.Label} ({hours}h{(unit.IsPreview ? ", preview" : string.Empty)})"; } private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent) { if (calendarEvent.StartTime.HasValue) { return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})"; } return $"{calendarEvent.EventType}: {calendarEvent.Description}"; } private static string FormatCompactHours(decimal value) { return Formatting.DurationFormatter.FormatHours(value); } private static decimal GetNightHours(WorkUnitDocument unit) { if (!unit.StartTime.HasValue || !unit.EndTime.HasValue || unit.EndTime <= unit.StartTime) { return 0m; } return GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(0, 0), new TimeOnly(6, 0)) + GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(22, 0), new TimeOnly(23, 59, 59)); } private static decimal GetOverlapHours(TimeOnly rangeStart, TimeOnly rangeEnd, TimeOnly windowStart, TimeOnly windowEnd) { var overlapStart = rangeStart > windowStart ? rangeStart : windowStart; var overlapEnd = rangeEnd < windowEnd ? rangeEnd : windowEnd; if (overlapEnd <= overlapStart) { return 0m; } return Math.Round((decimal)(overlapEnd - overlapStart).TotalHours, 2, MidpointRounding.AwayFromZero); } private static WorkUnitDocument CreatePreviewWorkUnit(string label, TimeOnly startTime, TimeOnly endTime, AppSettingsDocument settings) { var workUnit = new WorkUnitDocument { Id = Guid.NewGuid().ToString("N"), Label = label, Location = WorkUnitLocation.Office, StartTime = startTime, EndTime = endTime, IsPreview = true, ManualWorkedHours = Math.Round((decimal)(endTime - startTime).TotalHours, 2, MidpointRounding.AwayFromZero), CoeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay, HourlyGrossRate = settings.HourlyGrossRate, ProfitabilityCoefficient = settings.ProfitabilityCoefficient, InpsRate = settings.InpsRate, SubstituteTaxRate = settings.SubstituteTaxRate }, CreatedAtUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow }; Compute(workUnit); return workUnit; } private static void SortEntries(WorkDayDocument day) { day.WorkUnits = day.WorkUnits .OrderBy(unit => unit.StartTime ?? TimeOnly.MaxValue) .ThenBy(unit => unit.Label, StringComparer.CurrentCultureIgnoreCase) .ToList(); day.CalendarEvents = day.CalendarEvents .OrderBy(calendarEvent => calendarEvent.StartTime ?? TimeOnly.MaxValue) .ThenBy(calendarEvent => calendarEvent.Description, StringComparer.CurrentCultureIgnoreCase) .ToList(); } private static WorkUnitDocument MapWorkUnit(DictionaryObject unit) { var workUnit = new WorkUnitDocument { Id = unit.GetString("id") ?? Guid.NewGuid().ToString("N"), Label = unit.GetString("label") ?? "Work unit", Location = unit.Contains("location") ? (WorkUnitLocation)unit.GetInt("location") : WorkUnitLocation.Office, StartTime = ReadTimeOnly(unit, "startTime"), EndTime = ReadTimeOnly(unit, "endTime"), IsPreview = unit.GetBoolean("isPreview"), ManualWorkedHours = ReadDecimal(unit, "manualWorkedHours", 0m), CalculatedWorkedHours = ReadDecimal(unit, "calculatedWorkedHours", 0m), WorkedHoursDelta = ReadDecimal(unit, "workedHoursDelta", 0m), GrossIncome = ReadDecimal(unit, "grossIncome", 0m), NetIncome = ReadDecimal(unit, "netIncome", 0m), Notes = unit.GetString("notes"), CoeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = ReadDecimal(unit, "coeff_standardWorkHoursPerDay", 8m), HourlyGrossRate = ReadDecimal(unit, "coeff_hourlyGrossRate", 17.5m), ProfitabilityCoefficient = ReadDecimal(unit, "coeff_profitabilityCoefficient", 0.67m), InpsRate = ReadDecimal(unit, "coeff_inpsRate", 0.2607m), SubstituteTaxRate = ReadDecimal(unit, "coeff_substituteTaxRate", 0.15m) }, CreatedAtUtc = ReadDateTimeOffset(unit, "createdAtUtc"), UpdatedAtUtc = ReadDateTimeOffset(unit, "updatedAtUtc") }; Compute(workUnit); return workUnit; } private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent) { var entry = new CalendarEventDocument { Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"), EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic, Description = calendarEvent.GetString("description") ?? "Calendar entry", StartTime = ReadTimeOnly(calendarEvent, "startTime"), EndTime = ReadTimeOnly(calendarEvent, "endTime"), DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null, CreatedAtUtc = ReadDateTimeOffset(calendarEvent, "createdAtUtc"), UpdatedAtUtc = ReadDateTimeOffset(calendarEvent, "updatedAtUtc") }; Compute(entry); return entry; } private static WorkDayDocument MapLegacy(Document doc) { var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"); var dayType = doc.Contains("dayType") ? (DayType)doc.GetInt("dayType") : DayType.None; var day = new WorkDayDocument { Id = doc.Id, Date = date, IsWeekend = doc.GetBoolean("isWeekend"), IsItalianFestivity = doc.GetBoolean("isItalianFestivity"), CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"), UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc") }; var coeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m), HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m), ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m), InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m), SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m) }; if (dayType is DayType.Work or DayType.Home) { var workUnit = new WorkUnitDocument { Id = "legacy", Label = "Legacy entry", Location = dayType == DayType.Home ? WorkUnitLocation.Home : WorkUnitLocation.Office, StartTime = ReadTimeOnly(doc, "startTime"), EndTime = ReadTimeOnly(doc, "actualExitTime") ?? ReadTimeOnly(doc, "projectedExitTime"), IsPreview = false, ManualWorkedHours = ReadDecimal(doc, "workedHoursFinal", ReadDecimal(doc, "workedHoursBase", 0m)), GrossIncome = ReadDecimal(doc, "grossIncome", 0m), NetIncome = ReadDecimal(doc, "netIncome", 0m), Notes = doc.GetString("notes"), CoeffSnapshot = coeffSnapshot, CreatedAtUtc = day.CreatedAtUtc, UpdatedAtUtc = day.UpdatedAtUtc }; Compute(workUnit); day.WorkUnits.Add(workUnit); } else if (dayType != DayType.None) { var calendarEvent = new CalendarEventDocument { Id = "legacy", EventType = MapLegacyEventType(dayType), Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!, CreatedAtUtc = day.CreatedAtUtc, UpdatedAtUtc = day.UpdatedAtUtc }; Compute(calendarEvent); day.CalendarEvents.Add(calendarEvent); } return day; } private static CalendarEventType MapLegacyEventType(DayType dayType) { return dayType switch { DayType.DayOff => CalendarEventType.DayOff, DayType.Closure => CalendarEventType.Closure, DayType.Holiday => CalendarEventType.Holiday, DayType.Illness => CalendarEventType.Illness, _ => CalendarEventType.Generic }; } private static TimeOnly? ReadTimeOnly(Document doc, string key) { var value = doc.GetString(key); return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time) ? time : null; } private static TimeOnly? ReadTimeOnly(DictionaryObject doc, string key) { var value = doc.GetString(key); return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time) ? time : null; } private static decimal ReadDecimal(Document doc, string key, decimal defaultValue) { return doc.Contains(key) ? Convert.ToDecimal(doc.GetDouble(key)) : defaultValue; } private static decimal ReadDecimal(DictionaryObject doc, string key, decimal defaultValue) { return doc.Contains(key) ? Convert.ToDecimal(doc.GetDouble(key)) : defaultValue; } private static DateTimeOffset ReadDateTimeOffset(Document doc, string key) { var value = doc.GetString(key); return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt) ? dt : DateTimeOffset.UtcNow; } private static DateTimeOffset ReadDateTimeOffset(DictionaryObject doc, string key) { var value = doc.GetString(key); return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt) ? dt : DateTimeOffset.UtcNow; } }