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
+
+
+
+
+
+
+
+
+@if (loading)
+{
+
Loading...
+}
+else
+{
+
+
+
+
+
+
+ | Month |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @foreach (var month in summaryByMonth)
+ {
+
+ |
+ @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