diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor
index 58108a5..c54ec93 100644
--- a/Components/Pages/WorkDayEditor.razor
+++ b/Components/Pages/WorkDayEditor.razor
@@ -1,16 +1,17 @@
-@page "/workday"
-@page "/workday/{DateStr}"
+@page "/work-unit"
+@page "/work-unit/{DateStr}"
+@page "/work-unit/{DateStr}/{UnitId}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
-@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
+@inject IJSRuntime JS
-
@@ -68,20 +73,12 @@ else
Computed values
-
-
@(projectedExitTime?.ToString("HH:mm") ?? "—")
+
+
@FormatHours(calculatedWorkedHours)
-
-
@workedHoursBase.ToString("N2")h
-
-
-
-
@workedHoursFinal.ToString("N2")h
-
-
-
-
@hoursOff.ToString("N2")h
+
+
@FormatSignedHours(workedHoursDelta)
@@ -93,8 +90,27 @@ else
+
+
Day Total
+
+
+
+
@FormatHours(dayTotalHours)
+
+
+
+
@dayWorkUnitCount
+
+
+
+
+ @if (isExistingUnit)
+ {
+
+ }
+
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
@statusMessage
@@ -104,29 +120,31 @@ else
@code {
[Parameter] public string? DateStr { get; set; }
+ [Parameter] public string? UnitId { get; set; }
private bool loaded;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
- private DayType selectedDayType = DayType.None;
+ private string unitId = string.Empty;
+ private string label = "Work unit";
+ private WorkUnitLocation location = WorkUnitLocation.Office;
private string? startTimeStr;
- private string? actualExitTimeStr;
- private decimal extraHoursDelta;
+ private string? endTimeStr;
+ private decimal manualWorkedHours;
+ private string manualWorkedHoursStr = "00:00";
+ private bool isPreview;
private string? notes;
private string? statusMessage;
+ private bool isExistingUnit;
+ private WorkDayDocument? selectedDay;
- // 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;
+ private decimal? calculatedWorkedHours;
+ private decimal workedHoursDelta;
+ private decimal dayTotalHours;
+ private int dayWorkUnitCount;
- // Loaded from settings
private AppSettingsDocument settings = new();
- private IReadOnlyCollection festivities = [];
protected override async Task OnInitializedAsync()
{
@@ -136,33 +154,41 @@ else
}
settings = await AppSettingsService.GetAsync();
- festivities = FestivitySource.GetFestivities(selectedDate.Year);
-
- await LoadExistingEntry();
- RecomputeFlags();
- RecomputePreview();
+ await LoadUnitAsync();
loaded = true;
}
- private async Task LoadExistingEntry()
+ private async Task LoadUnitAsync()
{
- var existing = await WorkDayService.GetAsync(selectedDate);
+ if (string.IsNullOrWhiteSpace(UnitId))
+ {
+ selectedDay = await WorkDayService.GetAsync(selectedDate);
+ SetDefaults();
+ return;
+ }
+
+ selectedDay = await WorkDayService.GetAsync(selectedDate);
+ var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId);
if (existing is not null)
{
- selectedDayType = existing.DayType;
+ unitId = existing.Id;
+ label = existing.Label;
+ location = existing.Location;
startTimeStr = existing.StartTime?.ToString("HH:mm");
- actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
- extraHoursDelta = existing.ExtraHoursDelta;
+ endTimeStr = existing.EndTime?.ToString("HH:mm");
+ manualWorkedHours = existing.ManualWorkedHours;
+ manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
+ isPreview = existing.IsPreview;
notes = existing.Notes;
+ isExistingUnit = true;
}
else
{
- selectedDayType = DayType.None;
- startTimeStr = null;
- actualExitTimeStr = null;
- extraHoursDelta = 0;
- notes = null;
+ SetDefaults();
+ statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
}
+
+ RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
@@ -170,127 +196,214 @@ else
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)
+ private Task OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
- RecomputePreview();
+ SyncManualHoursToCalculated();
statusMessage = null;
+ return Task.CompletedTask;
}
- private void OnActualExitChanged(ChangeEventArgs e)
+ private Task OnEndTimeChanged(ChangeEventArgs e)
{
- actualExitTimeStr = e.Value?.ToString();
+ endTimeStr = e.Value?.ToString();
+ SyncManualHoursToCalculated();
statusMessage = null;
+ return Task.CompletedTask;
}
- private void OnExtraDeltaChanged(ChangeEventArgs e)
+ private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
{
- if (decimal.TryParse(e.Value?.ToString(), out var val))
+ var rawValue = e.Value?.ToString();
+ if (TryParseDurationHours(rawValue, out var parsedHours))
{
- extraHoursDelta = val;
+ manualWorkedHours = parsedHours;
+ manualWorkedHoursStr = FormatDurationHours(parsedHours);
}
+ else
+ {
+ manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
+ }
+
RecomputePreview();
statusMessage = null;
+ return Task.CompletedTask;
}
- private void RecomputeFlags()
+ private void SetDefaults()
{
- isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
- isFestivity = festivities.Contains(selectedDate);
+ unitId = string.Empty;
+ label = "Work unit";
+ location = WorkUnitLocation.Office;
+ startTimeStr = null;
+ endTimeStr = null;
+ manualWorkedHours = 0m;
+ manualWorkedHoursStr = "00:00";
+ isPreview = false;
+ notes = null;
+ isExistingUnit = false;
+ RecomputePreview();
+ }
+
+ private void SyncManualHoursToCalculated()
+ {
+ var calculated = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
+ manualWorkedHours = calculated ?? 0m;
+ manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
+ RecomputePreview();
}
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;
+ calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
+ workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m);
+ grossIncome = manualWorkedHours * settings.HourlyGrossRate;
var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
+ RecomputeDayTotals();
}
private async Task SaveAsync()
{
- TimeOnly? start = null;
- TimeOnly? exit = null;
+ RecomputePreview();
- if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
+ var workUnit = new WorkUnitDocument
{
- 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,
+ Id = unitId,
+ Label = label,
+ Location = location,
+ StartTime = ParseTime(startTimeStr),
+ EndTime = ParseTime(endTimeStr),
+ ManualWorkedHours = Math.Max(0m, manualWorkedHours),
+ IsPreview = isPreview,
Notes = notes
};
- var saved = await WorkDayService.SaveAsync(workDay);
+ var saved = await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
- // Update preview with saved computed values
- projectedExitTime = saved.ProjectedExitTime;
- workedHoursBase = saved.WorkedHoursBase;
- workedHoursFinal = saved.WorkedHoursFinal;
- hoursOff = saved.HoursOff;
+ unitId = saved.Id;
+ isExistingUnit = true;
+ label = saved.Label;
+ location = saved.Location;
+ startTimeStr = saved.StartTime?.ToString("HH:mm");
+ endTimeStr = saved.EndTime?.ToString("HH:mm");
+ manualWorkedHours = saved.ManualWorkedHours;
+ manualWorkedHoursStr = FormatDurationHours(saved.ManualWorkedHours);
+ isPreview = saved.IsPreview;
+ notes = saved.Notes;
+ calculatedWorkedHours = saved.CalculatedWorkedHours;
+ workedHoursDelta = saved.WorkedHoursDelta;
grossIncome = saved.GrossIncome;
netIncome = saved.NetIncome;
- isWeekend = saved.IsWeekend;
- isFestivity = saved.IsItalianFestivity;
- statusMessage = $"Saved at {DateTime.Now:t}";
+ Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
+ }
+
+ private async Task DeleteAsync()
+ {
+ if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
+ {
+ return;
+ }
+
+ var confirmed = await JS.InvokeAsync("confirm", $"Delete work unit '{label}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
+ if (!confirmed)
+ {
+ return;
+ }
+
+ var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
+ if (deleted)
+ {
+ Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
+ return;
+ }
+
+ statusMessage = "Unable to delete the work unit.";
+ }
+
+ private void BackToCalendar()
+ {
+ Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
+ }
+
+ private void RecomputeDayTotals()
+ {
+ var existingUnits = selectedDay?.WorkUnits ?? [];
+ dayTotalHours = existingUnits
+ .Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
+ .Sum(unit => unit.ManualWorkedHours) + manualWorkedHours;
+
+ dayWorkUnitCount = existingUnits
+ .Where(unit => !string.Equals(unit.Id, unitId, StringComparison.Ordinal))
+ .Count() + 1;
+ }
+
+ private static TimeOnly? ParseTime(string? value)
+ {
+ return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed)
+ ? parsed
+ : null;
+ }
+
+ 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 bool TryParseDurationHours(string? value, out decimal hours)
+ {
+ hours = 0m;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return true;
+ }
+
+ if (TimeSpan.TryParseExact(value, [@"h\:mm", @"hh\:mm"], null, out var timeSpan))
+ {
+ hours = Math.Round((decimal)timeSpan.TotalMinutes / 60m, 2, MidpointRounding.AwayFromZero);
+ return true;
+ }
+
+ if (decimal.TryParse(value, out var decimalHours))
+ {
+ hours = Math.Max(0m, decimalHours);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—";
+
+ private static string FormatSignedHours(decimal value) => value switch
+ {
+ > 0 => $"+{FormatDurationHours(value)}",
+ < 0 => $"-{FormatDurationHours(Math.Abs(value))}",
+ _ => "00:00"
+ };
+
+ private static string FormatDurationHours(decimal value)
+ {
+ var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
+ var sign = totalMinutes < 0 ? "-" : string.Empty;
+ totalMinutes = Math.Abs(totalMinutes);
+ var hours = totalMinutes / 60;
+ var minutes = totalMinutes % 60;
+ return $"{sign}{hours:00}:{minutes:00}";
+ }
+
+ protected override void OnParametersSet()
+ {
+ RecomputePreview();
}
}
diff --git a/Domain/AppSettingsDocument.cs b/Domain/AppSettingsDocument.cs
index d7fa2a1..a3a93d1 100644
--- a/Domain/AppSettingsDocument.cs
+++ b/Domain/AppSettingsDocument.cs
@@ -6,8 +6,6 @@ public sealed class AppSettingsDocument
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;
diff --git a/Domain/CalendarEventDocument.cs b/Domain/CalendarEventDocument.cs
new file mode 100644
index 0000000..22fb24f
--- /dev/null
+++ b/Domain/CalendarEventDocument.cs
@@ -0,0 +1,20 @@
+namespace WorkTracker.Domain;
+
+public sealed class CalendarEventDocument
+{
+ public string Id { get; set; } = string.Empty;
+
+ public CalendarEventType EventType { get; set; } = CalendarEventType.Generic;
+
+ public string Description { get; set; } = string.Empty;
+
+ public TimeOnly? StartTime { get; set; }
+
+ public TimeOnly? EndTime { get; set; }
+
+ public decimal? DurationHours { get; set; }
+
+ public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+
+ public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+}
\ No newline at end of file
diff --git a/Domain/CalendarEventType.cs b/Domain/CalendarEventType.cs
new file mode 100644
index 0000000..7a7b6b9
--- /dev/null
+++ b/Domain/CalendarEventType.cs
@@ -0,0 +1,10 @@
+namespace WorkTracker.Domain;
+
+public enum CalendarEventType
+{
+ Generic = 0,
+ DayOff = 1,
+ Closure = 2,
+ Holiday = 3,
+ Illness = 4
+}
\ No newline at end of file
diff --git a/Domain/CoeffSnapshotDocument.cs b/Domain/CoeffSnapshotDocument.cs
index 220be3a..347866f 100644
--- a/Domain/CoeffSnapshotDocument.cs
+++ b/Domain/CoeffSnapshotDocument.cs
@@ -4,8 +4,6 @@ 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;
diff --git a/Domain/MonthlySummaryModel.cs b/Domain/MonthlySummaryModel.cs
index 58cde37..254a7c9 100644
--- a/Domain/MonthlySummaryModel.cs
+++ b/Domain/MonthlySummaryModel.cs
@@ -8,6 +8,12 @@ public sealed class MonthlySummaryModel
public decimal TotalWorkedHours { get; set; }
+ public decimal TotalPreviewWorkedHours { get; set; }
+
+ public int CountedWorkUnits { get; set; }
+
+ public int PreviewWorkUnits { get; set; }
+
public int OfficeDays { get; set; }
public int HomeDays { get; set; }
diff --git a/Domain/MonthlyTimesheetDaySummary.cs b/Domain/MonthlyTimesheetDaySummary.cs
new file mode 100644
index 0000000..e7687f7
--- /dev/null
+++ b/Domain/MonthlyTimesheetDaySummary.cs
@@ -0,0 +1,26 @@
+namespace WorkTracker.Domain;
+
+public sealed class MonthlyTimesheetDaySummary
+{
+ public DateOnly Date { get; set; }
+
+ public decimal OfficeHours { get; set; }
+
+ public decimal HomeHours { get; set; }
+
+ public decimal OvertimeHours { get; set; }
+
+ public decimal WeekendHours { get; set; }
+
+ public decimal NightHours { get; set; }
+
+ public decimal VacationDays { get; set; }
+
+ public decimal PermitHours { get; set; }
+
+ public decimal CompensatoryRestDays { get; set; }
+
+ public decimal SickDays { get; set; }
+
+ public decimal HolidayDays { get; set; }
+}
\ No newline at end of file
diff --git a/Domain/MonthlyTimesheetModel.cs b/Domain/MonthlyTimesheetModel.cs
new file mode 100644
index 0000000..5b0ff34
--- /dev/null
+++ b/Domain/MonthlyTimesheetModel.cs
@@ -0,0 +1,46 @@
+namespace WorkTracker.Domain;
+
+public sealed class MonthlyTimesheetModel
+{
+ public int Year { get; set; }
+
+ public int Month { get; set; }
+
+ public List Days { get; set; } = [];
+
+ public List Rows { get; set; } = [];
+}
+
+public sealed class MonthlyTimesheetDayModel
+{
+ public DateOnly Date { get; set; }
+
+ public bool IsWeekend { get; set; }
+
+ public bool IsHoliday { get; set; }
+
+ public bool IsClosure { get; set; }
+
+ public List WorkUnitSummaries { get; set; } = [];
+
+ public List EventSummaries { get; set; } = [];
+}
+
+public sealed class MonthlyTimesheetRowModel
+{
+ public string Key { get; set; } = string.Empty;
+
+ public string Label { get; set; } = string.Empty;
+
+ public MonthlyTimesheetValueFormat ValueFormat { get; set; }
+
+ public List DailyValues { get; set; } = [];
+
+ public decimal? Total { get; set; }
+}
+
+public enum MonthlyTimesheetValueFormat
+{
+ Hours = 0,
+ Days = 1
+}
\ No newline at end of file
diff --git a/Domain/WorkDayDocument.cs b/Domain/WorkDayDocument.cs
index 664674e..f56d4b8 100644
--- a/Domain/WorkDayDocument.cs
+++ b/Domain/WorkDayDocument.cs
@@ -6,33 +6,13 @@ public sealed class WorkDayDocument
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 List WorkUnits { get; set; } = [];
- public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
+ public List CalendarEvents { get; set; } = [];
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
diff --git a/Domain/WorkUnitDocument.cs b/Domain/WorkUnitDocument.cs
new file mode 100644
index 0000000..3803c61
--- /dev/null
+++ b/Domain/WorkUnitDocument.cs
@@ -0,0 +1,34 @@
+namespace WorkTracker.Domain;
+
+public sealed class WorkUnitDocument
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string Label { get; set; } = "Work unit";
+
+ public WorkUnitLocation Location { get; set; } = WorkUnitLocation.Office;
+
+ public TimeOnly? StartTime { get; set; }
+
+ public TimeOnly? EndTime { get; set; }
+
+ public bool IsPreview { get; set; }
+
+ public decimal ManualWorkedHours { get; set; }
+
+ public decimal CalculatedWorkedHours { get; set; }
+
+ public decimal WorkedHoursDelta { get; set; }
+
+ public decimal GrossIncome { get; set; }
+
+ public decimal NetIncome { 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;
+}
\ No newline at end of file
diff --git a/Domain/WorkUnitLocation.cs b/Domain/WorkUnitLocation.cs
new file mode 100644
index 0000000..6c40a05
--- /dev/null
+++ b/Domain/WorkUnitLocation.cs
@@ -0,0 +1,7 @@
+namespace WorkTracker.Domain;
+
+public enum WorkUnitLocation
+{
+ Office = 1,
+ Home = 2
+}
\ No newline at end of file
diff --git a/README.Docker.md b/README.Docker.md
index 67a7a0d..7cfede9 100644
--- a/README.Docker.md
+++ b/README.Docker.md
@@ -8,6 +8,51 @@ Quick run (Docker Engine required):
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
+Production deployment:
+
+- Put deployment values in a `.env` file next to `docker-compose.yml` or export them in the shell before running Docker Compose.
+- The base compose file is now parameterized for host port, persisted storage path, image tag, auth mode, seeded user credentials, allowed hosts, and healthcheck timings.
+- For a direct production deployment, define at least `WORKTRACKER_DATA_PATH`. If you enable the built-in login flow, also define `APPAUTH_ENABLED=true` and a strong `SINGLEUSER_PASSWORD` before first startup.
+
+Deployment variables:
+
+| Variable | Required for production | Default | Purpose |
+| --- | --- | --- | --- |
+| `WORKTRACKER_DATA_PATH` | Yes | `./.docker-data/couchbase` | Host path mounted to `/data/couchbase` so the embedded Couchbase Lite database survives container replacement. |
+| `WORKTRACKER_PORT` | Usually | `8002` | Host port published for the app container. |
+| `IMAGE_REGISTRY` | No | `worktracker` | Image repository/name used by the `worktracker` service, for example `ghcr.io/your-org/worktracker`. |
+| `IMAGE_TAG` | Usually | `latest` | Image tag to deploy. Pin this to a release tag instead of using `latest`. |
+| `ASPNETCORE_ENVIRONMENT` | No | `Production` | ASP.NET Core environment name. Keep this as `Production` for real deployments. |
+| `ASPNETCORE_FORWARDEDHEADERS_ENABLED` | Recommended | `true` | Enables forwarded header handling when the app runs behind a reverse proxy or Zero Trust tunnel. |
+| `ALLOWED_HOSTS` | Recommended | `*` | ASP.NET Core allowed hostnames. Set this to your public hostname instead of leaving it wide open. |
+| `USE_HTTPS_REDIRECTION` | Depends | `false` | Enables ASP.NET Core HTTPS redirection. Leave this `false` when TLS terminates upstream unless you have verified forwarded headers and redirect behavior. |
+| `COUCHBASELITE_DATABASE_NAME` | No | `worktracker` | Embedded Couchbase Lite database name. |
+| `APPAUTH_ENABLED` | Yes, choose a mode explicitly | `false` | Enables the built-in login flow when `true`. Leave `false` only if access is protected upstream and you want zero-trust style default-admin passthrough. |
+| `APPAUTH_DEFAULT_USERNAME` | When `APPAUTH_ENABLED=false` | `Admin` | Display name injected for every request while built-in auth is disabled. |
+| `APPAUTH_DEFAULT_USERID` | When `APPAUTH_ENABLED=false` | `ADMIN` | User identifier injected for every request while built-in auth is disabled. |
+| `SINGLEUSER_SEED_ON_STARTUP` | No | `true` | Seeds the built-in admin account into Couchbase Lite on first startup if it does not exist yet. |
+| `SINGLEUSER_USERNAME` | When `APPAUTH_ENABLED=true` | `Admin` | Username for the seeded built-in account. |
+| `SINGLEUSER_PASSWORD` | When `APPAUTH_ENABLED=true` and the database is new | `Disagio` | Initial password for the seeded built-in account. Set this to a strong secret before the first production start. |
+| `WORKTRACKER_HEALTHCHECK_INTERVAL` | No | `30s` | Docker healthcheck interval. |
+| `WORKTRACKER_HEALTHCHECK_TIMEOUT` | No | `5s` | Docker healthcheck timeout. |
+| `WORKTRACKER_HEALTHCHECK_START_PERIOD` | No | `10s` | Grace period before Docker starts evaluating health. |
+| `WORKTRACKER_HEALTHCHECK_RETRIES` | No | `3` | Consecutive healthcheck failures before the container is marked unhealthy. |
+
+Example production `.env`:
+
+```dotenv
+WORKTRACKER_DATA_PATH=/srv/worktracker/couchbase
+WORKTRACKER_PORT=8002
+IMAGE_REGISTRY=ghcr.io/your-org/worktracker
+IMAGE_TAG=2026.04.20
+ALLOWED_HOSTS=worktracker.example.com
+ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
+USE_HTTPS_REDIRECTION=false
+APPAUTH_ENABLED=true
+SINGLEUSER_USERNAME=admin
+SINGLEUSER_PASSWORD=replace-with-a-strong-secret
+```
+
Authentication mode:
- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
@@ -39,7 +84,7 @@ Debugging in Docker from VS Code:
- Use the `WorkTracker: Debug in Docker` launch configuration.
- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
-- When the app reports that it is listening, VS Code automatically opens Microsoft Edge in browser debug mode against http://localhost:8002.
+- VS Code waits for the app to report that it is listening before opening Microsoft Edge in browser debug mode against http://localhost:8002.
- The app remains available at http://localhost:8002 while the debugger is attached.
- Stopping the debug session runs `docker compose down` for the debug stack.
diff --git a/Services/Settings/CouchbaseLiteAppSettingsService.cs b/Services/Settings/CouchbaseLiteAppSettingsService.cs
index 66e8040..fc934bf 100644
--- a/Services/Settings/CouchbaseLiteAppSettingsService.cs
+++ b/Services/Settings/CouchbaseLiteAppSettingsService.cs
@@ -48,7 +48,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
{
var document = new MutableDocument(DefaultSettingsId);
document.SetDouble("standardWorkHoursPerDay", Decimal.ToDouble(settings.StandardWorkHoursPerDay));
- document.SetDouble("lunchBreakHours", Decimal.ToDouble(settings.LunchBreakHours));
document.SetDouble("hourlyGrossRate", Decimal.ToDouble(settings.HourlyGrossRate));
document.SetDouble("profitabilityCoefficient", Decimal.ToDouble(settings.ProfitabilityCoefficient));
document.SetDouble("inpsRate", Decimal.ToDouble(settings.InpsRate));
@@ -67,7 +66,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
{
Id = document.Id,
StandardWorkHoursPerDay = ReadDecimal(document, "standardWorkHoursPerDay", 8m),
- LunchBreakHours = ReadDecimal(document, "lunchBreakHours", 1m),
HourlyGrossRate = ReadDecimal(document, "hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(document, "profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(document, "inpsRate", 0.2607m),
diff --git a/Services/WorkDays/CouchbaseLiteWorkDayService.cs b/Services/WorkDays/CouchbaseLiteWorkDayService.cs
index bfc458d..27a9b1d 100644
--- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs
+++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs
@@ -31,45 +31,132 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult(doc is not null ? Map(doc) : null);
}
- public async Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
+ 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 festivities = festivitySource.GetFestivities(workDay.Date.Year);
+ 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;
- 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
+ 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,
- LunchBreakHours = settings.LunchBreakHours,
HourlyGrossRate = settings.HourlyGrossRate,
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
InpsRate = settings.InpsRate,
SubstituteTaxRate = settings.SubstituteTaxRate
};
+ workUnit.CreatedAtUtc = existingCreatedAt;
+ workUnit.UpdatedAtUtc = now;
- Compute(workDay);
+ Compute(workUnit);
- // Preserve creation timestamp for existing documents
- var existing = workDaysCollection.GetDocument(workDay.Id);
- if (existing is not null)
+ if (existingIndex >= 0)
{
- workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
+ day.WorkUnits[existingIndex] = workUnit;
}
else
{
- workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
+ day.WorkUnits.Add(workUnit);
}
- workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
+ day.UpdatedAtUtc = now;
+ SortEntries(day);
+ SaveDocument(day);
+ return workUnit;
+ }
- SaveDocument(workDay);
- return workDay;
+ 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)
@@ -90,88 +177,180 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult>(results);
}
- public async Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
+ public async Task GetMonthlySummaryAsync(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 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 = 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)
+ 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 void Compute(WorkDayDocument day)
+ public async Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
{
- var coeff = day.CoeffSnapshot;
+ 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);
- // Calculate projected exit time
- if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
+ var daySummaries = new List();
+ for (var date = from; date <= to; date = date.AddDays(1))
{
- var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
- day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
- }
- else
- {
- day.ProjectedExitTime = null;
+ dayLookup.TryGetValue(date, out var day);
+ daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
}
- // Calculate worked hours
- day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
- ? coeff.StandardWorkHoursPerDay
- : 0m;
+ 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))
+ ]
+ };
+ }
- day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
+ public async Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- // 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;
+ 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;
- // Income calculations
- day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
- var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
- day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
+ 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.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));
+ 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"));
@@ -179,39 +358,426 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
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"),
- 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)
- },
+ 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 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 value == decimal.Truncate(value)
+ ? value.ToString("0")
+ : value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
+ }
+
+ 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);
@@ -220,6 +786,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: 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)
@@ -227,6 +801,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: 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);
@@ -234,4 +815,12 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
? 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;
+ }
}
diff --git a/Services/WorkDays/IWorkDayService.cs b/Services/WorkDays/IWorkDayService.cs
index 658fcd9..c6bc5b8 100644
--- a/Services/WorkDays/IWorkDayService.cs
+++ b/Services/WorkDays/IWorkDayService.cs
@@ -6,9 +6,23 @@ public interface IWorkDayService
{
Task GetAsync(DateOnly date, CancellationToken cancellationToken = default);
- Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
+ Task GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
+
+ Task GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
+
+ Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default);
+
+ Task SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default);
+
+ Task DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
+
+ Task DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
- Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
+ Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
+
+ Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
+
+ Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 08d2020..981a99f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,16 +5,27 @@ services:
dockerfile: Dockerfile
image: ${IMAGE_REGISTRY:-worktracker}:${IMAGE_TAG:-latest}
environment:
- ASPNETCORE_ENVIRONMENT: Production
- UseHttpsRedirection: "false"
+ ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production}
+ ASPNETCORE_URLS: http://+:8080
+ ASPNETCORE_FORWARDEDHEADERS_ENABLED: ${ASPNETCORE_FORWARDEDHEADERS_ENABLED:-true}
+ AllowedHosts: ${ALLOWED_HOSTS:-*}
+ UseHttpsRedirection: ${USE_HTTPS_REDIRECTION:-false}
+ CouchbaseLite__DatabaseName: ${COUCHBASELITE_DATABASE_NAME:-worktracker}
CouchbaseLite__Directory: /data/couchbase
+ AppAuth__Enabled: ${APPAUTH_ENABLED:-false}
+ AppAuth__DefaultUsername: ${APPAUTH_DEFAULT_USERNAME:-Admin}
+ AppAuth__DefaultUserId: ${APPAUTH_DEFAULT_USERID:-ADMIN}
+ SingleUser__SeedOnStartup: ${SINGLEUSER_SEED_ON_STARTUP:-true}
+ SingleUser__Username: ${SINGLEUSER_USERNAME:-Admin}
+ SingleUser__Password: ${SINGLEUSER_PASSWORD:-Disagio}
ports:
- - "8002:8080"
+ - "${WORKTRACKER_PORT:-8002}:8080"
volumes:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
- interval: 30s
- timeout: 5s
- retries: 3
+ interval: ${WORKTRACKER_HEALTHCHECK_INTERVAL:-30s}
+ timeout: ${WORKTRACKER_HEALTHCHECK_TIMEOUT:-5s}
+ start_period: ${WORKTRACKER_HEALTHCHECK_START_PERIOD:-10s}
+ retries: ${WORKTRACKER_HEALTHCHECK_RETRIES:-3}
diff --git a/tests/playwright/auth-bypass.spec.ts b/tests/playwright/auth-bypass.spec.ts
index 5250760..670b5e8 100644
--- a/tests/playwright/auth-bypass.spec.ts
+++ b/tests/playwright/auth-bypass.spec.ts
@@ -1,5 +1,30 @@
import { expect, test } from '@playwright/test';
+test.describe('sidebar collapse', () => {
+ test('starts collapsed and expands through the toggle button', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 960 });
+ await page.goto('/');
+
+ const sidebar = page.getByTestId('sidebar-shell');
+ const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
+
+ await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
+
+ await toggle.click();
+
+ await expect(sidebar).toHaveAttribute('data-collapsed', 'false');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ await expect(page.getByText('Dashboard')).toBeVisible();
+
+ await toggle.click();
+
+ await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ });
+});
+
test('home loads without a login screen', async ({ page }) => {
await page.goto('/');
@@ -14,7 +39,7 @@ test('protected pages are directly available without redirecting to login', asyn
{ path: '/calendar', heading: 'Calendar' },
{ path: '/summary', heading: 'Monthly Summary' },
{ path: '/settings', heading: 'Settings' },
- { path: '/workday', heading: 'Work Day Entry' },
+ { path: '/work-unit', heading: 'Work Unit' },
{ path: '/auth', heading: 'You are authenticated' }
];
diff --git a/wwwroot/app.css b/wwwroot/app.css
index 210fb13..071fd5d 100644
--- a/wwwroot/app.css
+++ b/wwwroot/app.css
@@ -61,20 +61,35 @@ h1:focus {
/* Calendar view */
.calendar-table td.calendar-cell {
- height: 5rem;
+ height: 10rem;
vertical-align: top;
padding: 0.25rem 0.4rem;
cursor: pointer;
min-width: 5rem;
+ position: relative;
}
.calendar-table td.calendar-cell:hover {
background-color: rgba(0, 0, 0, 0.05);
}
+.calendar-cell-active {
+ box-shadow: inset 0 0 0 0.15rem #1b6ec2;
+}
+
.calendar-day-number {
font-weight: bold;
font-size: 0.9rem;
+ margin-bottom: 0.3rem;
+}
+
+.calendar-day-total {
+ margin-top: auto;
+ padding-top: 0.25rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-align: right;
+ color: #334155;
}
.calendar-hours {
@@ -82,6 +97,118 @@ h1:focus {
color: #666;
}
+.calendar-item {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ width: 100%;
+ border: 0;
+ border-radius: 0.45rem;
+ font-size: 0.72rem;
+ margin-bottom: 0.2rem;
+ padding: 0.2rem 0.35rem;
+ text-align: left;
+}
+
+.calendar-item-work {
+ color: #14213d;
+}
+
+.calendar-item-office {
+ background-color: #cfe2ff;
+}
+
+.calendar-item-home {
+ background-color: #d1e7dd;
+}
+
+.calendar-item-preview-office {
+ background-color: rgba(207, 226, 255, 0.55);
+ border: 1px dashed #6c8ebf;
+}
+
+.calendar-item-preview-home {
+ background-color: rgba(209, 231, 221, 0.55);
+ border: 1px dashed #5b8a72;
+}
+
+.calendar-item-event {
+ color: #fff;
+}
+
+.calendar-item-generic {
+ background-color: #6c757d;
+}
+
+.calendar-item-dayoff {
+ background-color: #6c757d;
+}
+
+.calendar-item-closure {
+ background-color: #b08900;
+}
+
+.calendar-item-holiday {
+ background-color: #b02a37;
+}
+
+.calendar-item-illness {
+ background-color: #0c8599;
+}
+
+.calendar-popup {
+ position: absolute;
+ top: 2rem;
+ left: 0.35rem;
+ z-index: 20;
+ width: min(16rem, calc(100vw - 2rem));
+ max-width: calc(100vw - 2rem);
+ background: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 0.75rem;
+ box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.18);
+ padding: 0.75rem;
+}
+
+.calendar-popup-right {
+ left: auto;
+ right: 0.35rem;
+}
+
+.calendar-popup-left {
+ left: 0.35rem;
+ right: auto;
+}
+
+.calendar-popup-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.calendar-popup-link {
+ border: 0;
+ border-radius: 0.5rem;
+ background: #f1f3f5;
+ padding: 0.45rem 0.6rem;
+ text-align: left;
+}
+
+.calendar-legend-work {
+ background-color: #cfe2ff;
+ color: #14213d;
+}
+
+.calendar-legend-home {
+ background-color: #d1e7dd;
+ color: #1d3b2a;
+}
+
+.calendar-legend-preview {
+ background-color: #fff3cd;
+ color: #6b4f00;
+}
+
.calendar-weekend {
background-color: #ffe0e0 !important;
}
@@ -100,4 +227,136 @@ h1:focus {
.calendar-holiday {
background-color: #d4edda !important;
+}
+
+@media (max-width: 767.98px) {
+ .calendar-table td.calendar-cell {
+ height: 8rem;
+ min-width: 7rem;
+ }
+
+ .calendar-popup {
+ left: 0;
+ width: calc(100vw - 2rem);
+ }
+}
+
+/* Monthly timesheet summary */
+.timesheet-summary-card {
+ overflow: hidden;
+}
+
+.timesheet-summary-table {
+ min-width: max-content;
+}
+
+.timesheet-summary-table thead th {
+ background-color: #f8f9fa;
+ white-space: nowrap;
+}
+
+.timesheet-summary-table th,
+.timesheet-summary-table td {
+ min-width: 2.2rem;
+ padding: 0.25rem 0.12rem;
+ vertical-align: middle;
+}
+
+.timesheet-summary-sticky-column {
+ position: sticky;
+ left: 0;
+ z-index: 2;
+ min-width: 15rem !important;
+ background-color: #fff;
+}
+
+.timesheet-summary-table thead .timesheet-summary-sticky-column {
+ z-index: 3;
+ background-color: #f8f9fa;
+}
+
+.timesheet-summary-total-column {
+ background-color: #f8f9fa;
+ min-width: 3.3rem !important;
+}
+
+.timesheet-summary-table tbody tr:nth-child(odd) td,
+.timesheet-summary-table tbody tr:nth-child(odd) .timesheet-summary-sticky-column {
+ background-color: #fcfcfd;
+}
+
+.timesheet-summary-table .timesheet-summary-day-danger {
+ background-color: #f8d7da !important;
+}
+
+.timesheet-summary-table .timesheet-summary-day-closure {
+ background-color: #e2e3e5 !important;
+}
+
+.timesheet-summary-day-header {
+ position: relative;
+ cursor: default;
+}
+
+.timesheet-summary-day-popup {
+ position: absolute;
+ top: calc(100% + 0.35rem);
+ left: 50%;
+ z-index: 15;
+ width: min(18rem, calc(100vw - 2rem));
+ max-width: calc(100vw - 2rem);
+ padding: 0.65rem 0.75rem;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ border-radius: 0.7rem;
+ background: #fff;
+ box-shadow: 0 0.75rem 2rem rgba(15, 23, 42, 0.18);
+ text-align: left;
+ transform: translateX(-50%);
+ opacity: 0;
+ pointer-events: none;
+ visibility: hidden;
+}
+
+.timesheet-summary-day-header:hover .timesheet-summary-day-popup,
+.timesheet-summary-day-header:focus-within .timesheet-summary-day-popup {
+ opacity: 1;
+ visibility: visible;
+}
+
+.timesheet-summary-day-popup-left .timesheet-summary-day-popup {
+ left: 0;
+ transform: none;
+}
+
+.timesheet-summary-day-popup-right .timesheet-summary-day-popup {
+ left: auto;
+ right: 0;
+ transform: none;
+}
+
+.timesheet-summary-day-popup-item {
+ font-size: 0.75rem;
+ line-height: 1.35;
+ color: #1f2937;
+}
+
+.timesheet-summary-day-popup-item + .timesheet-summary-day-popup-item {
+ margin-top: 0.35rem;
+}
+
+.timesheet-summary-day-popup-item-event {
+ color: #475569;
+}
+
+@media (max-width: 767.98px) {
+ .timesheet-summary-sticky-column {
+ min-width: 12rem !important;
+ }
+
+ .timesheet-summary-day-popup {
+ left: 0;
+ right: 0;
+ width: auto;
+ transform: none;
+ }
}
\ No newline at end of file