feat: Add Grid View and Monthly Summary pages with workday management
Some checks failed
Publish Container / publish (push) Failing after 1m2s

- Implement GridView.razor for displaying a tabular view of workdays in the current month.
- Create MonthlySummary.razor to show a summary of worked hours, income, and day types for the selected month.
- Introduce WorkDayEditor.razor for adding and editing workday entries with detailed calculations.
- Update Home.razor to include links to the new Grid View and Monthly Summary pages.
- Add IWorkDayService interface and CouchbaseLiteWorkDayService implementation for managing workday data.
- Define domain models: WorkDayDocument, MonthlySummaryModel, and CoeffSnapshotDocument for data structure.
- Enhance CouchbaseLiteDatabaseProvider to include a collection for workdays.
- Update app settings and services to support new features.
- Add CSS styles for calendar view and table formatting.
This commit is contained in:
MaddoScientisto 2026-03-17 22:10:19 +01:00
commit 3ccce7e8a6
17 changed files with 1257 additions and 18 deletions

View file

@ -8,6 +8,7 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
{
private const string AppSettingsCollectionName = "app_settings";
private const string UsersCollectionName = "users";
private const string WorkDaysCollectionName = "workdays";
private readonly Database database;
@ -26,12 +27,15 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
}
public Collection AppSettings { get; }
public Collection Users { get; }
public Collection WorkDays { get; }
public void Dispose()
{
database.Close();

View file

@ -0,0 +1,237 @@
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<WorkDayDocument?> 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<WorkDayDocument> 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<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var results = new List<WorkDayDocument>();
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<IReadOnlyList<WorkDayDocument>>(results);
}
public async Task<MonthlySummaryModel> 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;
}
}

View file

@ -0,0 +1,14 @@
using WorkTracker.Domain;
namespace WorkTracker.Services.WorkDays;
public interface IWorkDayService
{
Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default);
Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
}