diff --git a/Components/App.razor b/Components/App.razor index 1cf0d7a..de639bd 100644 --- a/Components/App.razor +++ b/Components/App.razor @@ -1,5 +1,5 @@  - + diff --git a/Components/Pages/CalendarEventEditor.razor b/Components/Pages/CalendarEventEditor.razor index b64dcff..76e43b7 100644 --- a/Components/Pages/CalendarEventEditor.razor +++ b/Components/Pages/CalendarEventEditor.razor @@ -20,8 +20,14 @@ else {
- - + + +
+ +
+ + +
Optional. Leave empty for a single-day event.
@@ -29,24 +35,24 @@ else
- - + +
- +
- +
@@ -78,9 +84,10 @@ else private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today); private string eventId = string.Empty; private CalendarEventType eventType = CalendarEventType.Generic; - private string description = "Calendar entry"; + private string description = string.Empty; private string? startTimeStr; private string? endTimeStr; + private DateOnly? endDate; private string? statusMessage; protected override async Task OnInitializedAsync() @@ -106,10 +113,12 @@ else if (existing is not null) { eventId = existing.Id; + selectedDate = existing.StartDate == default ? selectedDate : existing.StartDate; eventType = existing.EventType; description = existing.Description; startTimeStr = existing.StartTime?.ToString("HH:mm"); endTimeStr = existing.EndTime?.ToString("HH:mm"); + endDate = existing.EndDate; isExistingEvent = true; } else @@ -123,23 +132,31 @@ else { eventId = string.Empty; eventType = CalendarEventType.Generic; - description = "Calendar entry"; + description = string.Empty; startTimeStr = null; endTimeStr = null; + endDate = null; isExistingEvent = false; } - private Task OnDateChanged(ChangeEventArgs e) + private Task OnDateChangedAsync(DateOnly? value) { - if (DateOnly.TryParse(e.Value?.ToString(), out var parsed)) + if (value.HasValue) { - selectedDate = parsed; + selectedDate = value.Value; statusMessage = null; } return Task.CompletedTask; } + private Task OnEndDateChangedAsync(DateOnly? value) + { + endDate = value; + statusMessage = null; + return Task.CompletedTask; + } + private Task OnStartTimeChanged(ChangeEventArgs e) { startTimeStr = e.Value?.ToString(); @@ -159,6 +176,8 @@ else var calendarEvent = new CalendarEventDocument { Id = eventId, + StartDate = selectedDate, + EndDate = endDate, EventType = eventType, Description = description, StartTime = ParseTime(startTimeStr), @@ -180,7 +199,8 @@ else return; } - var confirmed = await JS.InvokeAsync("confirm", $"Delete calendar event '{description}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone."); + var eventName = CalendarEventFormatter.GetDisplayDescription(eventType, description); + var confirmed = await JS.InvokeAsync("confirm", $"Delete calendar event '{eventName}' starting on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone."); if (!confirmed) { return; @@ -229,4 +249,9 @@ else ? parsed : null; } + + private static string FormatDisplayDate(DateOnly date) + { + return date.ToString("dddd dd/MM/yyyy"); + } } \ No newline at end of file diff --git a/Components/Pages/CalendarView.razor b/Components/Pages/CalendarView.razor index 0368681..8505c9d 100644 --- a/Components/Pages/CalendarView.razor +++ b/Components/Pages/CalendarView.razor @@ -5,7 +5,6 @@ @inject IWorkDayService WorkDayService @inject IItalianFestivitySource FestivitySource -@inject NavigationManager Navigation @inject IJSRuntime JS Calendar @@ -50,76 +49,75 @@ else @foreach (var cell in week) { - @if (cell is null) - { - - } - else - { - -
@cell.Date.Day
- - @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) + +
+
@cell.Date.Day
+ @if (!cell.IsCurrentMonth) { - +
@cell.Date.ToString("MMM")
} +
- @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) - { - - } + @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) + { + + } -
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
+ @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) + { + + } - @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 ?? []) - { - - } -
- -
- - +
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
+ + @if (IsActiveCell(cell.Date)) + { +
+
+
+
@FormatDisplayDate(cell.Date)
+
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 ?? []) + { + + } +
+ +
+ + +
+
+ } + } } @@ -194,6 +192,16 @@ else
} } + +@if (workUnitModalDate.HasValue) +{ + +} + +@if (calendarEventModalDate.HasValue) +{ + +}
@code { @@ -203,12 +211,16 @@ else private DateOnly firstOfMonth; private bool loading = true; - private List weeks = []; - private IReadOnlyCollection festivities = []; + private List weeks = []; + private readonly Dictionary> festivitiesByYear = []; private DateOnly? activeDate; private bool includePreviewTotals; private MonthlySummaryModel? monthTotals; private string? statusMessage; + private DateOnly? workUnitModalDate; + private string? workUnitModalId; + private DateOnly? calendarEventModalDate; + private string? calendarEventModalId; protected override async Task OnInitializedAsync() { @@ -244,41 +256,32 @@ else { loading = true; activeDate = null; - festivities = FestivitySource.GetFestivities(firstOfMonth.Year); - var lastDay = firstOfMonth.AddMonths(1).AddDays(-1); - var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay); + var gridStart = GetGridStart(firstOfMonth); + var gridEnd = GetGridEnd(lastDay); + var entries = await WorkDayService.GetRangeAsync(gridStart, gridEnd); 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)) + for (var weekStart = gridStart; weekStart <= gridEnd; weekStart = weekStart.AddDays(7)) { - currentWeek[dayOfWeek] = new CalendarCell + var week = new CalendarCell[7]; + for (var columnIndex = 0; columnIndex < 7; columnIndex++) { - 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; + var date = weekStart.AddDays(columnIndex); + week[columnIndex] = new CalendarCell + { + Date = date, + ColumnIndex = columnIndex, + IsCurrentMonth = date.Month == firstOfMonth.Month && date.Year == firstOfMonth.Year, + IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday, + IsFestivity = GetFestivities(date.Year).Contains(date), + Entry = lookup.GetValueOrDefault(date) + }; } - } - if (dayOfWeek > 0) - { - weeks.Add(currentWeek); + weeks.Add(week); } loading = false; @@ -331,22 +334,46 @@ else private void CreateWorkUnit(DateOnly date) { - Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}"); + OpenWorkUnit(date, null); } private void CreateCalendarEvent(DateOnly date) { - Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}"); + OpenCalendarEvent(date, null); } - private void OpenWorkUnit(DateOnly date, string workUnitId) + private void OpenWorkUnit(DateOnly date, string? workUnitId) { - Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}"); + activeDate = null; + workUnitModalDate = date; + workUnitModalId = workUnitId; } - private void OpenCalendarEvent(DateOnly date, string eventId) + private void OpenCalendarEvent(DateOnly date, string? eventId) { - Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}"); + activeDate = null; + calendarEventModalDate = date; + calendarEventModalId = eventId; + } + + private async Task HandleEditorSavedAsync() + { + CloseWorkUnitModal(); + CloseCalendarEventModal(); + statusMessage = null; + await LoadMonth(); + } + + private void CloseWorkUnitModal() + { + workUnitModalDate = null; + workUnitModalId = null; + } + + private void CloseCalendarEventModal() + { + calendarEventModalDate = null; + calendarEventModalId = null; } private async Task GeneratePreviewWorkUnitsAsync() @@ -465,10 +492,44 @@ else return DurationFormatter.FormatHours(value); } + private static string FormatDisplayDate(DateOnly date) + { + return date.ToString("dddd dd/MM/yyyy"); + } + + private IReadOnlyCollection GetFestivities(int year) + { + if (!festivitiesByYear.TryGetValue(year, out var festivities)) + { + festivities = FestivitySource.GetFestivities(year); + festivitiesByYear[year] = festivities; + } + + return festivities; + } + + private static DateOnly GetGridStart(DateOnly firstDayOfMonth) + { + var offset = ((int)firstDayOfMonth.DayOfWeek + 6) % 7; + return firstDayOfMonth.AddDays(-offset); + } + + private static DateOnly GetGridEnd(DateOnly lastDayOfMonth) + { + var offset = (7 - (((int)lastDayOfMonth.DayOfWeek + 6) % 7) - 1 + 7) % 7; + return lastDayOfMonth.AddDays(offset); + } + + private static DateOnly GetCalendarEventOwnerDate(CalendarEventDocument calendarEvent, DateOnly fallbackDate) + { + return calendarEvent.StartDate == default ? fallbackDate : calendarEvent.StartDate; + } + private sealed class CalendarCell { public DateOnly Date { get; set; } public int ColumnIndex { get; set; } + public bool IsCurrentMonth { get; set; } public bool IsWeekend { get; set; } public bool IsFestivity { get; set; } public WorkDayDocument? Entry { get; set; } diff --git a/Components/Pages/GridView.razor b/Components/Pages/GridView.razor index d016272..1df6f64 100644 --- a/Components/Pages/GridView.razor +++ b/Components/Pages/GridView.razor @@ -63,7 +63,7 @@ else { @foreach (var calendarEvent in row.Entry.CalendarEvents) { -
@calendarEvent.EventType: @calendarEvent.Description
+
@CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType): @CalendarEventFormatter.GetDisplayDescription(calendarEvent)
} } else diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index 754e7c2..969580a 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -22,7 +22,7 @@ else
- +
@@ -42,12 +42,12 @@ else
- +
- +
@@ -191,11 +191,11 @@ else RecomputePreview(); } - private async Task OnDateChanged(ChangeEventArgs e) + private async Task OnDateChangedAsync(DateOnly? value) { - if (DateOnly.TryParse(e.Value?.ToString(), out var d)) + if (value.HasValue) { - selectedDate = d; + selectedDate = value.Value; statusMessage = null; } } diff --git a/Components/Shared/CalendarEventEditorModal.razor b/Components/Shared/CalendarEventEditorModal.razor new file mode 100644 index 0000000..a467dd8 --- /dev/null +++ b/Components/Shared/CalendarEventEditorModal.razor @@ -0,0 +1,264 @@ +@inject IWorkDayService WorkDayService +@inject IJSRuntime JS + +
+
+ +
+
+ +@code { + [Parameter] public DateOnly Date { get; set; } + [Parameter] public string? EventId { get; set; } + [Parameter] public EventCallback OnSaved { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + + private bool loaded; + private bool isExistingEvent; + private DateOnly selectedDate; + private string eventId = string.Empty; + private CalendarEventType eventType = CalendarEventType.Generic; + private string description = string.Empty; + private string? startTimeStr; + private string? endTimeStr; + private DateOnly? endDate; + private string? statusMessage; + private string? loadKey; + + protected override async Task OnParametersSetAsync() + { + var nextKey = $"{Date:yyyy-MM-dd}|{EventId}"; + if (nextKey == loadKey) + { + return; + } + + loadKey = nextKey; + loaded = false; + selectedDate = Date; + statusMessage = null; + await LoadEventAsync(); + loaded = true; + } + + private async Task LoadEventAsync() + { + if (string.IsNullOrWhiteSpace(EventId)) + { + SetDefaults(); + return; + } + + var existing = await WorkDayService.GetCalendarEventAsync(selectedDate, EventId); + if (existing is not null) + { + eventId = existing.Id; + selectedDate = existing.StartDate == default ? selectedDate : existing.StartDate; + eventType = existing.EventType; + description = existing.Description; + startTimeStr = existing.StartTime?.ToString("HH:mm"); + endTimeStr = existing.EndTime?.ToString("HH:mm"); + endDate = existing.EndDate; + isExistingEvent = true; + } + else + { + SetDefaults(); + statusMessage = "The selected calendar event was not found. A new event will be created."; + } + } + + private void SetDefaults() + { + eventId = string.Empty; + eventType = CalendarEventType.Generic; + description = string.Empty; + startTimeStr = null; + endTimeStr = null; + endDate = null; + isExistingEvent = false; + } + + private Task OnDateChangedAsync(DateOnly? value) + { + if (value.HasValue) + { + selectedDate = value.Value; + statusMessage = null; + } + + return Task.CompletedTask; + } + + private Task OnEndDateChangedAsync(DateOnly? value) + { + endDate = value; + statusMessage = null; + return Task.CompletedTask; + } + + private Task OnStartTimeChanged(ChangeEventArgs e) + { + startTimeStr = e.Value?.ToString(); + statusMessage = null; + return Task.CompletedTask; + } + + private Task OnEndTimeChanged(ChangeEventArgs e) + { + endTimeStr = e.Value?.ToString(); + statusMessage = null; + return Task.CompletedTask; + } + + private async Task SaveAsync() + { + var calendarEvent = new CalendarEventDocument + { + Id = eventId, + StartDate = selectedDate, + EndDate = endDate, + EventType = eventType, + Description = description, + StartTime = ParseTime(startTimeStr), + EndTime = ParseTime(endTimeStr) + }; + + await WorkDayService.SaveCalendarEventAsync(Date, calendarEvent); + await OnSaved.InvokeAsync(); + } + + private async Task DeleteAsync() + { + if (!isExistingEvent || string.IsNullOrWhiteSpace(eventId)) + { + return; + } + + var eventName = CalendarEventFormatter.GetDisplayDescription(eventType, description); + var confirmed = await JS.InvokeAsync("confirm", $"Delete calendar event '{eventName}' starting on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone."); + if (!confirmed) + { + return; + } + + var deleted = await WorkDayService.DeleteCalendarEventAsync(Date, eventId); + if (deleted) + { + await OnSaved.InvokeAsync(); + return; + } + + statusMessage = "Unable to delete the calendar event."; + } + + private Task CloseAsync() => OnClosed.InvokeAsync(); + + private decimal? GetDuration() + { + var start = ParseTime(startTimeStr); + var end = ParseTime(endTimeStr); + if (!start.HasValue || !end.HasValue || end <= start) + { + return null; + } + + return Math.Round((decimal)(end.Value - start.Value).TotalHours, 2, MidpointRounding.AwayFromZero); + } + + private string FormatDuration() => GetDuration() is { } duration ? FormatDurationHours(duration) : "—"; + + private static string FormatDurationHours(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 static TimeOnly? ParseTime(string? value) + { + return !string.IsNullOrWhiteSpace(value) && TimeOnly.TryParse(value, out var parsed) + ? parsed + : null; + } + + private static string FormatDisplayDate(DateOnly date) + { + return date.ToString("dddd dd/MM/yyyy"); + } +} \ No newline at end of file diff --git a/Components/Shared/LocalizedDateInput.razor b/Components/Shared/LocalizedDateInput.razor new file mode 100644 index 0000000..427d63a --- /dev/null +++ b/Components/Shared/LocalizedDateInput.razor @@ -0,0 +1,246 @@ +@using System.Globalization + +
+
+ + +
+ + @if (isOpen && !Disabled) + { +
+
+ +
@visibleMonth.ToDateTime(TimeOnly.MinValue).ToString("MMMM yyyy", ItalianCulture)
+ +
+ +
+ @foreach (var weekday in mondayFirstWeekdays) + { +
@weekday
+ } +
+ +
+ @foreach (var day in calendarDays) + { + + } +
+ + @if (AllowEmpty) + { +
+ +
+ } +
+ } +
+ +@code { + private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT"); + private static readonly string[] SupportedFormats = ["dd/MM/yyyy", "d/M/yyyy", "dd/M/yyyy", "d/MM/yyyy"]; + private static readonly string[] mondayFirstWeekdays = BuildMondayFirstWeekdays(); + + [Parameter] public DateOnly? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool AllowEmpty { get; set; } + [Parameter] public string? InputId { get; set; } + [Parameter] public string? TestId { get; set; } + + private DateOnly? lastValue; + private string displayValue = string.Empty; + private bool isOpen; + private DateOnly visibleMonth; + private IReadOnlyList calendarDays = []; + + protected override void OnParametersSet() + { + if (visibleMonth == default) + { + visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today)); + calendarDays = BuildCalendarDays(visibleMonth); + } + + if (Value != lastValue) + { + lastValue = Value; + displayValue = FormatValue(Value); + visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today)); + calendarDays = BuildCalendarDays(visibleMonth); + } + } + + private void OpenPopup(FocusEventArgs _) + { + if (Disabled) + { + return; + } + + isOpen = true; + visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today)); + calendarDays = BuildCalendarDays(visibleMonth); + } + + private void TogglePopup() + { + if (Disabled) + { + return; + } + + isOpen = !isOpen; + if (isOpen) + { + visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today)); + calendarDays = BuildCalendarDays(visibleMonth); + } + } + + private async Task OnTextChangedAsync(ChangeEventArgs e) + { + displayValue = e.Value?.ToString() ?? string.Empty; + + if (TryParseInput(displayValue, out var parsedDate)) + { + await SetValueAsync(parsedDate, closePopup: false); + return; + } + + if (AllowEmpty && string.IsNullOrWhiteSpace(displayValue)) + { + await SetValueAsync(null, closePopup: false); + return; + } + + displayValue = FormatValue(Value); + } + + private async Task SelectDateAsync(DateOnly date) + { + await SetValueAsync(date, closePopup: true); + } + + private async Task ClearAsync() + { + await SetValueAsync(null, closePopup: true); + } + + private async Task SetValueAsync(DateOnly? date, bool closePopup) + { + lastValue = date; + Value = date; + displayValue = FormatValue(date); + if (date.HasValue) + { + visibleMonth = ToFirstOfMonth(date.Value); + calendarDays = BuildCalendarDays(visibleMonth); + } + + if (closePopup) + { + isOpen = false; + } + + await ValueChanged.InvokeAsync(date); + } + + private void ShowPreviousMonth() + { + visibleMonth = visibleMonth.AddMonths(-1); + calendarDays = BuildCalendarDays(visibleMonth); + } + + private void ShowNextMonth() + { + visibleMonth = visibleMonth.AddMonths(1); + calendarDays = BuildCalendarDays(visibleMonth); + } + + private void HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + isOpen = false; + } + } + + private string GetInputTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-input" : $"{TestId}-input"; + + private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover"; + + private string GetDayTestId(DateOnly date) => string.IsNullOrWhiteSpace(TestId) + ? $"localized-date-day-{date:yyyy-MM-dd}" + : $"{TestId}-day-{date:yyyy-MM-dd}"; + + private static string FormatValue(DateOnly? date) + { + return date?.ToString("dd/MM/yyyy", ItalianCulture) ?? string.Empty; + } + + private static bool TryParseInput(string? value, out DateOnly date) + { + foreach (var format in SupportedFormats) + { + if (DateOnly.TryParseExact(value, format, ItalianCulture, DateTimeStyles.None, out date)) + { + return true; + } + } + + return DateOnly.TryParse(value, ItalianCulture, DateTimeStyles.None, out date); + } + + private static DateOnly ToFirstOfMonth(DateOnly date) => new(date.Year, date.Month, 1); + + private static IReadOnlyList BuildCalendarDays(DateOnly month) + { + var firstDayOfMonth = ToFirstOfMonth(month); + var lastDayOfMonth = firstDayOfMonth.AddMonths(1).AddDays(-1); + var gridStart = firstDayOfMonth.AddDays(-(((int)firstDayOfMonth.DayOfWeek + 6) % 7)); + var gridEnd = lastDayOfMonth.AddDays((7 - (((int)lastDayOfMonth.DayOfWeek + 6) % 7) - 1 + 7) % 7); + var days = new List(); + + for (var date = gridStart; date <= gridEnd; date = date.AddDays(1)) + { + days.Add(new CalendarDayCell(date, date.Month == month.Month && date.Year == month.Year)); + } + + return days; + } + + private static string[] BuildMondayFirstWeekdays() + { + return Enumerable.Range(0, 7) + .Select(index => ItalianCulture.DateTimeFormat.AbbreviatedDayNames[(index + 1) % 7]) + .Select(dayName => ItalianCulture.TextInfo.ToTitleCase(dayName)) + .ToArray(); + } + + private sealed record CalendarDayCell(DateOnly Date, bool IsCurrentMonth); +} \ No newline at end of file diff --git a/Components/Shared/WorkUnitEditorModal.razor b/Components/Shared/WorkUnitEditorModal.razor new file mode 100644 index 0000000..e22d715 --- /dev/null +++ b/Components/Shared/WorkUnitEditorModal.razor @@ -0,0 +1,411 @@ +@inject IWorkDayService WorkDayService +@inject IAppSettingsService AppSettingsService +@inject IJSRuntime JS + +
+
+ +
+
+ +@code { + [Parameter] public DateOnly Date { get; set; } + [Parameter] public string? UnitId { get; set; } + [Parameter] public EventCallback OnSaved { get; set; } + [Parameter] public EventCallback OnClosed { get; set; } + + private bool loaded; + private DateOnly selectedDate; + private string unitId = string.Empty; + private string label = "Work unit"; + private WorkUnitLocation location = WorkUnitLocation.Office; + private string? startTimeStr; + private string? endTimeStr; + private decimal manualWorkedHours; + private string manualWorkedHoursStr = "00:00"; + private bool isPreview; + private string? notes; + private string? statusMessage; + private bool isExistingUnit; + private WorkDayDocument? selectedDay; + private string? loadKey; + + private decimal grossIncome; + private decimal netIncome; + private decimal? calculatedWorkedHours; + private decimal workedHoursDelta; + private decimal dayTotalHours; + private int dayWorkUnitCount; + + private AppSettingsDocument settings = new(); + + protected override async Task OnParametersSetAsync() + { + var nextKey = $"{Date:yyyy-MM-dd}|{UnitId}"; + if (nextKey == loadKey) + { + return; + } + + loadKey = nextKey; + loaded = false; + selectedDate = Date; + statusMessage = null; + settings = await AppSettingsService.GetAsync(); + await LoadUnitAsync(); + loaded = true; + } + + private async Task LoadUnitAsync() + { + if (string.IsNullOrWhiteSpace(UnitId)) + { + selectedDay = await WorkDayService.GetAsync(selectedDate); + SetDefaults(); + return; + } + + selectedDay = await WorkDayService.GetAsync(selectedDate); + var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId); + if (existing is not null) + { + unitId = existing.Id; + label = existing.Label; + location = existing.Location; + startTimeStr = existing.StartTime?.ToString("HH:mm"); + endTimeStr = existing.EndTime?.ToString("HH:mm"); + manualWorkedHours = existing.ManualWorkedHours; + manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours); + isPreview = existing.IsPreview; + notes = existing.Notes; + isExistingUnit = true; + } + else + { + SetDefaults(); + statusMessage = "The selected work unit was not found. A new unit will be created for this day."; + } + + RecomputePreview(); + } + + private async Task OnDateChangedAsync(DateOnly? value) + { + if (value.HasValue) + { + selectedDate = value.Value; + selectedDay = await WorkDayService.GetAsync(selectedDate); + statusMessage = null; + RecomputePreview(); + } + } + + private Task OnStartTimeChanged(ChangeEventArgs e) + { + startTimeStr = e.Value?.ToString(); + SyncManualHoursToCalculated(); + statusMessage = null; + return Task.CompletedTask; + } + + private Task OnEndTimeChanged(ChangeEventArgs e) + { + endTimeStr = e.Value?.ToString(); + SyncManualHoursToCalculated(); + statusMessage = null; + return Task.CompletedTask; + } + + private Task OnManualWorkedHoursChanged(ChangeEventArgs e) + { + var rawValue = e.Value?.ToString(); + if (TryParseDurationHours(rawValue, out var parsedHours)) + { + manualWorkedHours = parsedHours; + manualWorkedHoursStr = FormatDurationHours(parsedHours); + } + else + { + manualWorkedHoursStr = FormatDurationHours(manualWorkedHours); + } + + RecomputePreview(); + statusMessage = null; + return Task.CompletedTask; + } + + private void SetDefaults() + { + unitId = string.Empty; + label = "Work unit"; + location = WorkUnitLocation.Office; + startTimeStr = null; + endTimeStr = null; + manualWorkedHours = 0m; + manualWorkedHoursStr = "00:00"; + isPreview = false; + notes = null; + isExistingUnit = false; + RecomputePreview(); + } + + private void SyncManualHoursToCalculated() + { + var calculated = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr)); + manualWorkedHours = calculated ?? 0m; + manualWorkedHoursStr = FormatDurationHours(manualWorkedHours); + RecomputePreview(); + } + + private void RecomputePreview() + { + calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr)); + workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m); + grossIncome = manualWorkedHours * settings.HourlyGrossRate; + var taxableBase = grossIncome * settings.ProfitabilityCoefficient; + netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate); + RecomputeDayTotals(); + } + + private async Task SaveAsync() + { + RecomputePreview(); + + var workUnit = new WorkUnitDocument + { + Id = unitId, + Label = label, + Location = location, + StartTime = ParseTime(startTimeStr), + EndTime = ParseTime(endTimeStr), + ManualWorkedHours = Math.Max(0m, manualWorkedHours), + IsPreview = isPreview, + Notes = notes + }; + + await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit); + await OnSaved.InvokeAsync(); + } + + private async Task DeleteAsync() + { + if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId)) + { + return; + } + + var confirmed = await JS.InvokeAsync("confirm", $"Delete work unit '{label}' on {FormatDisplayDate(selectedDate)}?\nThis cannot be undone."); + if (!confirmed) + { + return; + } + + var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId); + if (deleted) + { + await OnSaved.InvokeAsync(); + return; + } + + statusMessage = "Unable to delete the work unit."; + } + + private Task CloseAsync() => OnClosed.InvokeAsync(); + + 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 string FormatDurationHours(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 static string FormatHours(decimal? value) + { + return value.HasValue ? DurationFormatter.FormatHours(value.Value) : "—"; + } + + private static string FormatSignedHours(decimal value) + { + if (value == 0m) + { + return "00:00"; + } + + var prefix = value > 0m ? "+" : "-"; + return prefix + DurationFormatter.FormatHours(Math.Abs(value)); + } + + private static bool TryParseDurationHours(string? value, out decimal hours) + { + hours = 0m; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (TimeOnly.TryParse(value, out var parsedTime)) + { + hours = parsedTime.Hour + (parsedTime.Minute / 60m); + return true; + } + + if (decimal.TryParse(value, out var parsedDecimal)) + { + hours = Math.Max(0m, parsedDecimal); + return true; + } + + return false; + } + + private static string FormatDisplayDate(DateOnly date) + { + return date.ToString("dddd dd/MM/yyyy"); + } +} \ No newline at end of file diff --git a/Components/_Imports.razor b/Components/_Imports.razor index b829145..199e3ff 100644 --- a/Components/_Imports.razor +++ b/Components/_Imports.razor @@ -10,6 +10,7 @@ @using Microsoft.JSInterop @using WorkTracker @using WorkTracker.Components +@using WorkTracker.Components.Shared @using WorkTracker.Domain @using WorkTracker.Formatting @using WorkTracker.Services.Festivities diff --git a/Domain/CalendarEventDocument.cs b/Domain/CalendarEventDocument.cs index 22fb24f..dd2f8fd 100644 --- a/Domain/CalendarEventDocument.cs +++ b/Domain/CalendarEventDocument.cs @@ -4,6 +4,10 @@ public sealed class CalendarEventDocument { public string Id { get; set; } = string.Empty; + public DateOnly StartDate { get; set; } + + public DateOnly? EndDate { get; set; } + public CalendarEventType EventType { get; set; } = CalendarEventType.Generic; public string Description { get; set; } = string.Empty; diff --git a/Formatting/CalendarEventFormatter.cs b/Formatting/CalendarEventFormatter.cs new file mode 100644 index 0000000..e7f867f --- /dev/null +++ b/Formatting/CalendarEventFormatter.cs @@ -0,0 +1,27 @@ +using WorkTracker.Domain; + +namespace WorkTracker.Formatting; + +public static class CalendarEventFormatter +{ + public static string GetDisplayDescription(CalendarEventDocument calendarEvent) + { + return GetDisplayDescription(calendarEvent.EventType, calendarEvent.Description); + } + + public static string GetDisplayDescription(CalendarEventType eventType, string? description) + { + return string.IsNullOrWhiteSpace(description) + ? GetEventTypeName(eventType) + : description.Trim(); + } + + public static string GetEventTypeName(CalendarEventType eventType) + { + return eventType switch + { + CalendarEventType.DayOff => "Day Off", + _ => eventType.ToString() + }; + } +} \ No newline at end of file diff --git a/Services/WorkDays/CouchbaseLiteWorkDayService.cs b/Services/WorkDays/CouchbaseLiteWorkDayService.cs index 34d9492..6b1888e 100644 --- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs +++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs @@ -1,5 +1,7 @@ using Couchbase.Lite; +using Couchbase.Lite.Query; using WorkTracker.Domain; +using WorkTracker.Formatting; using WorkTracker.Services.Festivities; using WorkTracker.Services.Settings; using WorkTracker.Services.Storage; @@ -44,7 +46,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); - return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); + var calendarEvent = day?.CalendarEvents.FirstOrDefault(entry => string.Equals(entry.Id, calendarEventId, StringComparison.Ordinal)); + if (calendarEvent is not null) + { + return calendarEvent; + } + + return FindCalendarEventLocation(calendarEventId, cancellationToken)?.CalendarEvent; } public async Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default) @@ -92,32 +100,59 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService { 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; + var existingLocation = string.IsNullOrWhiteSpace(calendarEvent.Id) + ? null + : FindCalendarEventLocation(calendarEvent.Id, cancellationToken); + var targetDate = calendarEvent.StartDate == default ? date : calendarEvent.StartDate; + var endDate = calendarEvent.EndDate; + + if (endDate.HasValue && endDate.Value < targetDate) + { + (targetDate, endDate) = (endDate.Value, targetDate); + } 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.StartDate = targetDate; + calendarEvent.EndDate = endDate.HasValue && endDate.Value > targetDate + ? endDate.Value + : null; + calendarEvent.Description = calendarEvent.Description?.Trim() ?? string.Empty; + calendarEvent.CreatedAtUtc = existingLocation?.CalendarEvent.CreatedAtUtc ?? now; calendarEvent.UpdatedAtUtc = now; Compute(calendarEvent); - if (existingIndex >= 0) + if (existingLocation is not null) { - day.CalendarEvents[existingIndex] = calendarEvent; + var ownerDay = existingLocation.Day; + if (ownerDay.Date == targetDate) + { + ownerDay.CalendarEvents[existingLocation.Index] = calendarEvent; + ownerDay.UpdatedAtUtc = now; + SortEntries(ownerDay); + SaveDocument(ownerDay); + return calendarEvent; + } + + ownerDay.CalendarEvents.RemoveAt(existingLocation.Index); + DeleteOrSaveDay(ownerDay); + } + + var targetDay = await GetOrCreateDayAsync(targetDate, cancellationToken); + var targetIndex = targetDay.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal)); + if (targetIndex >= 0) + { + targetDay.CalendarEvents[targetIndex] = calendarEvent; } else { - day.CalendarEvents.Add(calendarEvent); + targetDay.CalendarEvents.Add(calendarEvent); } - day.UpdatedAtUtc = now; - SortEntries(day); - SaveDocument(day); + targetDay.UpdatedAtUtc = now; + SortEntries(targetDay); + SaveDocument(targetDay); return calendarEvent; } @@ -145,36 +180,79 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); - if (day is null) + if (day is not null) + { + var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); + if (removed > 0) + { + return DeleteOrSaveDay(day); + } + } + + var existingLocation = FindCalendarEventLocation(calendarEventId, cancellationToken); + if (existingLocation 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); + existingLocation.Day.CalendarEvents.RemoveAt(existingLocation.Index); + return DeleteOrSaveDay(existingLocation.Day); } public Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var results = new List(); - for (var date = from; date <= to; date = date.AddDays(1)) + var storedDays = GetAllDays(cancellationToken); + var results = new Dictionary(); + + foreach (var day in storedDays) { - var id = date.ToString("yyyy-MM-dd"); - var doc = workDaysCollection.GetDocument(id); - if (doc is not null) + cancellationToken.ThrowIfCancellationRequested(); + + if (day.Date >= from && day.Date <= to) { - results.Add(Map(doc)); + if (results.TryGetValue(day.Date, out var existingRangeDay)) + { + MergeStoredDayIntoRangeDay(existingRangeDay, day); + } + else + { + results[day.Date] = CloneDayForRange(day.Date, day); + } + } + + foreach (var calendarEvent in day.CalendarEvents) + { + var startDate = calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate; + var endDate = calendarEvent.EndDate ?? startDate; + if (endDate < from || startDate > to) + { + continue; + } + + var overlapStart = startDate < from ? from : startDate; + var overlapEnd = endDate > to ? to : endDate; + for (var date = overlapStart; date <= overlapEnd; date = date.AddDays(1)) + { + if (!results.TryGetValue(date, out var rangeDay)) + { + rangeDay = CloneDayForRange(date); + results.Add(date, rangeDay); + } + + AddProjectedCalendarEvent(rangeDay, calendarEvent); + } } } - return Task.FromResult>(results); + var orderedResults = results.Values.OrderBy(day => day.Date).ToList(); + foreach (var day in orderedResults) + { + SortEntries(day); + } + + return Task.FromResult>(orderedResults); } public async Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) @@ -254,6 +332,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService var from = new DateOnly(year, month, 1); var to = from.AddMonths(1).AddDays(-1); var createdDays = 0; + var projectedDays = await GetRangeAsync(from, to, cancellationToken); + var projectedLookup = projectedDays.ToDictionary(day => day.Date); for (var date = from; date <= to; date = date.AddDays(1)) { @@ -265,7 +345,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService } var day = await GetOrCreateDayAsync(date, cancellationToken); - if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType))) + var projectedDay = projectedLookup.GetValueOrDefault(date); + if (day.WorkUnits.Count > 0 || projectedDay?.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)) == true) { continue; } @@ -320,10 +401,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService { var entry = new MutableDictionaryObject(); entry.SetString("id", calendarEvent.Id); + entry.SetString("startDate", (calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate).ToString("yyyy-MM-dd")); 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.EndDate.HasValue) + { + entry.SetString("endDate", calendarEvent.EndDate.Value.ToString("yyyy-MM-dd")); + } if (calendarEvent.DurationHours.HasValue) { entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value)); @@ -364,6 +450,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService private static WorkDayDocument Map(Document doc) { + var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"); + if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents")) { return MapLegacy(doc); @@ -392,7 +480,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService var calendarEvent = calendarEventsArray.GetDictionary(i); if (calendarEvent is not null) { - calendarEvents.Add(MapCalendarEvent(calendarEvent)); + calendarEvents.Add(MapCalendarEvent(calendarEvent, date)); } } } @@ -400,7 +488,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return new WorkDayDocument { Id = doc.Id, - Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"), + Date = date, IsWeekend = doc.GetBoolean("isWeekend"), IsItalianFestivity = doc.GetBoolean("isItalianFestivity"), WorkUnits = workUnits, @@ -593,12 +681,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent) { + var description = CalendarEventFormatter.GetDisplayDescription(calendarEvent); if (calendarEvent.StartTime.HasValue) { - return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})"; + return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description} ({calendarEvent.StartTime:HH:mm})"; } - return $"{calendarEvent.EventType}: {calendarEvent.Description}"; + return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description}"; } private static string FormatCompactHours(decimal value) @@ -701,13 +790,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return workUnit; } - private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent) + private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent, DateOnly owningDate) { var entry = new CalendarEventDocument { Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"), + StartDate = ReadDateOnly(calendarEvent, "startDate") ?? owningDate, + EndDate = ReadDateOnly(calendarEvent, "endDate"), EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic, - Description = calendarEvent.GetString("description") ?? "Calendar entry", + Description = calendarEvent.GetString("description") ?? string.Empty, StartTime = ReadTimeOnly(calendarEvent, "startTime"), EndTime = ReadTimeOnly(calendarEvent, "endTime"), DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null, @@ -769,6 +860,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService var calendarEvent = new CalendarEventDocument { Id = "legacy", + StartDate = date, EventType = MapLegacyEventType(dayType), Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!, CreatedAtUtc = day.CreatedAtUtc, @@ -810,6 +902,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService : null; } + private static DateOnly? ReadDateOnly(DictionaryObject doc, string key) + { + var value = doc.GetString(key); + return !string.IsNullOrEmpty(value) && DateOnly.TryParseExact(value, "yyyy-MM-dd", out var date) + ? date + : null; + } + private static decimal ReadDecimal(Document doc, string key, decimal defaultValue) { return doc.Contains(key) @@ -824,6 +924,148 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService : defaultValue; } + private IReadOnlyList GetAllDays(CancellationToken cancellationToken) + { + var query = QueryBuilder + .Select(SelectResult.Expression(Meta.ID)) + .From(DataSource.Collection(workDaysCollection)) + .OrderBy(Ordering.Expression(Meta.ID)); + + var results = new List(); + foreach (var result in query.Execute()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var id = result.GetString(0); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var doc = workDaysCollection.GetDocument(id); + if (doc is not null) + { + results.Add(Map(doc)); + } + } + + return results; + } + + private WorkDayDocument CloneDayForRange(DateOnly date, WorkDayDocument? sourceDay = null) + { + 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), + WorkUnits = sourceDay?.WorkUnits.Select(CloneWorkUnit).ToList() ?? [], + CalendarEvents = [], + CreatedAtUtc = sourceDay?.CreatedAtUtc ?? DateTimeOffset.UtcNow, + UpdatedAtUtc = sourceDay?.UpdatedAtUtc ?? DateTimeOffset.UtcNow + }; + } + + private static WorkUnitDocument CloneWorkUnit(WorkUnitDocument workUnit) + { + return new WorkUnitDocument + { + Id = workUnit.Id, + Label = workUnit.Label, + Location = workUnit.Location, + StartTime = workUnit.StartTime, + EndTime = workUnit.EndTime, + IsPreview = workUnit.IsPreview, + ManualWorkedHours = workUnit.ManualWorkedHours, + CalculatedWorkedHours = workUnit.CalculatedWorkedHours, + WorkedHoursDelta = workUnit.WorkedHoursDelta, + GrossIncome = workUnit.GrossIncome, + NetIncome = workUnit.NetIncome, + Notes = workUnit.Notes, + CoeffSnapshot = new CoeffSnapshotDocument + { + StandardWorkHoursPerDay = workUnit.CoeffSnapshot.StandardWorkHoursPerDay, + HourlyGrossRate = workUnit.CoeffSnapshot.HourlyGrossRate, + ProfitabilityCoefficient = workUnit.CoeffSnapshot.ProfitabilityCoefficient, + InpsRate = workUnit.CoeffSnapshot.InpsRate, + SubstituteTaxRate = workUnit.CoeffSnapshot.SubstituteTaxRate + }, + CreatedAtUtc = workUnit.CreatedAtUtc, + UpdatedAtUtc = workUnit.UpdatedAtUtc + }; + } + + private static CalendarEventDocument CloneCalendarEvent(CalendarEventDocument calendarEvent) + { + return new CalendarEventDocument + { + Id = calendarEvent.Id, + StartDate = calendarEvent.StartDate, + EndDate = calendarEvent.EndDate, + EventType = calendarEvent.EventType, + Description = calendarEvent.Description, + StartTime = calendarEvent.StartTime, + EndTime = calendarEvent.EndTime, + DurationHours = calendarEvent.DurationHours, + CreatedAtUtc = calendarEvent.CreatedAtUtc, + UpdatedAtUtc = calendarEvent.UpdatedAtUtc + }; + } + + private static void AddProjectedCalendarEvent(WorkDayDocument rangeDay, CalendarEventDocument calendarEvent) + { + if (rangeDay.CalendarEvents.Any(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal))) + { + return; + } + + rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent)); + } + + private static void MergeStoredDayIntoRangeDay(WorkDayDocument rangeDay, WorkDayDocument storedDay) + { + rangeDay.IsWeekend = storedDay.IsWeekend; + rangeDay.IsItalianFestivity = storedDay.IsItalianFestivity; + rangeDay.CreatedAtUtc = storedDay.CreatedAtUtc; + rangeDay.UpdatedAtUtc = storedDay.UpdatedAtUtc; + rangeDay.WorkUnits = storedDay.WorkUnits.Select(CloneWorkUnit).ToList(); + + foreach (var calendarEvent in storedDay.CalendarEvents) + { + var existingIndex = rangeDay.CalendarEvents.FindIndex(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal)); + if (existingIndex >= 0) + { + rangeDay.CalendarEvents[existingIndex] = CloneCalendarEvent(calendarEvent); + } + else + { + rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent)); + } + } + } + + private CalendarEventLocation? FindCalendarEventLocation(string calendarEventId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(calendarEventId)) + { + return null; + } + + foreach (var day in GetAllDays(cancellationToken)) + { + var index = day.CalendarEvents.FindIndex(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); + if (index >= 0) + { + return new CalendarEventLocation(day, index, day.CalendarEvents[index]); + } + } + + return null; + } + + private sealed record CalendarEventLocation(WorkDayDocument Day, int Index, CalendarEventDocument CalendarEvent); + private static DateTimeOffset ReadDateTimeOffset(Document doc, string key) { var value = doc.GetString(key); diff --git a/tests/playwright/date-locale.spec.ts b/tests/playwright/date-locale.spec.ts new file mode 100644 index 0000000..2777946 --- /dev/null +++ b/tests/playwright/date-locale.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; + +test.use({ + locale: 'en-US' +}); + +async function waitForBlazorConnection(page: Parameters[0]['page']) { + await page.waitForTimeout(750); +} + +test('date picker popup is monday-first and uses european formatting', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto('/calendar-event', { waitUntil: 'networkidle' }); + await waitForBlazorConnection(page); + + const startDateInput = page.getByTestId('calendar-event-start-date-input'); + await expect(startDateInput).toBeVisible(); + + await startDateInput.click(); + + const popup = page.getByTestId('calendar-event-start-date-popover'); + await expect(popup).toBeVisible(); + + const weekdayHeaders = popup.getByTestId('date-picker-weekday'); + await expect(weekdayHeaders).toHaveCount(7); + await expect(weekdayHeaders.first()).toHaveText(/^(Mon|Lun)$/); + + await page.getByTestId('calendar-event-start-date-day-2026-04-22').click(); + await expect(startDateInput).toHaveValue(/^22\/\d{2}\/\d{4}$/); +}); \ No newline at end of file diff --git a/wwwroot/app.css b/wwwroot/app.css index 63c0ce2..843d605 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -484,6 +484,10 @@ h1:focus { box-shadow: inset 0 0 0 0.15rem var(--wt-calendar-active); } +.calendar-cell-outside-month { + background-color: color-mix(in srgb, var(--bs-tertiary-bg) 72%, transparent); +} + .calendar-cell-today::after { content: ""; position: absolute; @@ -505,6 +509,18 @@ h1:focus { border-radius: 999px; } +.calendar-day-number-outside { + opacity: 0.72; +} + +.calendar-day-month-label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--bs-secondary-color); +} + .calendar-cell-today .calendar-day-number { background-color: var(--wt-calendar-today-badge-bg); color: var(--wt-calendar-today-badge-text); @@ -622,6 +638,139 @@ h1:focus { text-align: left; } +.calendar-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(15, 23, 42, 0.38); +} + +.calendar-modal-shell { + width: min(58rem, 100%); + max-height: calc(100vh - 2rem); +} + +.calendar-modal-shell-compact { + width: min(44rem, 100%); +} + +.calendar-modal-dialog { + display: flex; + flex-direction: column; + max-height: calc(100vh - 2rem); + background: var(--bs-body-bg); + color: var(--bs-body-color); + border: 1px solid var(--bs-border-color); + border-radius: 1rem; + box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.22); + overflow: hidden; +} + +.calendar-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.calendar-modal-body { + padding: 1rem 1.25rem; + overflow: auto; +} + +.calendar-modal-actions { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-top: 1px solid var(--bs-border-color); + background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-tertiary-bg)); +} + +.localized-date-input { + position: relative; +} + +.localized-date-input-toggle { + min-width: 3.25rem; +} + +.localized-date-input-popover { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + z-index: 1250; + width: min(18rem, 100vw - 2rem); + padding: 0.75rem; + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.85rem; + box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.16); +} + +.localized-date-input-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +.localized-date-input-month { + font-weight: 700; + text-transform: capitalize; +} + +.localized-date-input-weekdays, +.localized-date-input-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 0.2rem; +} + +.localized-date-input-weekdays { + margin-bottom: 0.35rem; +} + +.localized-date-input-weekday { + font-size: 0.75rem; + font-weight: 700; + text-align: center; + color: var(--bs-secondary-color); +} + +.localized-date-input-day { + border: 0; + border-radius: 0.55rem; + background: transparent; + padding: 0.45rem 0; + text-align: center; +} + +.localized-date-input-day:hover { + background: var(--bs-tertiary-bg); +} + +.localized-date-input-day-outside { + color: var(--bs-secondary-color); +} + +.localized-date-input-day-selected { + background: var(--bs-primary); + color: var(--bs-primary-bg-subtle, #fff); +} + +.localized-date-input-actions { + display: flex; + margin-top: 0.65rem; +} + .calendar-legend-work { background-color: #cfe2ff; color: #14213d; @@ -706,6 +855,34 @@ h1:focus { left: 0; width: calc(100vw - 2rem); } + + .calendar-modal-backdrop { + padding: 0.5rem; + } + + .calendar-modal-shell, + .calendar-modal-shell-compact { + width: 100%; + } + + .calendar-modal-dialog { + max-height: calc(100vh - 1rem); + } + + .calendar-modal-header, + .calendar-modal-body, + .calendar-modal-actions { + padding-left: 0.9rem; + padding-right: 0.9rem; + } + + .calendar-modal-actions { + flex-wrap: wrap; + } + + .localized-date-input-popover { + width: calc(100vw - 2rem); + } } /* Monthly timesheet summary */