+
-
Next step
-
Open Settings to adjust the default values used to prefill each workday.
+
Calendar
+
Visual calendar with day-type badges.
+
Open Calendar
+
+
+
+
+
+
+
Monthly Summary
+
Totals for worked hours, income, and day types.
+
Open Summary
+
+
+
+
+
+
+
Settings
+
Configure default rates, hours, and tax coefficients.
+
Open Settings
diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor
new file mode 100644
index 0000000..68bda19
--- /dev/null
+++ b/Components/Pages/MonthlySummary.razor
@@ -0,0 +1,155 @@
+@page "/summary"
+@page "/summary/{YearMonth}"
+@attribute [Authorize]
+@rendermode InteractiveServer
+
+@inject IWorkDayService WorkDayService
+
+
Monthly Summary
+
+
Monthly Summary
+
+
+
+
@currentMonth.ToString("MMMM yyyy")
+
+
+
+@if (loading)
+{
+
Loading...
+}
+else if (summary is not null)
+{
+
+
+
+
+
Working Days
+
@summary.TotalWorkingDays
+
+
+
+
+
+
+
Total Worked Hours
+
@summary.TotalWorkedHours.ToString("N1")h
+
+
+
+
+
+
+
Hours Off
+
@summary.TotalHoursOff.ToString("N1")h
+
+
+
+
+
+
+
Gross Income
+
€@summary.TotalGrossIncome.ToString("N2")
+
+
+
+
+
+
+
Net Income
+
€@summary.TotalNetIncome.ToString("N2")
+
+
+
+
+
+
+
Office Days
+
@summary.OfficeDays
+
+
+
+
+
+
+
Home Days
+
@summary.HomeDays
+
+
+
+
+
+
+
Holidays
+
@summary.HolidayDays
+
+
+
+
+
+
+
Sick Days
+
@summary.SickDays
+
+
+
+
+
+
+
Days Off
+
@summary.DaysOff
+
+
+
+
+
+
+
Closure Days
+
@summary.ClosureDays
+
+
+
+
+}
+
+@code {
+ [Parameter] public string? YearMonth { get; set; }
+
+ private DateOnly currentMonth;
+ private bool loading = true;
+ private MonthlySummaryModel? summary;
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed))
+ {
+ currentMonth = new DateOnly(parsed.Year, parsed.Month, 1);
+ }
+ else
+ {
+ currentMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1);
+ }
+
+ await LoadSummary();
+ }
+
+ private async Task LoadSummary()
+ {
+ loading = true;
+ summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month);
+ loading = false;
+ }
+
+ private async Task PreviousMonth()
+ {
+ currentMonth = currentMonth.AddMonths(-1);
+ await LoadSummary();
+ }
+
+ private async Task NextMonth()
+ {
+ currentMonth = currentMonth.AddMonths(1);
+ await LoadSummary();
+ }
+}
diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor
new file mode 100644
index 0000000..58108a5
--- /dev/null
+++ b/Components/Pages/WorkDayEditor.razor
@@ -0,0 +1,296 @@
+@page "/workday"
+@page "/workday/{DateStr}"
+@attribute [Authorize]
+@rendermode InteractiveServer
+
+@inject IWorkDayService WorkDayService
+@inject IAppSettingsService AppSettingsService
+@inject IItalianFestivitySource FestivitySource
+@inject NavigationManager Navigation
+
+
Work Day
+
+
Work Day Entry
+
+@if (!loaded)
+{
+
Loading...
+}
+else
+{
+
+
+
+
+ @if (isWeekend || isFestivity)
+ {
+
+ @if (isWeekend) { Weekend }
+ @if (isFestivity) { Festivity }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Informational only, not used in calculations.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Computed values
+
+
+
+
@(projectedExitTime?.ToString("HH:mm") ?? "—")
+
+
+
+
@workedHoursBase.ToString("N2")h
+
+
+
+
@workedHoursFinal.ToString("N2")h
+
+
+
+
@hoursOff.ToString("N2")h
+
+
+
+
€@grossIncome.ToString("N2")
+
+
+
+
€@netIncome.ToString("N2")
+
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(statusMessage))
+ {
+ @statusMessage
+ }
+
+}
+
+@code {
+ [Parameter] public string? DateStr { get; set; }
+
+ private bool loaded;
+ private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
+ private DayType selectedDayType = DayType.None;
+ private string? startTimeStr;
+ private string? actualExitTimeStr;
+ private decimal extraHoursDelta;
+ private string? notes;
+ private string? statusMessage;
+
+ // Computed preview
+ private TimeOnly? projectedExitTime;
+ private decimal workedHoursBase;
+ private decimal workedHoursFinal;
+ private decimal hoursOff;
+ private decimal grossIncome;
+ private decimal netIncome;
+ private bool isWeekend;
+ private bool isFestivity;
+
+ // Loaded from settings
+ private AppSettingsDocument settings = new();
+ private IReadOnlyCollection
festivities = [];
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!string.IsNullOrEmpty(DateStr) && DateOnly.TryParseExact(DateStr, "yyyy-MM-dd", out var parsed))
+ {
+ selectedDate = parsed;
+ }
+
+ settings = await AppSettingsService.GetAsync();
+ festivities = FestivitySource.GetFestivities(selectedDate.Year);
+
+ await LoadExistingEntry();
+ RecomputeFlags();
+ RecomputePreview();
+ loaded = true;
+ }
+
+ private async Task LoadExistingEntry()
+ {
+ var existing = await WorkDayService.GetAsync(selectedDate);
+ if (existing is not null)
+ {
+ selectedDayType = existing.DayType;
+ startTimeStr = existing.StartTime?.ToString("HH:mm");
+ actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
+ extraHoursDelta = existing.ExtraHoursDelta;
+ notes = existing.Notes;
+ }
+ else
+ {
+ selectedDayType = DayType.None;
+ startTimeStr = null;
+ actualExitTimeStr = null;
+ extraHoursDelta = 0;
+ notes = null;
+ }
+ }
+
+ private async Task OnDateChanged(ChangeEventArgs e)
+ {
+ if (DateOnly.TryParse(e.Value?.ToString(), out var d))
+ {
+ selectedDate = d;
+ festivities = FestivitySource.GetFestivities(selectedDate.Year);
+ await LoadExistingEntry();
+ RecomputeFlags();
+ RecomputePreview();
+ statusMessage = null;
+ }
+ }
+
+ private void OnDayTypeChanged(ChangeEventArgs e)
+ {
+ if (Enum.TryParse(e.Value?.ToString(), out var dt))
+ {
+ selectedDayType = dt;
+ RecomputePreview();
+ statusMessage = null;
+ }
+ }
+
+ private void OnStartTimeChanged(ChangeEventArgs e)
+ {
+ startTimeStr = e.Value?.ToString();
+ RecomputePreview();
+ statusMessage = null;
+ }
+
+ private void OnActualExitChanged(ChangeEventArgs e)
+ {
+ actualExitTimeStr = e.Value?.ToString();
+ statusMessage = null;
+ }
+
+ private void OnExtraDeltaChanged(ChangeEventArgs e)
+ {
+ if (decimal.TryParse(e.Value?.ToString(), out var val))
+ {
+ extraHoursDelta = val;
+ }
+ RecomputePreview();
+ statusMessage = null;
+ }
+
+ private void RecomputeFlags()
+ {
+ isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
+ isFestivity = festivities.Contains(selectedDate);
+ }
+
+ private void RecomputePreview()
+ {
+ TimeOnly? start = null;
+ if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
+ {
+ start = s;
+ }
+
+ if (selectedDayType is DayType.Work or DayType.Home)
+ {
+ workedHoursBase = settings.StandardWorkHoursPerDay;
+ if (start.HasValue)
+ {
+ var totalHours = settings.StandardWorkHoursPerDay + settings.LunchBreakHours;
+ projectedExitTime = start.Value.Add(TimeSpan.FromHours((double)totalHours));
+ }
+ else
+ {
+ projectedExitTime = null;
+ }
+ }
+ else
+ {
+ workedHoursBase = 0;
+ projectedExitTime = null;
+ }
+
+ workedHoursFinal = workedHoursBase + extraHoursDelta;
+
+ hoursOff = selectedDayType is DayType.Work or DayType.Home
+ ? Math.Max(0, settings.StandardWorkHoursPerDay - workedHoursFinal)
+ : 0;
+
+ grossIncome = workedHoursFinal * settings.HourlyGrossRate;
+ var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
+ netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
+ }
+
+ private async Task SaveAsync()
+ {
+ TimeOnly? start = null;
+ TimeOnly? exit = null;
+
+ if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
+ {
+ start = s;
+ }
+ if (!string.IsNullOrEmpty(actualExitTimeStr) && TimeOnly.TryParse(actualExitTimeStr, out var e2))
+ {
+ exit = e2;
+ }
+
+ var workDay = new WorkDayDocument
+ {
+ Date = selectedDate,
+ DayType = selectedDayType,
+ StartTime = start,
+ ActualExitTime = exit,
+ ExtraHoursDelta = extraHoursDelta,
+ Notes = notes
+ };
+
+ var saved = await WorkDayService.SaveAsync(workDay);
+
+ // Update preview with saved computed values
+ projectedExitTime = saved.ProjectedExitTime;
+ workedHoursBase = saved.WorkedHoursBase;
+ workedHoursFinal = saved.WorkedHoursFinal;
+ hoursOff = saved.HoursOff;
+ grossIncome = saved.GrossIncome;
+ netIncome = saved.NetIncome;
+ isWeekend = saved.IsWeekend;
+ isFestivity = saved.IsItalianFestivity;
+
+ statusMessage = $"Saved at {DateTime.Now:t}";
+ }
+}
diff --git a/Components/_Imports.razor b/Components/_Imports.razor
index fa08d31..1e53c28 100644
--- a/Components/_Imports.razor
+++ b/Components/_Imports.razor
@@ -11,4 +11,6 @@
@using WorkTracker
@using WorkTracker.Components
@using WorkTracker.Domain
+@using WorkTracker.Services.Festivities
@using WorkTracker.Services.Settings
+@using WorkTracker.Services.WorkDays
diff --git a/Domain/CoeffSnapshotDocument.cs b/Domain/CoeffSnapshotDocument.cs
new file mode 100644
index 0000000..220be3a
--- /dev/null
+++ b/Domain/CoeffSnapshotDocument.cs
@@ -0,0 +1,16 @@
+namespace WorkTracker.Domain;
+
+public sealed class CoeffSnapshotDocument
+{
+ public decimal StandardWorkHoursPerDay { get; set; } = 8m;
+
+ public decimal LunchBreakHours { get; set; } = 1m;
+
+ public decimal HourlyGrossRate { get; set; } = 17.5m;
+
+ public decimal ProfitabilityCoefficient { get; set; } = 0.67m;
+
+ public decimal InpsRate { get; set; } = 0.2607m;
+
+ public decimal SubstituteTaxRate { get; set; } = 0.15m;
+}
diff --git a/Domain/MonthlySummaryModel.cs b/Domain/MonthlySummaryModel.cs
new file mode 100644
index 0000000..58cde37
--- /dev/null
+++ b/Domain/MonthlySummaryModel.cs
@@ -0,0 +1,30 @@
+namespace WorkTracker.Domain;
+
+public sealed class MonthlySummaryModel
+{
+ public int Year { get; set; }
+
+ public int Month { get; set; }
+
+ public decimal TotalWorkedHours { get; set; }
+
+ public int OfficeDays { get; set; }
+
+ public int HomeDays { get; set; }
+
+ public int HolidayDays { get; set; }
+
+ public int SickDays { get; set; }
+
+ public int DaysOff { get; set; }
+
+ public int ClosureDays { get; set; }
+
+ public decimal TotalHoursOff { get; set; }
+
+ public decimal TotalGrossIncome { get; set; }
+
+ public decimal TotalNetIncome { get; set; }
+
+ public int TotalWorkingDays { get; set; }
+}
diff --git a/Domain/WorkDayDocument.cs b/Domain/WorkDayDocument.cs
new file mode 100644
index 0000000..664674e
--- /dev/null
+++ b/Domain/WorkDayDocument.cs
@@ -0,0 +1,40 @@
+namespace WorkTracker.Domain;
+
+public sealed class WorkDayDocument
+{
+ public string Id { get; set; } = string.Empty;
+
+ public DateOnly Date { get; set; }
+
+ public TimeOnly? StartTime { get; set; }
+
+ public TimeOnly? ProjectedExitTime { get; set; }
+
+ public TimeOnly? ActualExitTime { get; set; }
+
+ public DayType DayType { get; set; } = DayType.None;
+
+ public decimal ExtraHoursDelta { get; set; }
+
+ public decimal WorkedHoursBase { get; set; }
+
+ public decimal WorkedHoursFinal { get; set; }
+
+ public decimal HoursOff { get; set; }
+
+ public decimal GrossIncome { get; set; }
+
+ public decimal NetIncome { get; set; }
+
+ public bool IsWeekend { get; set; }
+
+ public bool IsItalianFestivity { get; set; }
+
+ public string? Notes { get; set; }
+
+ public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
+
+ public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/Program.cs b/Program.cs
index 00e82cb..758e924 100644
--- a/Program.cs
+++ b/Program.cs
@@ -14,6 +14,7 @@ using WorkTracker.Services.Auth;
using WorkTracker.Services.Festivities;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
+using WorkTracker.Services.WorkDays;
var builder = WebApplication.CreateBuilder(args);
@@ -46,6 +47,7 @@ builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddScoped();
builder.Services.AddHostedService();
var app = builder.Build();
diff --git a/Services/Storage/CouchbaseLiteDatabaseProvider.cs b/Services/Storage/CouchbaseLiteDatabaseProvider.cs
index 99d4e1e..18126d5 100644
--- a/Services/Storage/CouchbaseLiteDatabaseProvider.cs
+++ b/Services/Storage/CouchbaseLiteDatabaseProvider.cs
@@ -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();
diff --git a/Services/WorkDays/CouchbaseLiteWorkDayService.cs b/Services/WorkDays/CouchbaseLiteWorkDayService.cs
new file mode 100644
index 0000000..bfc458d
--- /dev/null
+++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs
@@ -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 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;
+ }
+}
diff --git a/Services/WorkDays/IWorkDayService.cs b/Services/WorkDays/IWorkDayService.cs
new file mode 100644
index 0000000..658fcd9
--- /dev/null
+++ b/Services/WorkDays/IWorkDayService.cs
@@ -0,0 +1,14 @@
+using WorkTracker.Domain;
+
+namespace WorkTracker.Services.WorkDays;
+
+public interface IWorkDayService
+{
+ Task GetAsync(DateOnly date, CancellationToken cancellationToken = default);
+
+ Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
+
+ Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
+
+ Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
+}
diff --git a/plan.md b/plan.md
index db4962f..e55003c 100644
--- a/plan.md
+++ b/plan.md
@@ -3,7 +3,6 @@
## 1) Chosen stack
- **Frontend + backend host**: ASP.NET Core + Blazor Web App (.NET 9)
- **Database**: Couchbase Lite (local embedded database)
-- **Auth approach (single user)**: ASP.NET Core Identity configured and enabled now; registration can be disabled later and one seeded account can be used.
Why this stack:
- Fits CRUD-heavy workflow with strong typing and server-side calculations.
diff --git a/wwwroot/app.css b/wwwroot/app.css
index 73a69d6..210fb13 100644
--- a/wwwroot/app.css
+++ b/wwwroot/app.css
@@ -57,4 +57,47 @@ h1:focus {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
+}
+
+/* Calendar view */
+.calendar-table td.calendar-cell {
+ height: 5rem;
+ vertical-align: top;
+ padding: 0.25rem 0.4rem;
+ cursor: pointer;
+ min-width: 5rem;
+}
+
+.calendar-table td.calendar-cell:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.calendar-day-number {
+ font-weight: bold;
+ font-size: 0.9rem;
+}
+
+.calendar-hours {
+ font-size: 0.75rem;
+ color: #666;
+}
+
+.calendar-weekend {
+ background-color: #ffe0e0 !important;
+}
+
+.calendar-closure {
+ background-color: #fff3cd !important;
+}
+
+.calendar-illness {
+ background-color: #d1ecf1 !important;
+}
+
+.calendar-dayoff {
+ background-color: #e2e3e5 !important;
+}
+
+.calendar-holiday {
+ background-color: #d4edda !important;
}
\ No newline at end of file