@page "/calendar" @page "/calendar/{YearMonth}" @attribute [Authorize] @rendermode InteractiveServer @inject IWorkDayService WorkDayService @inject IItalianFestivitySource FestivitySource @inject NavigationManager Navigation @inject IJSRuntime JS Calendar

Calendar

@firstOfMonth.ToString("MMMM yyyy")

@if (!string.IsNullOrWhiteSpace(statusMessage)) {
@statusMessage
} @if (loading) {

Loading...

} else {
@foreach (var week in weeks) { @foreach (var cell in week) { @if (cell is null) { } else { } } }
Mon Tue Wed Thu Fri Sat Sun
@cell.Date.Day
@foreach (var workUnit in cell.Entry?.WorkUnits ?? []) { } @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) { }
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
@if (IsActiveCell(cell.Date)) {
@cell.Date.ToString("dddd d MMMM")
Select an existing entry or create a new one.
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0) {
No entries for this day.
} @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) { } @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) { }
}

Legend

Office work unit Home work unit Preview work unit Closure Day off Holiday
@if (monthTotals is not null) {

Month Totals

Worked Hours
@FormatHours(monthTotals.TotalWorkedHours)
Counted Units
@monthTotals.CountedWorkUnits
Hours Off
@FormatHours(monthTotals.TotalHoursOff)
Gross Income
€@monthTotals.TotalGrossIncome.ToString("N2")
Net Income
€@monthTotals.TotalNetIncome.ToString("N2")
} }
@code { [Parameter] public string? YearMonth { get; set; } private DateOnly firstOfMonth; private bool loading = true; private List weeks = []; private IReadOnlyCollection festivities = []; private DateOnly? activeDate; private bool includePreviewTotals; private MonthlySummaryModel? monthTotals; private string? statusMessage; protected override async Task OnInitializedAsync() { if (!string.IsNullOrEmpty(YearMonth) && DateTime.TryParseExact(YearMonth, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var parsed)) { firstOfMonth = new DateOnly(parsed.Year, parsed.Month, 1); } else { firstOfMonth = new DateOnly(DateTime.Today.Year, DateTime.Today.Month, 1); } await LoadMonth(); } private async Task LoadMonth() { loading = true; activeDate = null; festivities = FestivitySource.GetFestivities(firstOfMonth.Year); var lastDay = firstOfMonth.AddMonths(1).AddDays(-1); var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay); var lookup = entries.ToDictionary(e => e.Date); monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals); // Build calendar grid (ISO weeks: Monday = 0) weeks = []; var currentWeek = new CalendarCell?[7]; var dayOfWeek = ((int)firstOfMonth.DayOfWeek + 6) % 7; // Mon=0 for (var d = firstOfMonth; d <= lastDay; d = d.AddDays(1)) { currentWeek[dayOfWeek] = new CalendarCell { Date = d, ColumnIndex = dayOfWeek, IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, IsFestivity = festivities.Contains(d), Entry = lookup.GetValueOrDefault(d) }; dayOfWeek++; if (dayOfWeek == 7) { weeks.Add(currentWeek); currentWeek = new CalendarCell?[7]; dayOfWeek = 0; } } if (dayOfWeek > 0) { weeks.Add(currentWeek); } loading = false; } private async Task OnIncludePreviewTotalsChanged(ChangeEventArgs e) { includePreviewTotals = e.Value is bool value && value; await LoadMonth(); } private async Task PreviousMonth() { firstOfMonth = firstOfMonth.AddMonths(-1); statusMessage = null; await LoadMonth(); } private async Task NextMonth() { firstOfMonth = firstOfMonth.AddMonths(1); statusMessage = null; await LoadMonth(); } private void TogglePopup(DateOnly date) { activeDate = activeDate == date ? null : date; } private void ClosePopup() { activeDate = null; } private static string GetPopupClass(CalendarCell cell) { if (cell.ColumnIndex == 0) { return "calendar-popup-left"; } return cell.ColumnIndex >= 5 ? "calendar-popup-right" : string.Empty; } private bool IsActiveCell(DateOnly date) => activeDate == date; private static bool IsToday(DateOnly date) => date == DateOnly.FromDateTime(DateTime.Today); private void CreateWorkUnit(DateOnly date) { Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}"); } private void CreateCalendarEvent(DateOnly date) { Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}"); } private void OpenWorkUnit(DateOnly date, string workUnitId) { Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}"); } private void OpenCalendarEvent(DateOnly date, string eventId) { Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}"); } private async Task GeneratePreviewWorkUnitsAsync() { var confirmed = await JS.InvokeAsync("confirm", $"Generate preview work units for {firstOfMonth:MMMM yyyy}? Existing work-unit days will be left unchanged."); if (!confirmed) { return; } var createdDays = await WorkDayService.GenerateMonthlyPreviewWorkUnitsAsync(firstOfMonth.Year, firstOfMonth.Month); statusMessage = createdDays == 0 ? "No preview work units were created. Every eligible day already had work units or a blocking calendar event." : $"Created preview work units for {createdDays} day(s)."; await LoadMonth(); } private string GetCellClass(CalendarCell cell) { if (cell.IsWeekend || cell.IsFestivity) { return "calendar-weekend"; } var eventType = GetDominantEventType(cell.Entry); if (eventType.HasValue) { return eventType.Value switch { CalendarEventType.Closure => "calendar-closure", CalendarEventType.Illness => "calendar-illness", CalendarEventType.DayOff => "calendar-dayoff", CalendarEventType.Holiday => "calendar-holiday", _ => string.Empty }; } return string.Empty; } private static CalendarEventType? GetDominantEventType(WorkDayDocument? day) { if (day is null || day.CalendarEvents.Count == 0) { return null; } if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday)) { return CalendarEventType.Holiday; } if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure)) { return CalendarEventType.Closure; } if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff)) { return CalendarEventType.DayOff; } if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness)) { return CalendarEventType.Illness; } return null; } private static string GetWorkUnitClass(WorkUnitDocument workUnit) { if (workUnit.IsPreview) { return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-preview-home" : "calendar-item-preview-office"; } return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-home" : "calendar-item-office"; } private static string GetCalendarEventClass(CalendarEventDocument calendarEvent) => calendarEvent.EventType switch { CalendarEventType.Holiday => "calendar-item-holiday", CalendarEventType.Closure => "calendar-item-closure", CalendarEventType.DayOff => "calendar-item-dayoff", CalendarEventType.Illness => "calendar-item-illness", _ => "calendar-item-generic" }; private static string FormatWorkUnit(WorkUnitDocument workUnit) { var hours = FormatHours(workUnit.ManualWorkedHours); var timeRange = workUnit.StartTime.HasValue && workUnit.EndTime.HasValue ? $"{workUnit.StartTime:HH:mm}-{workUnit.EndTime:HH:mm}" : hours; return workUnit.IsPreview ? $"{timeRange} preview" : timeRange; } private static decimal GetDayTotalHours(WorkDayDocument? day, bool includePreview) { if (day is null) { return 0m; } return day.WorkUnits .Where(unit => includePreview || !unit.IsPreview) .Sum(unit => unit.ManualWorkedHours); } private static string FormatHours(decimal value) { var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero); var hours = totalMinutes / 60; var minutes = totalMinutes % 60; return $"{hours:00}:{minutes:00}"; } private sealed class CalendarCell { public DateOnly Date { get; set; } public int ColumnIndex { get; set; } public bool IsWeekend { get; set; } public bool IsFestivity { get; set; } public WorkDayDocument? Entry { get; set; } } }