diff --git a/.vscode/launch.json b/.vscode/launch.json
index 4e1e71d..a82168c 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -3,7 +3,88 @@
"compounds": [],
"configurations": [
{
- "name": "WorkTracker: Debug in Docker",
+ "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",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "WorkTracker: Docker Debug Prepare",
@@ -38,58 +119,19 @@
},
"serverReadyAction": {
"action": "debugWithEdge",
- "pattern": "Now listening on:\\s+https?://\\S+:(\\d+)",
- "uriFormat": "http://localhost:8002/?ready=%s"
+ "pattern": "\\bNow listening on:\\s+https?://\\S+",
+ "uriFormat": "http://localhost:8002",
+ "webRoot": "${workspaceFolder}"
},
"justMyCode": true,
"requireExactSource": false,
"console": "internalConsole"
},
{
- "name": "WorkTracker: Debug App in Docker",
- "type": "coreclr",
+ "name": "WorkTracker: Launch Integrated Browser for Running Docker App",
+ "type": "editor-browser",
"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 Edge",
- "type": "msedge",
- "request": "launch",
- "url": "http://localhost:8002",
- "webRoot": "${workspaceFolder}",
- "internalConsoleOptions": "neverOpen"
+ "url": "http://localhost:8002"
}
]
}
\ No newline at end of file
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
- {
- TogglePopup(cell.Date)" role="button">
- @cell.Date.Day
-
- @foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
+ | TogglePopup(cell.Date)" role="button">
+
+ @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))
- {
- |
- }
+
+
+
+
+
+
+
+
+ }
+
}
}
@@ -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/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor
index 06b89ec..6debd76 100644
--- a/Components/Pages/MonthlySummary.razor
+++ b/Components/Pages/MonthlySummary.razor
@@ -294,12 +294,24 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
{
- if (day.IsWeekend || day.IsHoliday)
+ var classes = new List();
+
+ if (day.Date == DateOnly.FromDateTime(DateTime.Today))
{
- return "timesheet-summary-day-danger";
+ classes.Add("timesheet-summary-day-today");
}
- return day.IsClosure ? "timesheet-summary-day-closure" : string.Empty;
+ if (day.IsWeekend || day.IsHoliday)
+ {
+ classes.Add("timesheet-summary-day-danger");
+ }
+
+ if (day.IsClosure)
+ {
+ classes.Add("timesheet-summary-day-closure");
+ }
+
+ return string.Join(" ", classes);
}
private static string GetDayPopupClass(int index, int totalDays)
diff --git a/Components/Pages/WorkDayEditor.razor b/Components/Pages/WorkDayEditor.razor
index 754e7c2..a19fce2 100644
--- a/Components/Pages/WorkDayEditor.razor
+++ b/Components/Pages/WorkDayEditor.razor
@@ -22,7 +22,7 @@ else
-
+
@@ -42,12 +42,12 @@ else
-
+
-
+
@@ -191,12 +191,14 @@ 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;
+ selectedDay = await WorkDayService.GetAsync(selectedDate);
+ RecomputePreview();
}
}
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
+
+
+
+
+
+
+
+ @if (!loaded)
+ {
+
Loading...
+ }
+ else
+ {
+
+
+
+
+
+
+
+
+
+
Optional. Leave empty for a single-day event.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@FormatDuration()
+
+
+
+ @if (!string.IsNullOrWhiteSpace(statusMessage))
+ {
+
@statusMessage
+ }
+ }
+
+
+
+
+ @if (isExistingEvent)
+ {
+
+ }
+
+
+
+
+
+
+@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..f2c6a72
--- /dev/null
+++ b/Components/Shared/LocalizedDateInput.razor
@@ -0,0 +1,296 @@
+@using System.Globalization
+@implements IAsyncDisposable
+@inject IJSRuntime JS
+
+
+
+@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
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
+
+
+
+
+
+
+
+ @if (!loaded)
+ {
+
Loading...
+ }
+ else
+ {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Computed values
+
+
+
+
@FormatHours(calculatedWorkedHours)
+
+
+
+
@FormatSignedHours(workedHoursDelta)
+
+
+
+
€@grossIncome.ToString("N2")
+
+
+
+
€@netIncome.ToString("N2")
+
+
+
+
+
Day Total
+
+
+
+
@FormatHours(dayTotalHours)
+
+
+
+
@dayWorkUnitCount
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(statusMessage))
+ {
+
@statusMessage
+ }
+ }
+
+
+
+
+ @if (isExistingUnit)
+ {
+
+ }
+
+
+
+
+
+
+@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..fac5d8e 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,
@@ -513,7 +601,8 @@ 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 permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && totalHours < standardHours
+ var isFutureEmptyDay = date > DateOnly.FromDateTime(DateTime.Today) && includedUnits.Count == 0;
+ var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && !isFutureEmptyDay && totalHours < standardHours
? standardHours - totalHours
: 0m;
@@ -593,12 +682,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 +791,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 +861,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 +903,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 +925,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/docker-compose.tests.yml b/docker-compose.tests.yml
index 9c4c27a..16b7a98 100644
--- a/docker-compose.tests.yml
+++ b/docker-compose.tests.yml
@@ -1,4 +1,8 @@
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
new file mode 100644
index 0000000..97b61c2
--- /dev/null
+++ b/tests/playwright/calendar-pickers.spec.ts
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 0000000..e03fd34
--- /dev/null
+++ b/tests/playwright/date-locale.spec.ts
@@ -0,0 +1,30 @@
+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 63c0ce2..9996a06 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 */
@@ -781,6 +958,26 @@ 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 cf7b2a2..6007550 100644
--- a/wwwroot/theme.js
+++ b/wwwroot/theme.js
@@ -86,4 +86,42 @@ 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