From 0d003903cf5683b40d5de1c48618714f04bcd2c4 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Mon, 20 Apr 2026 23:56:23 +0200 Subject: [PATCH] feat: add yearly summary page with navigation and formatting improvements --- Components/Layout/NavMenu.razor | 7 + Components/Pages/CalendarView.razor | 5 +- Components/Pages/GridView.razor | 5 +- Components/Pages/Home.razor | 9 + Components/Pages/MonthlySummary.razor | 41 ++-- Components/Pages/WorkDayEditor.razor | 16 +- Components/Pages/YearlySummary.razor | 205 ++++++++++++++++++ Components/_Imports.razor | 1 + Formatting/DurationFormatter.cs | 37 ++++ .../WorkDays/CouchbaseLiteWorkDayService.cs | 76 ++++--- Services/WorkDays/IWorkDayService.cs | 2 + wwwroot/app.css | 72 ++++++ 12 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 Components/Pages/YearlySummary.razor create mode 100644 Formatting/DurationFormatter.cs diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index 514e372..9b7e1e1 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -60,6 +60,13 @@ + + +
+
+
+

Yearly Summary

+

Month-by-month totals in a single table for the selected year.

+ Open Yearly Summary +
+
+
diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 8f45fe5..06b89ec 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -6,6 +6,7 @@ @using System.Globalization @inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService @inject IJSRuntime JS +@inject NavigationManager Navigation Monthly Summary @@ -15,6 +16,7 @@

@currentMonth.ToString("MMMM yyyy")

+ Yearly Summary
@@ -219,17 +221,9 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet; private SummaryViewMode viewMode = SummaryViewMode.Timesheet; - protected override async Task OnInitializedAsync() + protected override async Task OnParametersSetAsync() { - if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed)) - { - currentMonth = new DateOnly(parsed.Year, parsed.Month, 1); - } - else - { - currentMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1); - } - + currentMonth = ParseCurrentMonth(); await LoadSummary(); } @@ -264,16 +258,18 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) loading = false; } - private async Task PreviousMonth() + private Task PreviousMonth() { currentMonth = currentMonth.AddMonths(-1); - await LoadSummary(); + Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}"); + return Task.CompletedTask; } - private async Task NextMonth() + private Task NextMonth() { currentMonth = currentMonth.AddMonths(1); - await LoadSummary(); + Navigation.NavigateTo($"/summary/{currentMonth:yyyy-MM}"); + return Task.CompletedTask; } private void SetViewMode(SummaryViewMode mode) @@ -286,6 +282,16 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture)); } + private DateOnly ParseCurrentMonth() + { + if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, DateTimeStyles.None, out var parsed)) + { + return new DateOnly(parsed.Year, parsed.Month, 1); + } + + return new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1); + } + private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day) { if (day.IsWeekend || day.IsHoliday) @@ -320,15 +326,12 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) private static string FormatDecimalHours(decimal value) { - return value.ToString("0.##", ItalianCulture); + return DurationFormatter.FormatHours(value, blankWhenZero: true); } 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}"; + return DurationFormatter.FormatHours(value); } private enum SummaryViewMode diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index c54ec93..754e7c2 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -383,23 +383,13 @@ else return false; } - private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—"; + private static string FormatHours(decimal? value) => value.HasValue ? DurationFormatter.FormatHours(value.Value) : "—"; - private static string FormatSignedHours(decimal value) => value switch - { - > 0 => $"+{FormatDurationHours(value)}", - < 0 => $"-{FormatDurationHours(Math.Abs(value))}", - _ => "00:00" - }; + private static string FormatSignedHours(decimal value) => DurationFormatter.FormatSignedHours(value); 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}"; + return DurationFormatter.FormatHours(value); } protected override void OnParametersSet() diff --git a/Components/Pages/YearlySummary.razor b/Components/Pages/YearlySummary.razor new file mode 100644 index 0000000..b7bc7c7 --- /dev/null +++ b/Components/Pages/YearlySummary.razor @@ -0,0 +1,205 @@ +@page "/yearly-summary" +@page "/yearly-summary/{Year:int}" +@attribute [Authorize] +@rendermode InteractiveServer + +@using System.Globalization +@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService +@inject IJSRuntime JS +@inject NavigationManager Navigation + +Yearly Summary + +

Yearly Summary

+ +
+ +

@currentYear

+ + Monthly Summary +
+ +
+ + +
+ +@if (loading) +{ +

Loading...

+} +else +{ +
+
+
+ + + + + + + + + + + + + + + + + + + @foreach (var month in summaryByMonth) + { + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + +
MonthWorking DaysWorked HoursHours OffGross IncomeNet IncomeOffice DaysHome DaysHolidaysClosure DaysDays OffSick Days
+ @GetMonthLabel(month) + @FormatCount(month.TotalWorkingDays)@FormatHoursCell(month.TotalWorkedHours)@FormatHoursCell(month.TotalHoursOff)@FormatCurrency(month.TotalGrossIncome)@FormatCurrency(month.TotalNetIncome)@FormatCount(month.OfficeDays)@FormatCount(month.HomeDays)@FormatCount(month.HolidayDays)@FormatCount(month.ClosureDays)@FormatCount(month.DaysOff)@FormatCount(month.SickDays)
Total@FormatCount(Totals.TotalWorkingDays)@FormatHoursCell(Totals.TotalWorkedHours)@FormatHoursCell(Totals.TotalHoursOff)@FormatCurrency(Totals.TotalGrossIncome)@FormatCurrency(Totals.TotalNetIncome)@FormatCount(Totals.OfficeDays)@FormatCount(Totals.HomeDays)@FormatCount(Totals.HolidayDays)@FormatCount(Totals.ClosureDays)@FormatCount(Totals.DaysOff)@FormatCount(Totals.SickDays)
+
+
+
+} + +@code { + [Parameter] public int? Year { get; set; } + + private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT"); + private const string IncludePreviewPreferenceKey = "worktracker.includePreviewWorkUnits"; + + private int currentYear; + private bool loading = true; + private bool includePreview; + private IReadOnlyList summaryByMonth = []; + + private global::WorkTracker.Domain.MonthlySummaryModel Totals => new() + { + Year = currentYear, + TotalWorkingDays = summaryByMonth.Sum(item => item.TotalWorkingDays), + CountedWorkUnits = summaryByMonth.Sum(item => item.CountedWorkUnits), + TotalWorkedHours = summaryByMonth.Sum(item => item.TotalWorkedHours), + TotalPreviewWorkedHours = summaryByMonth.Sum(item => item.TotalPreviewWorkedHours), + PreviewWorkUnits = summaryByMonth.Sum(item => item.PreviewWorkUnits), + TotalHoursOff = summaryByMonth.Sum(item => item.TotalHoursOff), + TotalGrossIncome = summaryByMonth.Sum(item => item.TotalGrossIncome), + TotalNetIncome = summaryByMonth.Sum(item => item.TotalNetIncome), + OfficeDays = summaryByMonth.Sum(item => item.OfficeDays), + HomeDays = summaryByMonth.Sum(item => item.HomeDays), + HolidayDays = summaryByMonth.Sum(item => item.HolidayDays), + ClosureDays = summaryByMonth.Sum(item => item.ClosureDays), + DaysOff = summaryByMonth.Sum(item => item.DaysOff), + SickDays = summaryByMonth.Sum(item => item.SickDays) + }; + + protected override async Task OnParametersSetAsync() + { + currentYear = Year ?? DateTime.Today.Year; + await LoadSummary(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + var savedIncludePreview = await JS.InvokeAsync("workTrackerPreferences.getBool", IncludePreviewPreferenceKey); + if (savedIncludePreview.HasValue && savedIncludePreview.Value != includePreview) + { + includePreview = savedIncludePreview.Value; + await LoadSummary(); + await InvokeAsync(StateHasChanged); + } + } + + private async Task LoadSummary() + { + loading = true; + summaryByMonth = await WorkDayService.GetYearlySummaryAsync(currentYear, includePreview); + loading = false; + } + + private async Task OnIncludePreviewChanged(ChangeEventArgs e) + { + includePreview = e.Value is bool value && value; + await JS.InvokeVoidAsync("workTrackerPreferences.setBool", IncludePreviewPreferenceKey, includePreview); + await LoadSummary(); + } + + private Task PreviousYear() + { + currentYear--; + Navigation.NavigateTo($"/yearly-summary/{currentYear}"); + return Task.CompletedTask; + } + + private Task NextYear() + { + currentYear++; + Navigation.NavigateTo($"/yearly-summary/{currentYear}"); + return Task.CompletedTask; + } + + private static string GetMonthLabel(global::WorkTracker.Domain.MonthlySummaryModel month) + { + return new DateOnly(month.Year, month.Month, 1).ToString("MMMM", ItalianCulture); + } + + private static string GetYearMonth(global::WorkTracker.Domain.MonthlySummaryModel month) + { + return $"{month.Year:D4}-{month.Month:D2}"; + } + + private static string FormatHours(decimal value) + { + return DurationFormatter.FormatHours(value); + } + + private static string FormatHoursCell(decimal value) + { + return DurationFormatter.FormatHours(value, blankWhenZero: true); + } + + private static string FormatCount(int value) + { + return value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture); + } + + private static string FormatCurrency(decimal value) + { + return value == 0m ? string.Empty : $"€{value.ToString("N2", ItalianCulture)}"; + } +} \ No newline at end of file diff --git a/Components/_Imports.razor b/Components/_Imports.razor index 1e53c28..b829145 100644 --- a/Components/_Imports.razor +++ b/Components/_Imports.razor @@ -11,6 +11,7 @@ @using WorkTracker @using WorkTracker.Components @using WorkTracker.Domain +@using WorkTracker.Formatting @using WorkTracker.Services.Festivities @using WorkTracker.Services.Settings @using WorkTracker.Services.WorkDays diff --git a/Formatting/DurationFormatter.cs b/Formatting/DurationFormatter.cs new file mode 100644 index 0000000..624a2dc --- /dev/null +++ b/Formatting/DurationFormatter.cs @@ -0,0 +1,37 @@ +namespace WorkTracker.Formatting; + +public static class DurationFormatter +{ + public static string FormatHours(decimal value, bool blankWhenZero = false) + { + var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero); + if (totalMinutes == 0) + { + return blankWhenZero ? string.Empty : "0"; + } + + var sign = totalMinutes < 0 ? "-" : string.Empty; + totalMinutes = Math.Abs(totalMinutes); + + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + return minutes == 0 + ? $"{sign}{hours}" + : $"{sign}{hours}:{minutes:00}"; + } + + public static string FormatSignedHours(decimal value) + { + if (value > 0m) + { + return $"+{FormatHours(value)}"; + } + + if (value < 0m) + { + return $"-{FormatHours(Math.Abs(value))}"; + } + + return "0"; + } +} \ No newline at end of file diff --git a/Services/WorkDays/CouchbaseLiteWorkDayService.cs b/Services/WorkDays/CouchbaseLiteWorkDayService.cs index 27a9b1d..34d9492 100644 --- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs +++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs @@ -180,36 +180,20 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService 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); + return await BuildMonthlySummaryAsync(from, includePreview, cancellationToken); + } - var includedUnits = days - .SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit })) - .ToList(); + public async Task> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default) + { + var summaries = new List(12); - var previewUnits = days - .SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit })) - .ToList(); - - return new MonthlySummaryModel + for (var month = 1; month <= 12; month++) { - 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() - }; + cancellationToken.ThrowIfCancellationRequested(); + summaries.Add(await BuildMonthlySummaryAsync(new DateOnly(year, month, 1), includePreview, cancellationToken)); + } + + return summaries; } public async Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) @@ -480,6 +464,40 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType)); } + private async Task BuildMonthlySummaryAsync(DateOnly from, bool includePreview, CancellationToken cancellationToken) + { + 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 = from.Year, + Month = from.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() + }; + } + private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours) { var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? []; @@ -585,9 +603,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService private static string FormatCompactHours(decimal value) { - return value == decimal.Truncate(value) - ? value.ToString("0") - : value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + return Formatting.DurationFormatter.FormatHours(value); } private static decimal GetNightHours(WorkUnitDocument unit) diff --git a/Services/WorkDays/IWorkDayService.cs b/Services/WorkDays/IWorkDayService.cs index c6bc5b8..77a6677 100644 --- a/Services/WorkDays/IWorkDayService.cs +++ b/Services/WorkDays/IWorkDayService.cs @@ -22,6 +22,8 @@ public interface IWorkDayService Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default); + Task> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default); + Task GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default); Task GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default); diff --git a/wwwroot/app.css b/wwwroot/app.css index a2f6721..63c0ce2 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -189,6 +189,10 @@ main { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M1 11a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3zm5-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5-5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2z'/%3E%3C/svg%3E"); } +.bi-table-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm15 2H1v3h14V4zm0 4H1v3h14V8zm0 4H1v2a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2z'/%3E%3C/svg%3E"); +} + .nav-item { font-size: 0.9rem; padding-bottom: 0.5rem; @@ -860,4 +864,72 @@ h1:focus { width: auto; transform: none; } +} + +/* Yearly summary */ +.yearly-summary-card { + overflow: hidden; +} + +.yearly-summary-table { + min-width: 52rem; +} + +.yearly-summary-table thead th, +.yearly-summary-table tfoot th, +.yearly-summary-table tfoot td { + background-color: var(--wt-summary-head-bg); +} + +.yearly-summary-table thead th { + white-space: normal; + line-height: 1.15; +} + +.yearly-summary-table th, +.yearly-summary-table td { + white-space: nowrap; + vertical-align: middle; + padding: 0.45rem 0.4rem; +} + +.yearly-summary-sticky-column { + position: sticky; + left: 0; + z-index: 2; + background-color: var(--wt-summary-sticky-bg); +} + +.yearly-summary-header-cell { + width: 5.2rem; + min-width: 5.2rem; + max-width: 5.2rem; +} + +.yearly-summary-table thead .yearly-summary-sticky-column, +.yearly-summary-table tfoot .yearly-summary-sticky-column { + z-index: 3; + background-color: var(--wt-summary-head-bg); +} + +.yearly-summary-table tbody tr:nth-child(odd) td, +.yearly-summary-table tbody tr:nth-child(odd) .yearly-summary-sticky-column { + background-color: var(--wt-summary-row-alt); +} + +.yearly-summary-total-row th, +.yearly-summary-total-row td { + font-weight: 600; +} + +@media (max-width: 767.98px) { + .yearly-summary-table { + min-width: 44rem; + } + + .yearly-summary-header-cell { + width: 4.5rem; + min-width: 4.5rem; + max-width: 4.5rem; + } } \ No newline at end of file