diff --git a/.vscode/launch.json b/.vscode/launch.json index 4e1e71d..2b0caf4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,50 +1,16 @@ { "version": "0.2.0", - "compounds": [], - "configurations": [ + "compounds": [ { "name": "WorkTracker: Debug in Docker", - "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" + "configurations": [ + "WorkTracker: Debug App in Docker", + "WorkTracker: Debug Edge" ], - "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" - }, + "stopAll": true + } + ], + "configurations": [ { "name": "WorkTracker: Debug App in Docker", "type": "coreclr", diff --git a/Components/App.razor b/Components/App.razor index a2917af..1e8963d 100644 --- a/Components/App.razor +++ b/Components/App.razor @@ -14,7 +14,7 @@ - + diff --git a/Components/Layout/MainLayout.razor b/Components/Layout/MainLayout.razor index 7e9232b..78624f3 100644 --- a/Components/Layout/MainLayout.razor +++ b/Components/Layout/MainLayout.razor @@ -1,24 +1,12 @@ @inherits LayoutComponentBase -
- -
- - -
- -
- - -
- @if (loading) {

Loading...

} -else if (viewMode == SummaryViewMode.Cards && summary is not null) +else if (summary is not null) {
@@ -41,35 +30,11 @@ else if (viewMode == SummaryViewMode.Cards && summary is not null)
-
-
-
-
Counted Work Units
-
@summary.CountedWorkUnits
-
-
-
Total Worked Hours
-
@FormatHours(summary.TotalWorkedHours)
-
-
-
-
-
-
-
Preview Hours
-
@FormatHours(summary.TotalPreviewWorkedHours)
-
-
-
-
-
-
-
Preview Units
-
@summary.PreviewWorkUnits
+
@summary.TotalWorkedHours.ToString("N1")h
@@ -77,7 +42,7 @@ else if (viewMode == SummaryViewMode.Cards && summary is not null)
Hours Off
-
@FormatHours(summary.TotalHoursOff)
+
@summary.TotalHoursOff.ToString("N1")h
@@ -124,8 +89,8 @@ else if (viewMode == SummaryViewMode.Cards && summary is not null)
-
Closure Days
-
@summary.ClosureDays
+
Sick Days
+
@summary.SickDays
@@ -140,82 +105,20 @@ else if (viewMode == SummaryViewMode.Cards && summary is not null)
-
Sick Days
-
@summary.SickDays
+
Closure Days
+
@summary.ClosureDays
} -else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) -{ -
-
-
- - - - - @for (var i = 0; i < timesheet.Days.Count; i++) - { - var day = timesheet.Days[i]; - - } - - - - - @foreach (var row in timesheet.Rows) - { - - - @for (var i = 0; i < row.DailyValues.Count; i++) - { - - } - - - } - -
Categoria -
@day.Date.Day
-
@GetDayHeader(day.Date)
-
-
@day.Date.ToString("dddd d MMMM", ItalianCulture)
- @if (day.WorkUnitSummaries.Count == 0 && day.EventSummaries.Count == 0) - { -
Nessun elemento registrato.
- } - else - { - @foreach (var workUnit in day.WorkUnitSummaries) - { -
@workUnit
- } - - @foreach (var calendarEvent in day.EventSummaries) - { -
@calendarEvent
- } - } -
-
Totale
@row.Label@FormatTimesheetValue(row.DailyValues[i], row.ValueFormat)@FormatTimesheetValue(row.Total, row.ValueFormat)
-
-
-
-} @code { [Parameter] public string? YearMonth { get; set; } - private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT"); - private DateOnly currentMonth; private bool loading = true; - private bool includePreview; - private global::WorkTracker.Domain.MonthlySummaryModel? summary; - private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet; - private SummaryViewMode viewMode = SummaryViewMode.Timesheet; + private MonthlySummaryModel? summary; protected override async Task OnInitializedAsync() { @@ -231,17 +134,10 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet 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, includePreview); - timesheet = await WorkDayService.GetMonthlyTimesheetAsync(currentMonth.Year, currentMonth.Month, includePreview); + summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month); loading = false; } @@ -256,65 +152,4 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) currentMonth = currentMonth.AddMonths(1); await LoadSummary(); } - - private void SetViewMode(SummaryViewMode mode) - { - viewMode = mode; - } - - private static string GetDayHeader(DateOnly date) - { - return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture)); - } - - private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day) - { - if (day.IsWeekend || day.IsHoliday) - { - return "timesheet-summary-day-danger"; - } - - return day.IsClosure ? "timesheet-summary-day-closure" : string.Empty; - } - - private static string GetDayPopupClass(int index, int totalDays) - { - if (index == 0) - { - return "timesheet-summary-day-popup-left"; - } - - return index >= totalDays - 2 ? "timesheet-summary-day-popup-right" : string.Empty; - } - - private static string FormatTimesheetValue(decimal? value, global::WorkTracker.Domain.MonthlyTimesheetValueFormat valueFormat) - { - if (!value.HasValue || value.Value <= 0m) - { - return string.Empty; - } - - return valueFormat == global::WorkTracker.Domain.MonthlyTimesheetValueFormat.Days - ? value.Value.ToString("0.##", ItalianCulture) - : FormatDecimalHours(value.Value); - } - - private static string FormatDecimalHours(decimal value) - { - return value.ToString("0.##", ItalianCulture); - } - - 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 enum SummaryViewMode - { - Cards, - Timesheet - } } diff --git a/Components/Pages/Settings.razor b/Components/Pages/Settings.razor index 0cb3a2d..5034b2d 100644 --- a/Components/Pages/Settings.razor +++ b/Components/Pages/Settings.razor @@ -6,7 +6,7 @@ Settings

Settings

-

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

+

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

@if (settings is null) { @@ -22,6 +22,10 @@ else +
+ + +
diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index c54ec93..58108a5 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -1,17 +1,16 @@ -@page "/work-unit" -@page "/work-unit/{DateStr}" -@page "/work-unit/{DateStr}/{UnitId}" +@page "/workday" +@page "/workday/{DateStr}" @attribute [Authorize] @rendermode InteractiveServer @inject IWorkDayService WorkDayService @inject IAppSettingsService AppSettingsService +@inject IItalianFestivitySource FestivitySource @inject NavigationManager Navigation -@inject IJSRuntime JS -Work Unit +Work Day -

Work Unit

+

Work Day Entry

@if (!loaded) { @@ -22,20 +21,22 @@ else
- + + @if (isWeekend || isFestivity) + { +
+ @if (isWeekend) { Weekend } + @if (isFestivity) { Festivity } +
+ }
- - -
- -
- - + @foreach (var dt in Enum.GetValues()) { - + }
@@ -46,20 +47,14 @@ else
- - + + +
Informational only, not used in calculations.
- - -
- -
-
- - -
+ +
@@ -73,12 +68,20 @@ else

Computed values

- -
@FormatHours(calculatedWorkedHours)
+ +
@(projectedExitTime?.ToString("HH:mm") ?? "—")
- -
@FormatSignedHours(workedHoursDelta)
+ +
@workedHoursBase.ToString("N2")h
+
+
+ +
@workedHoursFinal.ToString("N2")h
+
+
+ +
@hoursOff.ToString("N2")h
@@ -90,27 +93,8 @@ else
-
-

Day Total

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