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