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 SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var settings = await appSettingsService.GetAsync(cancellationToken); var festivities = festivitySource.GetFestivities(workDay.Date.Year); workDay.Id = workDay.Date.ToString("yyyy-MM-dd"); workDay.IsWeekend = workDay.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; workDay.IsItalianFestivity = festivities.Contains(workDay.Date); // Snapshot coefficients from current settings workDay.CoeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay, LunchBreakHours = settings.LunchBreakHours, HourlyGrossRate = settings.HourlyGrossRate, ProfitabilityCoefficient = settings.ProfitabilityCoefficient, InpsRate = settings.InpsRate, SubstituteTaxRate = settings.SubstituteTaxRate }; Compute(workDay); // Preserve creation timestamp for existing documents var existing = workDaysCollection.GetDocument(workDay.Id); if (existing is not null) { workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc"); } else { workDay.CreatedAtUtc = DateTimeOffset.UtcNow; } workDay.UpdatedAtUtc = DateTimeOffset.UtcNow; SaveDocument(workDay); return workDay; } 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, CancellationToken cancellationToken = default) { var from = new DateOnly(year, month, 1); var to = from.AddMonths(1).AddDays(-1); var days = await GetRangeAsync(from, to, cancellationToken); return new MonthlySummaryModel { Year = year, Month = month, TotalWorkedHours = days.Sum(d => d.WorkedHoursFinal), OfficeDays = days.Count(d => d.DayType == DayType.Work), HomeDays = days.Count(d => d.DayType == DayType.Home), HolidayDays = days.Count(d => d.DayType == DayType.Holiday), SickDays = days.Count(d => d.DayType == DayType.Illness), DaysOff = days.Count(d => d.DayType == DayType.DayOff), ClosureDays = days.Count(d => d.DayType == DayType.Closure), TotalHoursOff = days.Sum(d => d.HoursOff), TotalGrossIncome = days.Sum(d => d.GrossIncome), TotalNetIncome = days.Sum(d => d.NetIncome), TotalWorkingDays = days.Count(d => d.DayType is DayType.Work or DayType.Home) }; } private static void Compute(WorkDayDocument day) { var coeff = day.CoeffSnapshot; // Calculate projected exit time if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home) { var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours; day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours)); } else { day.ProjectedExitTime = null; } // Calculate worked hours day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home ? coeff.StandardWorkHoursPerDay : 0m; day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta; // Hours off (only for work/home days) day.HoursOff = day.DayType is DayType.Work or DayType.Home ? Math.Max(0m, coeff.StandardWorkHoursPerDay - day.WorkedHoursFinal) : 0m; // Income calculations day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate; var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient; day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate); } private void SaveDocument(WorkDayDocument day) { var doc = new MutableDocument(day.Id); doc.SetString("date", day.Date.ToString("yyyy-MM-dd")); doc.SetString("startTime", day.StartTime?.ToString("HH:mm")); doc.SetString("projectedExitTime", day.ProjectedExitTime?.ToString("HH:mm")); doc.SetString("actualExitTime", day.ActualExitTime?.ToString("HH:mm")); doc.SetInt("dayType", (int)day.DayType); doc.SetDouble("extraHoursDelta", decimal.ToDouble(day.ExtraHoursDelta)); doc.SetDouble("workedHoursBase", decimal.ToDouble(day.WorkedHoursBase)); doc.SetDouble("workedHoursFinal", decimal.ToDouble(day.WorkedHoursFinal)); doc.SetDouble("hoursOff", decimal.ToDouble(day.HoursOff)); doc.SetDouble("grossIncome", decimal.ToDouble(day.GrossIncome)); doc.SetDouble("netIncome", decimal.ToDouble(day.NetIncome)); doc.SetBoolean("isWeekend", day.IsWeekend); doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity); doc.SetString("notes", day.Notes); // Coefficient snapshot doc.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(day.CoeffSnapshot.StandardWorkHoursPerDay)); doc.SetDouble("coeff_lunchBreakHours", decimal.ToDouble(day.CoeffSnapshot.LunchBreakHours)); doc.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(day.CoeffSnapshot.HourlyGrossRate)); doc.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(day.CoeffSnapshot.ProfitabilityCoefficient)); doc.SetDouble("coeff_inpsRate", decimal.ToDouble(day.CoeffSnapshot.InpsRate)); doc.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(day.CoeffSnapshot.SubstituteTaxRate)); doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O")); doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O")); workDaysCollection.Save(doc); } private static WorkDayDocument Map(Document doc) { return new WorkDayDocument { Id = doc.Id, Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"), StartTime = ReadTimeOnly(doc, "startTime"), ProjectedExitTime = ReadTimeOnly(doc, "projectedExitTime"), ActualExitTime = ReadTimeOnly(doc, "actualExitTime"), DayType = (DayType)doc.GetInt("dayType"), ExtraHoursDelta = Convert.ToDecimal(doc.GetDouble("extraHoursDelta")), WorkedHoursBase = Convert.ToDecimal(doc.GetDouble("workedHoursBase")), WorkedHoursFinal = Convert.ToDecimal(doc.GetDouble("workedHoursFinal")), HoursOff = Convert.ToDecimal(doc.GetDouble("hoursOff")), GrossIncome = Convert.ToDecimal(doc.GetDouble("grossIncome")), NetIncome = Convert.ToDecimal(doc.GetDouble("netIncome")), IsWeekend = doc.GetBoolean("isWeekend"), IsItalianFestivity = doc.GetBoolean("isItalianFestivity"), Notes = doc.GetString("notes"), CoeffSnapshot = new CoeffSnapshotDocument { StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m), LunchBreakHours = ReadDecimal(doc, "coeff_lunchBreakHours", 1m), 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) }, CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"), UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc") }; } 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 decimal ReadDecimal(Document 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; } }