diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor
index c54ec93..58108a5 100644
--- a/Components/Pages/WorkDayEditor.razor
+++ b/Components/Pages/WorkDayEditor.razor
@@ -1,17 +1,16 @@
-@page "/work-unit"
-@page "/work-unit/{DateStr}"
-@page "/work-unit/{DateStr}/{UnitId}"
+@page "/workday"
+@page "/workday/{DateStr}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
+@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
-@inject IJSRuntime JS
-
@@ -73,12 +68,20 @@ else
Computed values
-
-
@FormatHours(calculatedWorkedHours)
+
+
@(projectedExitTime?.ToString("HH:mm") ?? "—")
-
-
@FormatSignedHours(workedHoursDelta)
+
+
@workedHoursBase.ToString("N2")h
+
+
+
+
@workedHoursFinal.ToString("N2")h
+
+
+
+
@hoursOff.ToString("N2")h
@@ -90,27 +93,8 @@ else
-
-
Day Total
-
-
-
-
@FormatHours(dayTotalHours)
-
-
-
-
@dayWorkUnitCount
-
-
-
-
- @if (isExistingUnit)
- {
-
- }
-
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
@statusMessage
@@ -120,31 +104,29 @@ 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 string unitId = string.Empty;
- private string label = "Work unit";
- private WorkUnitLocation location = WorkUnitLocation.Office;
+ private DayType selectedDayType = DayType.None;
private string? startTimeStr;
- private string? endTimeStr;
- private decimal manualWorkedHours;
- private string manualWorkedHoursStr = "00:00";
- private bool isPreview;
+ private string? actualExitTimeStr;
+ private decimal extraHoursDelta;
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 decimal? calculatedWorkedHours;
- private decimal workedHoursDelta;
- private decimal dayTotalHours;
- private int dayWorkUnitCount;
+ private bool isWeekend;
+ private bool isFestivity;
+ // Loaded from settings
private AppSettingsDocument settings = new();
+ private IReadOnlyCollection festivities = [];
protected override async Task OnInitializedAsync()
{
@@ -154,41 +136,33 @@ else
}
settings = await AppSettingsService.GetAsync();
- await LoadUnitAsync();
+ festivities = FestivitySource.GetFestivities(selectedDate.Year);
+
+ await LoadExistingEntry();
+ RecomputeFlags();
+ RecomputePreview();
loaded = true;
}
- private async Task LoadUnitAsync()
+ private async Task LoadExistingEntry()
{
- if (string.IsNullOrWhiteSpace(UnitId))
- {
- selectedDay = await WorkDayService.GetAsync(selectedDate);
- SetDefaults();
- return;
- }
-
- selectedDay = await WorkDayService.GetAsync(selectedDate);
- var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId);
+ var existing = await WorkDayService.GetAsync(selectedDate);
if (existing is not null)
{
- unitId = existing.Id;
- label = existing.Label;
- location = existing.Location;
+ selectedDayType = existing.DayType;
startTimeStr = existing.StartTime?.ToString("HH:mm");
- endTimeStr = existing.EndTime?.ToString("HH:mm");
- manualWorkedHours = existing.ManualWorkedHours;
- manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
- isPreview = existing.IsPreview;
+ actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
+ extraHoursDelta = existing.ExtraHoursDelta;
notes = existing.Notes;
- isExistingUnit = true;
}
else
{
- SetDefaults();
- statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
+ selectedDayType = DayType.None;
+ startTimeStr = null;
+ actualExitTimeStr = null;
+ extraHoursDelta = 0;
+ notes = null;
}
-
- RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
@@ -196,214 +170,127 @@ else
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
+ festivities = FestivitySource.GetFestivities(selectedDate.Year);
+ await LoadExistingEntry();
+ RecomputeFlags();
+ RecomputePreview();
statusMessage = null;
}
}
- private Task OnStartTimeChanged(ChangeEventArgs e)
+ 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();
- SyncManualHoursToCalculated();
+ RecomputePreview();
statusMessage = null;
- return Task.CompletedTask;
}
- private Task OnEndTimeChanged(ChangeEventArgs e)
+ private void OnActualExitChanged(ChangeEventArgs e)
{
- endTimeStr = e.Value?.ToString();
- SyncManualHoursToCalculated();
+ actualExitTimeStr = e.Value?.ToString();
statusMessage = null;
- return Task.CompletedTask;
}
- private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
+ private void OnExtraDeltaChanged(ChangeEventArgs e)
{
- var rawValue = e.Value?.ToString();
- if (TryParseDurationHours(rawValue, out var parsedHours))
+ if (decimal.TryParse(e.Value?.ToString(), out var val))
{
- manualWorkedHours = parsedHours;
- manualWorkedHoursStr = FormatDurationHours(parsedHours);
+ extraHoursDelta = val;
}
- else
- {
- manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
- }
-
RecomputePreview();
statusMessage = null;
- return Task.CompletedTask;
}
- private void SetDefaults()
+ private void RecomputeFlags()
{
- 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();
+ isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
+ isFestivity = festivities.Contains(selectedDate);
}
private void RecomputePreview()
{
- calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
- workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m);
- grossIncome = manualWorkedHours * settings.HourlyGrossRate;
+ 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);
- RecomputeDayTotals();
}
private async Task SaveAsync()
{
- RecomputePreview();
+ TimeOnly? start = null;
+ TimeOnly? exit = null;
- var workUnit = new WorkUnitDocument
+ if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
- Id = unitId,
- Label = label,
- Location = location,
- StartTime = ParseTime(startTimeStr),
- EndTime = ParseTime(endTimeStr),
- ManualWorkedHours = Math.Max(0m, manualWorkedHours),
- IsPreview = isPreview,
+ 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.SaveWorkUnitAsync(selectedDate, workUnit);
+ var saved = await WorkDayService.SaveAsync(workDay);
- 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;
+ // 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;
- 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();
+ statusMessage = $"Saved at {DateTime.Now:t}";
}
}
diff --git a/Domain/AppSettingsDocument.cs b/Domain/AppSettingsDocument.cs
index a3a93d1..d7fa2a1 100644
--- a/Domain/AppSettingsDocument.cs
+++ b/Domain/AppSettingsDocument.cs
@@ -6,6 +6,8 @@ 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
deleted file mode 100644
index 22fb24f..0000000
--- a/Domain/CalendarEventDocument.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 7a7b6b9..0000000
--- a/Domain/CalendarEventType.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-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 347866f..220be3a 100644
--- a/Domain/CoeffSnapshotDocument.cs
+++ b/Domain/CoeffSnapshotDocument.cs
@@ -4,6 +4,8 @@ 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 254a7c9..58cde37 100644
--- a/Domain/MonthlySummaryModel.cs
+++ b/Domain/MonthlySummaryModel.cs
@@ -8,12 +8,6 @@ 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
deleted file mode 100644
index e7687f7..0000000
--- a/Domain/MonthlyTimesheetDaySummary.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index 5b0ff34..0000000
--- a/Domain/MonthlyTimesheetModel.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-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 f56d4b8..664674e 100644
--- a/Domain/WorkDayDocument.cs
+++ b/Domain/WorkDayDocument.cs
@@ -6,13 +6,33 @@ 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 List WorkUnits { get; set; } = [];
+ public string? Notes { get; set; }
- public List CalendarEvents { get; set; } = [];
+ public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
diff --git a/Domain/WorkUnitDocument.cs b/Domain/WorkUnitDocument.cs
deleted file mode 100644
index 3803c61..0000000
--- a/Domain/WorkUnitDocument.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 6c40a05..0000000
--- a/Domain/WorkUnitLocation.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-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 7cfede9..67a7a0d 100644
--- a/README.Docker.md
+++ b/README.Docker.md
@@ -8,51 +8,6 @@ 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.
@@ -84,7 +39,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.
-- VS Code waits for the app to report that it is listening before opening Microsoft Edge in browser debug mode against http://localhost:8002.
+- When the app reports that it is listening, VS Code automatically opens 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 fc934bf..66e8040 100644
--- a/Services/Settings/CouchbaseLiteAppSettingsService.cs
+++ b/Services/Settings/CouchbaseLiteAppSettingsService.cs
@@ -48,6 +48,7 @@ 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));
@@ -66,6 +67,7 @@ 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 27a9b1d..bfc458d 100644
--- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs
+++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs
@@ -31,132 +31,45 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult(doc is not null ? Map(doc) : null);
}
- public async Task GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var day = await GetAsync(date, cancellationToken);
- return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
- }
-
- public async Task GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var day = await GetAsync(date, cancellationToken);
- return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
- }
-
- public async Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
+ public async Task SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var settings = await appSettingsService.GetAsync(cancellationToken);
- var day = await GetOrCreateDayAsync(date, cancellationToken);
- var now = DateTimeOffset.UtcNow;
- var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal));
- var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now;
+ var festivities = festivitySource.GetFestivities(workDay.Date.Year);
- 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
+ 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
};
- workUnit.CreatedAtUtc = existingCreatedAt;
- workUnit.UpdatedAtUtc = now;
- Compute(workUnit);
+ Compute(workDay);
- if (existingIndex >= 0)
+ // Preserve creation timestamp for existing documents
+ var existing = workDaysCollection.GetDocument(workDay.Id);
+ if (existing is not null)
{
- day.WorkUnits[existingIndex] = workUnit;
+ workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
}
else
{
- day.WorkUnits.Add(workUnit);
+ workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
}
- day.UpdatedAtUtc = now;
- SortEntries(day);
- SaveDocument(day);
- return workUnit;
- }
+ workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
- 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);
+ SaveDocument(workDay);
+ return workDay;
}
public Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
@@ -177,180 +90,88 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult>(results);
}
- public async Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
+ 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);
- 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 = 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()
+ 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)
};
}
- public async Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
+ private static void Compute(WorkDayDocument day)
{
- var from = new DateOnly(year, month, 1);
- var to = from.AddMonths(1).AddDays(-1);
- var days = await GetRangeAsync(from, to, cancellationToken);
- var dayLookup = days.ToDictionary(day => day.Date);
- var settings = await appSettingsService.GetAsync(cancellationToken);
+ var coeff = day.CoeffSnapshot;
- var daySummaries = new List();
- for (var date = from; date <= to; date = date.AddDays(1))
+ // Calculate projected exit time
+ if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
{
- dayLookup.TryGetValue(date, out var day);
- daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
+ var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
+ day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
+ }
+ else
+ {
+ day.ProjectedExitTime = null;
}
- 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))
- ]
- };
- }
+ // Calculate worked hours
+ day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
+ ? coeff.StandardWorkHoursPerDay
+ : 0m;
- public async Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
- 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;
+ // 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;
- 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;
+ // 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);
- 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);
+ // 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"));
@@ -358,424 +179,37 @@ 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"),
- 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),
+ Notes = doc.GetString("notes"),
CoeffSnapshot = new CoeffSnapshotDocument
{
- StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
- HourlyGrossRate = settings.HourlyGrossRate,
- ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
- InpsRate = settings.InpsRate,
- SubstituteTaxRate = settings.SubstituteTaxRate
+ 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 = 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)
@@ -786,14 +220,6 @@ 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)
@@ -801,13 +227,6 @@ 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);
@@ -815,12 +234,4 @@ 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 c6bc5b8..658fcd9 100644
--- a/Services/WorkDays/IWorkDayService.cs
+++ b/Services/WorkDays/IWorkDayService.cs
@@ -6,23 +6,9 @@ public interface IWorkDayService
{
Task GetAsync(DateOnly date, 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 SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
Task> GetRangeAsync(DateOnly from, DateOnly to, 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);
+ Task GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 981a99f..08d2020 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,27 +5,16 @@ services:
dockerfile: Dockerfile
image: ${IMAGE_REGISTRY:-worktracker}:${IMAGE_TAG:-latest}
environment:
- 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}
+ ASPNETCORE_ENVIRONMENT: Production
+ UseHttpsRedirection: "false"
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:
- - "${WORKTRACKER_PORT:-8002}:8080"
+ - "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: ${WORKTRACKER_HEALTHCHECK_INTERVAL:-30s}
- timeout: ${WORKTRACKER_HEALTHCHECK_TIMEOUT:-5s}
- start_period: ${WORKTRACKER_HEALTHCHECK_START_PERIOD:-10s}
- retries: ${WORKTRACKER_HEALTHCHECK_RETRIES:-3}
+ interval: 30s
+ timeout: 5s
+ retries: 3
diff --git a/tests/playwright/auth-bypass.spec.ts b/tests/playwright/auth-bypass.spec.ts
index 670b5e8..5250760 100644
--- a/tests/playwright/auth-bypass.spec.ts
+++ b/tests/playwright/auth-bypass.spec.ts
@@ -1,30 +1,5 @@
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('/');
@@ -39,7 +14,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: '/work-unit', heading: 'Work Unit' },
+ { path: '/workday', heading: 'Work Day Entry' },
{ path: '/auth', heading: 'You are authenticated' }
];
diff --git a/wwwroot/app.css b/wwwroot/app.css
index 071fd5d..210fb13 100644
--- a/wwwroot/app.css
+++ b/wwwroot/app.css
@@ -61,35 +61,20 @@ h1:focus {
/* Calendar view */
.calendar-table td.calendar-cell {
- height: 10rem;
+ height: 5rem;
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 {
@@ -97,118 +82,6 @@ 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;
}
@@ -227,136 +100,4 @@ 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