From cab549ab3a8e1a18bfd5e141076ab088c1b2e1fa Mon Sep 17 00:00:00 2001 From: Marco Date: Mon, 20 Apr 2026 16:11:27 +0200 Subject: [PATCH] Refactor AppSettingsDocument and CoeffSnapshotDocument: Remove LunchBreakHours property Add CalendarEventDocument and CalendarEventType enum for event management Update WorkDayDocument to include WorkUnitDocument and CalendarEventDocument lists Enhance CouchbaseLiteWorkDayService with methods for managing WorkUnit and CalendarEvent Revise MonthlySummaryModel to track preview worked hours and counted work units Improve CSS for calendar view, including responsive design and new item styles --- .vscode/launch.json | 50 +- Components/Layout/NavMenu.razor | 6 - Components/Pages/CalendarEventEditor.razor | 232 +++++++ Components/Pages/CalendarView.razor | 308 ++++++++- Components/Pages/GridView.razor | 158 ++++- Components/Pages/Home.razor | 16 +- Components/Pages/MonthlySummary.razor | 58 +- Components/Pages/Settings.razor | 6 +- Components/Pages/WorkDayEditor.razor | 399 ++++++++---- Domain/AppSettingsDocument.cs | 2 - Domain/CalendarEventDocument.cs | 20 + Domain/CalendarEventType.cs | 10 + Domain/CoeffSnapshotDocument.cs | 2 - Domain/MonthlySummaryModel.cs | 6 + Domain/WorkDayDocument.cs | 24 +- Domain/WorkUnitDocument.cs | 34 + Domain/WorkUnitLocation.cs | 7 + README.Docker.md | 2 +- .../CouchbaseLiteAppSettingsService.cs | 2 - .../WorkDays/CouchbaseLiteWorkDayService.cs | 613 +++++++++++++++--- Services/WorkDays/IWorkDayService.cs | 16 +- wwwroot/app.css | 130 +++- 22 files changed, 1735 insertions(+), 366 deletions(-) create mode 100644 Components/Pages/CalendarEventEditor.razor create mode 100644 Domain/CalendarEventDocument.cs create mode 100644 Domain/CalendarEventType.cs create mode 100644 Domain/WorkUnitDocument.cs create mode 100644 Domain/WorkUnitLocation.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 2b0caf4..4e1e71d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,16 +1,50 @@ { "version": "0.2.0", - "compounds": [ + "compounds": [], + "configurations": [ { "name": "WorkTracker: Debug in Docker", - "configurations": [ - "WorkTracker: Debug App in Docker", - "WorkTracker: Debug Edge" + "type": "coreclr", + "request": "launch", + "preLaunchTask": "WorkTracker: Docker Debug Prepare", + "postDebugTask": "WorkTracker: Docker Debug Down", + "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", + "args": [ + "--urls", + "http://+:8080" ], - "stopAll": true - } - ], - "configurations": [ + "cwd": "/workspace", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://+:8080", + "DOTNET_USE_POLLING_FILE_WATCHER": "1", + "UseHttpsRedirection": "false" + }, + "sourceFileMap": { + "/workspace": "${workspaceFolder}" + }, + "pipeTransport": { + "pipeProgram": "docker", + "pipeArgs": [ + "exec", + "-i", + "worktracker-dev", + "sh", + "-c" + ], + "debuggerPath": "/vsdbg/vsdbg", + "pipeCwd": "${workspaceFolder}", + "quoteArgs": false + }, + "serverReadyAction": { + "action": "debugWithEdge", + "pattern": "Now listening on:\\s+https?://\\S+:(\\d+)", + "uriFormat": "http://localhost:8002/?ready=%s" + }, + "justMyCode": true, + "requireExactSource": false, + "console": "internalConsole" + }, { "name": "WorkTracker: Debug App in Docker", "type": "coreclr", diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index fe28638..5d09d86 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -18,12 +18,6 @@ - - +@if (!string.IsNullOrWhiteSpace(statusMessage)) +{ +
@statusMessage
+} + @if (loading) {

Loading...

@@ -48,12 +55,67 @@ else } else { - +
@cell.Date.Day
- @if (cell.Entry is not null) + + @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) { - @cell.Entry.DayType -
@cell.Entry.WorkedHoursFinal.ToString("N1")h
+ + } + + @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) + { + + } + +
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
+ + @if (IsActiveCell(cell.Date)) + { +
+
+
+
@cell.Date.ToString("dddd d MMMM")
+
Select an existing entry or create a new one.
+
+ +
+ +
+ @if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0) + { +
No entries for this day.
+ } + + @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) + { + + } + + @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) + { + + } +
+ +
+ + +
+
} } @@ -67,14 +129,69 @@ else

Legend

- Work - Home + Office work unit + Home work unit + Preview work unit Closure - Illness - DayOff + Day off Holiday
+ + @if (monthTotals is not null) + { +
+
+

Month Totals

+
+ + +
+
+
+
+
+
+
Worked Hours
+
@FormatHours(monthTotals.TotalWorkedHours)
+
+
+
+
+
+
+
Counted Units
+
@monthTotals.CountedWorkUnits
+
+
+
+
+
+
+
Hours Off
+
@FormatHours(monthTotals.TotalHoursOff)
+
+
+
+
+
+
+
Gross Income
+
€@monthTotals.TotalGrossIncome.ToString("N2")
+
+
+
+
+
+
+
Net Income
+
€@monthTotals.TotalNetIncome.ToString("N2")
+
+
+
+
+
+ } } @code { @@ -84,6 +201,10 @@ else private bool loading = true; private List weeks = []; private IReadOnlyCollection festivities = []; + private DateOnly? activeDate; + private bool includePreviewTotals; + private MonthlySummaryModel? monthTotals; + private string? statusMessage; protected override async Task OnInitializedAsync() { @@ -102,11 +223,13 @@ else private async Task LoadMonth() { loading = true; + activeDate = null; festivities = FestivitySource.GetFestivities(firstOfMonth.Year); var lastDay = firstOfMonth.AddMonths(1).AddDays(-1); var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay); var lookup = entries.ToDictionary(e => e.Date); + monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals); // Build calendar grid (ISO weeks: Monday = 0) weeks = []; @@ -140,46 +263,177 @@ else loading = false; } + private async Task OnIncludePreviewTotalsChanged(ChangeEventArgs e) + { + includePreviewTotals = e.Value is bool value && value; + await LoadMonth(); + } + private async Task PreviousMonth() { firstOfMonth = firstOfMonth.AddMonths(-1); + statusMessage = null; await LoadMonth(); } private async Task NextMonth() { firstOfMonth = firstOfMonth.AddMonths(1); + statusMessage = null; await LoadMonth(); } - private void NavigateToDay(DateOnly date) => - Navigation.NavigateTo($"/workday/{date:yyyy-MM-dd}"); + private void TogglePopup(DateOnly date) + { + activeDate = activeDate == date ? null : date; + } + + private void ClosePopup() + { + activeDate = null; + } + + private bool IsActiveCell(DateOnly date) => activeDate == date; + + private void CreateWorkUnit(DateOnly date) + { + Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}"); + } + + private void CreateCalendarEvent(DateOnly date) + { + Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}"); + } + + private void OpenWorkUnit(DateOnly date, string workUnitId) + { + Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}"); + } + + private void OpenCalendarEvent(DateOnly date, string eventId) + { + Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}"); + } + + private async Task GeneratePreviewWorkUnitsAsync() + { + var confirmed = await JS.InvokeAsync("confirm", $"Generate preview work units for {firstOfMonth:MMMM yyyy}? Existing work-unit days will be left unchanged."); + if (!confirmed) + { + return; + } + + var createdDays = await WorkDayService.GenerateMonthlyPreviewWorkUnitsAsync(firstOfMonth.Year, firstOfMonth.Month); + statusMessage = createdDays == 0 + ? "No preview work units were created. Every eligible day already had work units or a blocking calendar event." + : $"Created preview work units for {createdDays} day(s)."; + await LoadMonth(); + } private string GetCellClass(CalendarCell cell) { - if (cell.IsWeekend || cell.IsFestivity) return "calendar-weekend"; - if (cell.Entry is null) return ""; - return cell.Entry.DayType switch + if (cell.IsWeekend || cell.IsFestivity) { - DayType.Closure => "calendar-closure", - DayType.Illness => "calendar-illness", - DayType.DayOff => "calendar-dayoff", - DayType.Holiday => "calendar-holiday", - _ => "" - }; + return "calendar-weekend"; + } + + var eventType = GetDominantEventType(cell.Entry); + if (eventType.HasValue) + { + return eventType.Value switch + { + CalendarEventType.Closure => "calendar-closure", + CalendarEventType.Illness => "calendar-illness", + CalendarEventType.DayOff => "calendar-dayoff", + CalendarEventType.Holiday => "calendar-holiday", + _ => string.Empty + }; + } + + return string.Empty; } - private static string GetBadgeClass(DayType type) => type switch + private static CalendarEventType? GetDominantEventType(WorkDayDocument? day) { - DayType.Work => "bg-primary", - DayType.Home => "bg-success", - DayType.Closure => "bg-warning text-dark", - DayType.Illness => "bg-info text-dark", - DayType.DayOff => "bg-secondary", - DayType.Holiday => "bg-danger", - _ => "bg-light text-dark" + if (day is null || day.CalendarEvents.Count == 0) + { + return null; + } + + if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday)) + { + return CalendarEventType.Holiday; + } + + if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure)) + { + return CalendarEventType.Closure; + } + + if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff)) + { + return CalendarEventType.DayOff; + } + + if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness)) + { + return CalendarEventType.Illness; + } + + return null; + } + + private static string GetWorkUnitClass(WorkUnitDocument workUnit) + { + if (workUnit.IsPreview) + { + return workUnit.Location == WorkUnitLocation.Home + ? "calendar-item-preview-home" + : "calendar-item-preview-office"; + } + + return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-home" : "calendar-item-office"; + } + + private static string GetCalendarEventClass(CalendarEventDocument calendarEvent) => calendarEvent.EventType switch + { + CalendarEventType.Holiday => "calendar-item-holiday", + CalendarEventType.Closure => "calendar-item-closure", + CalendarEventType.DayOff => "calendar-item-dayoff", + CalendarEventType.Illness => "calendar-item-illness", + _ => "calendar-item-generic" }; + private static string FormatWorkUnit(WorkUnitDocument workUnit) + { + var hours = FormatHours(workUnit.ManualWorkedHours); + var timeRange = workUnit.StartTime.HasValue && workUnit.EndTime.HasValue + ? $"{workUnit.StartTime:HH:mm}-{workUnit.EndTime:HH:mm}" + : hours; + + return workUnit.IsPreview ? $"{timeRange} preview" : timeRange; + } + + private static decimal GetDayTotalHours(WorkDayDocument? day, bool includePreview) + { + if (day is null) + { + return 0m; + } + + return day.WorkUnits + .Where(unit => includePreview || !unit.IsPreview) + .Sum(unit => unit.ManualWorkedHours); + } + + private static string FormatHours(decimal value) + { + var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return $"{hours:00}:{minutes:00}"; + } + private sealed class CalendarCell { public DateOnly Date { get; set; } diff --git a/Components/Pages/GridView.razor b/Components/Pages/GridView.razor index a8236eb..2887739 100644 --- a/Components/Pages/GridView.razor +++ b/Components/Pages/GridView.razor @@ -29,12 +29,10 @@ else Date Day - Type - Start - Projected - Actual - Worked - Extra + Work Units + Calendar Events + Counted + Preview Off Gross € Net € @@ -47,24 +45,42 @@ else @row.Date.ToString("dd") @row.Date.ToString("ddd") - @if (row.Entry is not null) - { - @row.Entry.DayType - @(row.Entry.StartTime?.ToString("HH:mm") ?? "") - @(row.Entry.ProjectedExitTime?.ToString("HH:mm") ?? "") - @(row.Entry.ActualExitTime?.ToString("HH:mm") ?? "") - @row.Entry.WorkedHoursFinal.ToString("N2") - @FormatDelta(row.Entry.ExtraHoursDelta) - @row.Entry.HoursOff.ToString("N2") - @row.Entry.GrossIncome.ToString("N2") - @row.Entry.NetIncome.ToString("N2") - } - else - { - — - } - Edit + @if (row.Entry?.WorkUnits.Count > 0) + { + @foreach (var unit in row.Entry.WorkUnits) + { +
@unit.Label: @FormatTimeRange(unit.StartTime, unit.EndTime) (@FormatHours(unit.ManualWorkedHours)@(unit.IsPreview ? ", preview" : ""))
+ } + } + else + { + + } + + + @if (row.Entry?.CalendarEvents.Count > 0) + { + @foreach (var calendarEvent in row.Entry.CalendarEvents) + { +
@calendarEvent.EventType: @calendarEvent.Description
+ } + } + else + { + + } + + @FormatHours(GetCountedHours(row)) + @FormatHours(GetPreviewHours(row)) + @FormatHours(GetHoursOff(row)) + @GetGrossIncome(row).ToString("N2") + @GetNetIncome(row).ToString("N2") + +
+ Unit + Event +
} @@ -136,23 +152,93 @@ else { if (row.IsWeekend || row.IsFestivity) return "table-danger"; if (row.Entry is null) return ""; - return row.Entry.DayType switch + if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday)) { - DayType.Closure => "table-warning", - DayType.Illness => "table-info", - DayType.DayOff => "table-secondary", - DayType.Holiday => "table-success", - DayType.Home => "table-light", - _ => "" - }; + return "table-success"; + } + + if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure)) + { + return "table-warning"; + } + + if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness)) + { + return "table-info"; + } + + if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff)) + { + return "table-secondary"; + } + + if (row.Entry.WorkUnits.Any(entry => entry.Location == WorkUnitLocation.Home)) + { + return "table-light"; + } + + return string.Empty; } - private static string FormatDelta(decimal d) => d switch + private static decimal GetCountedHours(CalendarDayRow row) { - > 0 => $"+{d:N2}", - < 0 => d.ToString("N2"), - _ => "—" - }; + return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m; + } + + private static decimal GetPreviewHours(CalendarDayRow row) + { + return row.Entry?.WorkUnits.Where(unit => unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m; + } + + private static decimal GetHoursOff(CalendarDayRow row) + { + if (row.Entry is null || row.Entry.WorkUnits.Count == 0) + { + return 0m; + } + + var countedUnits = row.Entry.WorkUnits.Where(unit => !unit.IsPreview).ToList(); + if (countedUnits.Count == 0) + { + return 0m; + } + + var standardHours = countedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay; + return Math.Max(0m, standardHours - countedUnits.Sum(unit => unit.ManualWorkedHours)); + } + + private static decimal GetGrossIncome(CalendarDayRow row) + { + return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.GrossIncome) ?? 0m; + } + + private static decimal GetNetIncome(CalendarDayRow row) + { + return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.NetIncome) ?? 0m; + } + + private static string FormatTimeRange(TimeOnly? startTime, TimeOnly? endTime) + { + if (startTime.HasValue && endTime.HasValue) + { + return $"{startTime:HH:mm}-{endTime:HH:mm}"; + } + + if (startTime.HasValue) + { + return startTime.Value.ToString("HH:mm"); + } + + return "No time range"; + } + + private static string FormatHours(decimal value) + { + var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return $"{hours:00}:{minutes:00}"; + } private sealed class CalendarDayRow { diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 9f09292..3f46f78 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -8,9 +8,9 @@
-

Today

-

Quick-add or edit today's work entry.

- Open Today +

Calendar

+

Open the calendar and create work units or calendar events from each day cell.

+ Open Calendar
@@ -26,9 +26,9 @@
-

Calendar

-

Visual calendar with day-type badges.

- Open Calendar +

Grid View

+

Tabular view of daily work-unit and calendar-event details.

+ Open Grid
@@ -36,7 +36,7 @@

Monthly Summary

-

Totals for worked hours, income, and day types.

+

Totals for counted hours, preview hours, income, and non-working events.

Open Summary
@@ -45,7 +45,7 @@

Settings

-

Configure default rates, hours, and tax coefficients.

+

Configure the standard daily target and the income coefficients.

Open Settings
diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 68bda19..79edd24 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -15,6 +15,11 @@ +
+ + +
+ @if (loading) {

Loading...

@@ -30,11 +35,35 @@ else if (summary is not null) +
+
+
+
Counted Work Units
+
@summary.CountedWorkUnits
+
+
+
Total Worked Hours
-
@summary.TotalWorkedHours.ToString("N1")h
+
@FormatHours(summary.TotalWorkedHours)
+
+
+
+
+
+
+
Preview Hours
+
@FormatHours(summary.TotalPreviewWorkedHours)
+
+
+
+
+
+
+
Preview Units
+
@summary.PreviewWorkUnits
@@ -42,7 +71,7 @@ else if (summary is not null)
Hours Off
-
@summary.TotalHoursOff.ToString("N1")h
+
@FormatHours(summary.TotalHoursOff)
@@ -89,8 +118,8 @@ else if (summary is not null)
-
Sick Days
-
@summary.SickDays
+
Closure Days
+
@summary.ClosureDays
@@ -105,8 +134,8 @@ else if (summary is not null)
-
Closure Days
-
@summary.ClosureDays
+
Sick Days
+
@summary.SickDays
@@ -118,6 +147,7 @@ else if (summary is not null) private DateOnly currentMonth; private bool loading = true; + private bool includePreview; private MonthlySummaryModel? summary; protected override async Task OnInitializedAsync() @@ -134,10 +164,16 @@ else if (summary is not null) await LoadSummary(); } + private async Task OnIncludePreviewChanged(ChangeEventArgs e) + { + includePreview = e.Value is bool value && value; + await LoadSummary(); + } + private async Task LoadSummary() { loading = true; - summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month); + summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month, includePreview); loading = false; } @@ -152,4 +188,12 @@ else if (summary is not null) currentMonth = currentMonth.AddMonths(1); await LoadSummary(); } + + private static string FormatHours(decimal value) + { + var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero); + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return $"{hours:00}:{minutes:00}"; + } } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 5034b2d..0cb3a2d 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -6,7 +6,7 @@ Settings

Settings

-

Default values used to prefill each workday. Every day can still override these values.

+

Default values used to compute manual work-unit totals and income.

@if (settings is null) { @@ -22,10 +22,6 @@ else -
- - -
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 -Work Day +Work Unit -

Work Day Entry

+

Work Unit

@if (!loaded) { @@ -21,22 +22,20 @@ else
- - @if (isWeekend || isFestivity) - { -
- @if (isWeekend) { Weekend } - @if (isFestivity) { Festivity } -
- } +
- - +
+ +
+ +
@@ -47,14 +46,20 @@ else
- - -
Informational only, not used in calculations.
+ +
- - + + +
+ +
+
+ + +
@@ -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/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..aab278f 100644 --- a/README.Docker.md +++ b/README.Docker.md @@ -39,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. -- 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..dcd7f1c 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,131 @@ 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 GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default) { - var coeff = day.CoeffSnapshot; + cancellationToken.ThrowIfCancellationRequested(); - // Calculate projected exit time - if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home) + 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; + + 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; + 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++; } - // Calculate worked hours - day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home - ? coeff.StandardWorkHoursPerDay - : 0m; - - day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta; - - // Hours off (only for work/home days) - day.HoursOff = day.DayType is DayType.Work or DayType.Home - ? Math.Max(0m, coeff.StandardWorkHoursPerDay - day.WorkedHoursFinal) - : 0m; - - // Income calculations - day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate; - var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient; - day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate); + 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 +309,311 @@ 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 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 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 +622,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 +637,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 +651,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..32a6805 100644 --- a/Services/WorkDays/IWorkDayService.cs +++ b/Services/WorkDays/IWorkDayService.cs @@ -6,9 +6,21 @@ 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 GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default); } diff --git a/wwwroot/app.css b/wwwroot/app.css index 210fb13..7c9f49b 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,107 @@ 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(18rem, calc(100vw - 3rem)); + 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-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 +216,16 @@ 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); + } } \ No newline at end of file