diff --git a/.vscode/launch.json b/.vscode/launch.json index a82168c..4e1e71d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,88 +3,7 @@ "compounds": [], "configurations": [ { - "name": "WorkTracker: Debug Docker App + Browser", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "WorkTracker: Docker Debug Prepare", - "postDebugTask": "WorkTracker: Docker Debug Down", - "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", - "args": [ - "--urls", - "http://+:8080" - ], - "cwd": "/workspace", - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://+:8080", - "DOTNET_USE_POLLING_FILE_WATCHER": "1", - "UseHttpsRedirection": "false" - }, - "sourceFileMap": { - "/workspace": "${workspaceFolder}" - }, - "pipeTransport": { - "pipeProgram": "docker", - "pipeArgs": [ - "exec", - "-i", - "worktracker-dev", - "sh", - "-c" - ], - "debuggerPath": "/vsdbg/vsdbg", - "pipeCwd": "${workspaceFolder}", - "quoteArgs": false - }, - "serverReadyAction": { - "action": "openExternally", - "pattern": "\\bNow listening on:\\s+https?://\\S+", - "uriFormat": "http://localhost:8002" - }, - "justMyCode": true, - "requireExactSource": false, - "console": "internalConsole" - }, - { - "name": "WorkTracker: Debug Docker App", - "type": "coreclr", - "request": "launch", - "preLaunchTask": "WorkTracker: Docker Debug Prepare", - "postDebugTask": "WorkTracker: Docker Debug Down", - "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", - "args": [ - "--urls", - "http://+:8080" - ], - "cwd": "/workspace", - "env": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ASPNETCORE_URLS": "http://+:8080", - "DOTNET_USE_POLLING_FILE_WATCHER": "1", - "UseHttpsRedirection": "false" - }, - "sourceFileMap": { - "/workspace": "${workspaceFolder}" - }, - "pipeTransport": { - "pipeProgram": "docker", - "pipeArgs": [ - "exec", - "-i", - "worktracker-dev", - "sh", - "-c" - ], - "debuggerPath": "/vsdbg/vsdbg", - "pipeCwd": "${workspaceFolder}", - "quoteArgs": false - }, - "justMyCode": true, - "requireExactSource": false, - "console": "internalConsole" - }, - { - "name": "WorkTracker: Debug Docker App + Edge", + "name": "WorkTracker: Debug in Docker", "type": "coreclr", "request": "launch", "preLaunchTask": "WorkTracker: Docker Debug Prepare", @@ -119,19 +38,58 @@ }, "serverReadyAction": { "action": "debugWithEdge", - "pattern": "\\bNow listening on:\\s+https?://\\S+", - "uriFormat": "http://localhost:8002", - "webRoot": "${workspaceFolder}" + "pattern": "Now listening on:\\s+https?://\\S+:(\\d+)", + "uriFormat": "http://localhost:8002/?ready=%s" }, "justMyCode": true, "requireExactSource": false, "console": "internalConsole" }, { - "name": "WorkTracker: Launch Integrated Browser for Running Docker App", - "type": "editor-browser", + "name": "WorkTracker: Debug App in Docker", + "type": "coreclr", "request": "launch", - "url": "http://localhost:8002" + "preLaunchTask": "WorkTracker: Docker Debug Prepare", + "postDebugTask": "WorkTracker: Docker Debug Down", + "program": "/workspace/bin/Debug/net10.0/WorkTracker.dll", + "args": [ + "--urls", + "http://+:8080" + ], + "cwd": "/workspace", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://+:8080", + "DOTNET_USE_POLLING_FILE_WATCHER": "1", + "UseHttpsRedirection": "false" + }, + "sourceFileMap": { + "/workspace": "${workspaceFolder}" + }, + "pipeTransport": { + "pipeProgram": "docker", + "pipeArgs": [ + "exec", + "-i", + "worktracker-dev", + "sh", + "-c" + ], + "debuggerPath": "/vsdbg/vsdbg", + "pipeCwd": "${workspaceFolder}", + "quoteArgs": false + }, + "justMyCode": true, + "requireExactSource": false, + "console": "internalConsole" + }, + { + "name": "WorkTracker: Debug Edge", + "type": "msedge", + "request": "launch", + "url": "http://localhost:8002", + "webRoot": "${workspaceFolder}", + "internalConsoleOptions": "neverOpen" } ] } \ No newline at end of file diff --git a/Components/App.razor b/Components/App.razor index de639bd..1cf0d7a 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 76e43b7..b64dcff 100644 --- a/Components/Pages/CalendarEventEditor.razor +++ b/Components/Pages/CalendarEventEditor.razor @@ -20,14 +20,8 @@ else {
- - -
- -
- - -
Optional. Leave empty for a single-day event.
+ +
@@ -35,24 +29,24 @@ else
- - + +
- +
- +
@@ -84,10 +78,9 @@ else private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today); private string eventId = string.Empty; private CalendarEventType eventType = CalendarEventType.Generic; - private string description = string.Empty; + private string description = "Calendar entry"; private string? startTimeStr; private string? endTimeStr; - private DateOnly? endDate; private string? statusMessage; protected override async Task OnInitializedAsync() @@ -113,12 +106,10 @@ 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 @@ -132,31 +123,23 @@ else { eventId = string.Empty; eventType = CalendarEventType.Generic; - description = string.Empty; + description = "Calendar entry"; startTimeStr = null; endTimeStr = null; - endDate = null; isExistingEvent = false; } - private Task OnDateChangedAsync(DateOnly? value) + private Task OnDateChanged(ChangeEventArgs e) { - if (value.HasValue) + if (DateOnly.TryParse(e.Value?.ToString(), out var parsed)) { - selectedDate = value.Value; + selectedDate = parsed; 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(); @@ -176,8 +159,6 @@ else var calendarEvent = new CalendarEventDocument { Id = eventId, - StartDate = selectedDate, - EndDate = endDate, EventType = eventType, Description = description, StartTime = ParseTime(startTimeStr), @@ -199,8 +180,7 @@ else 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."); + var confirmed = await JS.InvokeAsync("confirm", $"Delete calendar event '{description}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone."); if (!confirmed) { return; @@ -249,9 +229,4 @@ 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 8505c9d..0368681 100644 --- a/Components/Pages/CalendarView.razor +++ b/Components/Pages/CalendarView.razor @@ -5,6 +5,7 @@ @inject IWorkDayService WorkDayService @inject IItalianFestivitySource FestivitySource +@inject NavigationManager Navigation @inject IJSRuntime JS Calendar @@ -49,75 +50,76 @@ else @foreach (var cell in week) { - -
-
@cell.Date.Day
- @if (!cell.IsCurrentMonth) + @if (cell is null) + { + + } + else + { + +
@cell.Date.Day
+ + @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) { -
@cell.Date.ToString("MMM")
+ } -
- @foreach (var workUnit in cell.Entry?.WorkUnits ?? []) - { - - } + @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) + { + + } - @foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? []) - { - - } +
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
-
@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))
- - @if (IsActiveCell(cell.Date)) - { -
-
-
-
@FormatDisplayDate(cell.Date)
-
Select an existing entry or create a new one.
+ @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 ?? []) + { + + } +
+ +
+ +
-
- -
- @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 ?? []) - { - - } -
- -
- - -
-
- } - + } + + } } } @@ -192,16 +194,6 @@ else
} } - -@if (workUnitModalDate.HasValue) -{ - -} - -@if (calendarEventModalDate.HasValue) -{ - -}
@code { @@ -211,16 +203,12 @@ else private DateOnly firstOfMonth; private bool loading = true; - private List weeks = []; - private readonly Dictionary> festivitiesByYear = []; + private List weeks = []; + private IReadOnlyCollection festivities = []; 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() { @@ -256,32 +244,41 @@ else { loading = true; activeDate = null; + festivities = FestivitySource.GetFestivities(firstOfMonth.Year); + var lastDay = firstOfMonth.AddMonths(1).AddDays(-1); - var gridStart = GetGridStart(firstOfMonth); - var gridEnd = GetGridEnd(lastDay); - var entries = await WorkDayService.GetRangeAsync(gridStart, gridEnd); + 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 = []; - for (var weekStart = gridStart; weekStart <= gridEnd; weekStart = weekStart.AddDays(7)) - { - var week = new CalendarCell[7]; - for (var columnIndex = 0; columnIndex < 7; columnIndex++) - { - 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) - }; - } + var currentWeek = new CalendarCell?[7]; + var dayOfWeek = ((int)firstOfMonth.DayOfWeek + 6) % 7; // Mon=0 - weeks.Add(week); + 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; @@ -334,46 +331,22 @@ else private void CreateWorkUnit(DateOnly date) { - OpenWorkUnit(date, null); + Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}"); } private void CreateCalendarEvent(DateOnly date) { - OpenCalendarEvent(date, null); + Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}"); } - private void OpenWorkUnit(DateOnly date, string? workUnitId) + private void OpenWorkUnit(DateOnly date, string workUnitId) { - activeDate = null; - workUnitModalDate = date; - workUnitModalId = workUnitId; + Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}"); } - private void OpenCalendarEvent(DateOnly date, string? eventId) + private void OpenCalendarEvent(DateOnly date, string 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; + Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}"); } private async Task GeneratePreviewWorkUnitsAsync() @@ -492,44 +465,10 @@ 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 1df6f64..d016272 100644 --- a/Components/Pages/GridView.razor +++ b/Components/Pages/GridView.razor @@ -63,7 +63,7 @@ else { @foreach (var calendarEvent in row.Entry.CalendarEvents) { -
@CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType): @CalendarEventFormatter.GetDisplayDescription(calendarEvent)
+
@calendarEvent.EventType: @calendarEvent.Description
} } else diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor index 6debd76..06b89ec 100644 --- a/Components/Pages/MonthlySummary.razor +++ b/Components/Pages/MonthlySummary.razor @@ -294,24 +294,12 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null) private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day) { - var classes = new List(); - - if (day.Date == DateOnly.FromDateTime(DateTime.Today)) - { - classes.Add("timesheet-summary-day-today"); - } - if (day.IsWeekend || day.IsHoliday) { - classes.Add("timesheet-summary-day-danger"); + return "timesheet-summary-day-danger"; } - if (day.IsClosure) - { - classes.Add("timesheet-summary-day-closure"); - } - - return string.Join(" ", classes); + return day.IsClosure ? "timesheet-summary-day-closure" : string.Empty; } private static string GetDayPopupClass(int index, int totalDays) diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor index a19fce2..754e7c2 100644 --- a/Components/Pages/WorkDayEditor.razor +++ b/Components/Pages/WorkDayEditor.razor @@ -22,7 +22,7 @@ else
- +
@@ -42,12 +42,12 @@ else
- +
- +
@@ -191,14 +191,12 @@ else RecomputePreview(); } - private async Task OnDateChangedAsync(DateOnly? value) + private async Task OnDateChanged(ChangeEventArgs e) { - if (value.HasValue) + if (DateOnly.TryParse(e.Value?.ToString(), out var d)) { - selectedDate = value.Value; + selectedDate = d; statusMessage = null; - selectedDay = await WorkDayService.GetAsync(selectedDate); - RecomputePreview(); } } diff --git a/Components/Shared/CalendarEventEditorModal.razor b/Components/Shared/CalendarEventEditorModal.razor deleted file mode 100644 index a467dd8..0000000 --- a/Components/Shared/CalendarEventEditorModal.razor +++ /dev/null @@ -1,264 +0,0 @@ -@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 deleted file mode 100644 index f2c6a72..0000000 --- a/Components/Shared/LocalizedDateInput.razor +++ /dev/null @@ -1,296 +0,0 @@ -@using System.Globalization -@implements IAsyncDisposable -@inject IJSRuntime JS - -
-
- - -
- - @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 bool outsideClickListenerActive; - private DateOnly visibleMonth; - private IReadOnlyList calendarDays = []; - private ElementReference rootElement; - private DotNetObjectReference? dotNetReference; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (isOpen && !outsideClickListenerActive) - { - dotNetReference ??= DotNetObjectReference.Create(this); - await JS.InvokeVoidAsync("workTrackerDateInput.registerOutsideClick", rootElement, dotNetReference); - outsideClickListenerActive = true; - } - else if (!isOpen && outsideClickListenerActive) - { - await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement); - outsideClickListenerActive = false; - } - - await base.OnAfterRenderAsync(firstRender); - } - - 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; - } - } - - [JSInvokable] - public Task ClosePopupFromOutsideClickAsync() - { - if (!isOpen) - { - return Task.CompletedTask; - } - - isOpen = false; - return InvokeAsync(StateHasChanged); - } - - public async ValueTask DisposeAsync() - { - if (outsideClickListenerActive) - { - try - { - await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement); - } - catch (JSDisconnectedException) - { - } - } - - dotNetReference?.Dispose(); - } - - 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 deleted file mode 100644 index e22d715..0000000 --- a/Components/Shared/WorkUnitEditorModal.razor +++ /dev/null @@ -1,411 +0,0 @@ -@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 199e3ff..b829145 100644 --- a/Components/_Imports.razor +++ b/Components/_Imports.razor @@ -10,7 +10,6 @@ @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 dd2f8fd..22fb24f 100644 --- a/Domain/CalendarEventDocument.cs +++ b/Domain/CalendarEventDocument.cs @@ -4,10 +4,6 @@ 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 deleted file mode 100644 index e7f867f..0000000 --- a/Formatting/CalendarEventFormatter.cs +++ /dev/null @@ -1,27 +0,0 @@ -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 fac5d8e..34d9492 100644 --- a/Services/WorkDays/CouchbaseLiteWorkDayService.cs +++ b/Services/WorkDays/CouchbaseLiteWorkDayService.cs @@ -1,7 +1,5 @@ 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; @@ -46,13 +44,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); - 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; + return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); } public async Task SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default) @@ -100,59 +92,32 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService { cancellationToken.ThrowIfCancellationRequested(); + var day = await GetOrCreateDayAsync(date, cancellationToken); var now = DateTimeOffset.UtcNow; - 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); - } + var existingIndex = day.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal)); + var existingCreatedAt = existingIndex >= 0 ? day.CalendarEvents[existingIndex].CreatedAtUtc : now; calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id; - calendarEvent.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.Description = string.IsNullOrWhiteSpace(calendarEvent.Description) + ? "Calendar entry" + : calendarEvent.Description.Trim(); + calendarEvent.CreatedAtUtc = existingCreatedAt; calendarEvent.UpdatedAtUtc = now; Compute(calendarEvent); - if (existingLocation is not null) + if (existingIndex >= 0) { - 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; + day.CalendarEvents[existingIndex] = calendarEvent; } else { - targetDay.CalendarEvents.Add(calendarEvent); + day.CalendarEvents.Add(calendarEvent); } - targetDay.UpdatedAtUtc = now; - SortEntries(targetDay); - SaveDocument(targetDay); + day.UpdatedAtUtc = now; + SortEntries(day); + SaveDocument(day); return calendarEvent; } @@ -180,79 +145,36 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService cancellationToken.ThrowIfCancellationRequested(); var day = await GetAsync(date, cancellationToken); - 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) + if (day is null) { return false; } - existingLocation.Day.CalendarEvents.RemoveAt(existingLocation.Index); - return DeleteOrSaveDay(existingLocation.Day); + var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal)); + if (removed == 0) + { + return false; + } + + return DeleteOrSaveDay(day); } public Task> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - var storedDays = GetAllDays(cancellationToken); - var results = new Dictionary(); - - foreach (var day in storedDays) + var results = new List(); + for (var date = from; date <= to; date = date.AddDays(1)) { - cancellationToken.ThrowIfCancellationRequested(); - - if (day.Date >= from && day.Date <= to) + var id = date.ToString("yyyy-MM-dd"); + var doc = workDaysCollection.GetDocument(id); + if (doc is not null) { - 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); - } + results.Add(Map(doc)); } } - var orderedResults = results.Values.OrderBy(day => day.Date).ToList(); - foreach (var day in orderedResults) - { - SortEntries(day); - } - - return Task.FromResult>(orderedResults); + return Task.FromResult>(results); } public async Task GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default) @@ -332,8 +254,6 @@ 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)) { @@ -345,8 +265,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService } var day = await GetOrCreateDayAsync(date, cancellationToken); - var projectedDay = projectedLookup.GetValueOrDefault(date); - if (day.WorkUnits.Count > 0 || projectedDay?.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)) == true) + if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType))) { continue; } @@ -401,15 +320,10 @@ 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)); @@ -450,8 +364,6 @@ 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); @@ -480,7 +392,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService var calendarEvent = calendarEventsArray.GetDictionary(i); if (calendarEvent is not null) { - calendarEvents.Add(MapCalendarEvent(calendarEvent, date)); + calendarEvents.Add(MapCalendarEvent(calendarEvent)); } } } @@ -488,7 +400,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return new WorkDayDocument { Id = doc.Id, - Date = date, + Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"), IsWeekend = doc.GetBoolean("isWeekend"), IsItalianFestivity = doc.GetBoolean("isItalianFestivity"), WorkUnits = workUnits, @@ -601,8 +513,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService var weekdayDaytimeHours = isWeekend ? 0m : Math.Max(0m, totalHours - nightHours); var suppressVacation = isWeekend || explicitHoliday || isAutomaticHoliday || illness; var hasNonWorkingEvent = explicitHoliday || illness || dayOff || closure; - var isFutureEmptyDay = date > DateOnly.FromDateTime(DateTime.Today) && includedUnits.Count == 0; - var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && !isFutureEmptyDay && totalHours < standardHours + var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && totalHours < standardHours ? standardHours - totalHours : 0m; @@ -682,13 +593,12 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent) { - var description = CalendarEventFormatter.GetDisplayDescription(calendarEvent); if (calendarEvent.StartTime.HasValue) { - return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description} ({calendarEvent.StartTime:HH:mm})"; + return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})"; } - return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description}"; + return $"{calendarEvent.EventType}: {calendarEvent.Description}"; } private static string FormatCompactHours(decimal value) @@ -791,15 +701,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService return workUnit; } - private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent, DateOnly owningDate) + private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent) { 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") ?? string.Empty, + Description = calendarEvent.GetString("description") ?? "Calendar entry", StartTime = ReadTimeOnly(calendarEvent, "startTime"), EndTime = ReadTimeOnly(calendarEvent, "endTime"), DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null, @@ -861,7 +769,6 @@ 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, @@ -903,14 +810,6 @@ 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) @@ -925,148 +824,6 @@ 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/docker-compose.tests.yml b/docker-compose.tests.yml index 16b7a98..9c4c27a 100644 --- a/docker-compose.tests.yml +++ b/docker-compose.tests.yml @@ -1,8 +1,4 @@ services: - worktracker: - build: - context: . - dockerfile: Dockerfile playwright: build: context: . diff --git a/tests/playwright/calendar-pickers.spec.ts b/tests/playwright/calendar-pickers.spec.ts deleted file mode 100644 index 97b61c2..0000000 --- a/tests/playwright/calendar-pickers.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -async function waitForBlazorConnection(page: Page) { - await page.waitForTimeout(750); -} - -test('calendar modal saves and deletes an event with picker ranges', async ({ page }) => { - const eventTitle = `Smoke ${Date.now()}`; - - await page.setViewportSize({ width: 1440, height: 960 }); - await page.goto('/calendar-event/2026-04-22', { waitUntil: 'networkidle' }); - await waitForBlazorConnection(page); - - const startDateInput = page.getByTestId('calendar-event-start-date-input'); - const endDateInput = page.getByTestId('calendar-event-end-date-input'); - await expect(startDateInput).toBeVisible(); - await expect(endDateInput).toBeVisible(); - - await endDateInput.click(); - await page.getByTestId('calendar-event-end-date-day-2026-04-23').click(); - await expect(endDateInput).toHaveValue('23/04/2026'); - - const editorTimeInputs = page.locator('input[type="time"]'); - await expect(editorTimeInputs).toHaveCount(2); - await editorTimeInputs.nth(0).fill('09:00'); - await editorTimeInputs.nth(1).fill('12:00'); - - await page.locator('input[placeholder="Optional"]').fill(eventTitle); - await page.getByRole('button', { name: 'Save' }).click(); - - await waitForBlazorConnection(page); - await expect(page).toHaveURL(/\/calendar\/2026-04$/); - - const savedEventEntries = page.locator('.calendar-item-event', { hasText: eventTitle }); - await expect(savedEventEntries).toHaveCount(2); - await expect(savedEventEntries.first()).toContainText('09:00'); - - await savedEventEntries.first().click(); - page.once('dialog', dialog => dialog.accept()); - await page.getByRole('button', { name: 'Delete' }).click(); - - await expect(page.getByText(eventTitle)).toHaveCount(0); -}); \ No newline at end of file diff --git a/tests/playwright/date-locale.spec.ts b/tests/playwright/date-locale.spec.ts deleted file mode 100644 index e03fd34..0000000 --- a/tests/playwright/date-locale.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; - -test.use({ - locale: 'en-US' -}); - -async function waitForBlazorConnection(page: 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 9996a06..63c0ce2 100644 --- a/wwwroot/app.css +++ b/wwwroot/app.css @@ -484,10 +484,6 @@ 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; @@ -509,18 +505,6 @@ 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); @@ -638,139 +622,6 @@ 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; @@ -855,34 +706,6 @@ 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 */ @@ -958,26 +781,6 @@ h1:focus { background-color: #e2e3e5 !important; } -.timesheet-summary-table .timesheet-summary-day-today { - box-shadow: - inset 0.15rem 0 0 var(--wt-calendar-today-ring), - inset -0.15rem 0 0 var(--wt-calendar-today-ring); -} - -.timesheet-summary-table thead .timesheet-summary-day-today { - box-shadow: - inset 0.15rem 0 0 var(--wt-calendar-today-ring), - inset -0.15rem 0 0 var(--wt-calendar-today-ring), - inset 0 0.15rem 0 var(--wt-calendar-today-ring); -} - -.timesheet-summary-table tbody tr:last-child .timesheet-summary-day-today { - box-shadow: - inset 0.15rem 0 0 var(--wt-calendar-today-ring), - inset -0.15rem 0 0 var(--wt-calendar-today-ring), - inset 0 -0.15rem 0 var(--wt-calendar-today-ring); -} - [data-bs-theme=dark] .timesheet-summary-table .timesheet-summary-day-danger { background-color: #5b2833 !important; } diff --git a/wwwroot/theme.js b/wwwroot/theme.js index 6007550..cf7b2a2 100644 --- a/wwwroot/theme.js +++ b/wwwroot/theme.js @@ -86,42 +86,4 @@ window.workTrackerPreferences = { } }; -window.workTrackerDateInput = (() => { - const listeners = new WeakMap(); - - function unregisterOutsideClick(root) { - const existingListener = listeners.get(root); - if (!existingListener) { - return; - } - - document.removeEventListener("pointerdown", existingListener, true); - listeners.delete(root); - } - - function registerOutsideClick(root, dotNetReference) { - if (!root || !dotNetReference) { - return; - } - - unregisterOutsideClick(root); - - const listener = (event) => { - if (root.contains(event.target)) { - return; - } - - dotNetReference.invokeMethodAsync("ClosePopupFromOutsideClickAsync"); - }; - - listeners.set(root, listener); - document.addEventListener("pointerdown", listener, true); - } - - return { - registerOutsideClick, - unregisterOutsideClick - }; -})(); - window.workTrackerTheme.init(); \ No newline at end of file