feat: add WorkUnitEditorModal component for managing work units

- Implemented WorkUnitEditorModal.razor for creating and editing work units.
- Added necessary services and parameters for data handling.
- Included computed values for calculated hours, gross income, and net income.
- Enhanced UI with modal structure and styling.

fix: update _Imports.razor to include Shared components

- Added reference to WorkUnitEditorModal in _Imports.razor for accessibility.

feat: extend CalendarEventDocument with StartDate and EndDate properties

- Updated CalendarEventDocument.cs to include StartDate and EndDate for better event management.

feat: create CalendarEventFormatter for event description formatting

- Introduced CalendarEventFormatter.cs to handle display logic for calendar events.

fix: enhance CouchbaseLiteWorkDayService for calendar event management

- Updated methods to handle new StartDate and EndDate properties in calendar events.
- Improved event saving and deletion logic.

test: add Playwright tests for date locale functionality

- Created date-locale.spec.ts to verify date picker behavior and formatting.

style: enhance app.css with modal and date input styles

- Added styles for calendar modal, date input, and related components for improved UI.
This commit is contained in:
Marco 2026-04-22 11:07:30 +02:00
commit bc28d869eb
14 changed files with 1638 additions and 150 deletions

View file

@ -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<WorkUnitDocument> 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<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var results = new List<WorkDayDocument>();
for (var date = from; date <= to; date = date.AddDays(1))
var storedDays = GetAllDays(cancellationToken);
var results = new Dictionary<DateOnly, WorkDayDocument>();
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<IReadOnlyList<WorkDayDocument>>(results);
var orderedResults = results.Values.OrderBy(day => day.Date).ToList();
foreach (var day in orderedResults)
{
SortEntries(day);
}
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(orderedResults);
}
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
@ -254,6 +332,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1).AddDays(-1);
var createdDays = 0;
var projectedDays = await GetRangeAsync(from, to, cancellationToken);
var projectedLookup = projectedDays.ToDictionary(day => day.Date);
for (var date = from; date <= to; date = date.AddDays(1))
{
@ -265,7 +345,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
}
var day = await GetOrCreateDayAsync(date, cancellationToken);
if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)))
var projectedDay = projectedLookup.GetValueOrDefault(date);
if (day.WorkUnits.Count > 0 || projectedDay?.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)) == true)
{
continue;
}
@ -320,10 +401,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
{
var entry = new MutableDictionaryObject();
entry.SetString("id", calendarEvent.Id);
entry.SetString("startDate", (calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate).ToString("yyyy-MM-dd"));
entry.SetInt("eventType", (int)calendarEvent.EventType);
entry.SetString("description", calendarEvent.Description);
entry.SetString("startTime", calendarEvent.StartTime?.ToString("HH:mm"));
entry.SetString("endTime", calendarEvent.EndTime?.ToString("HH:mm"));
if (calendarEvent.EndDate.HasValue)
{
entry.SetString("endDate", calendarEvent.EndDate.Value.ToString("yyyy-MM-dd"));
}
if (calendarEvent.DurationHours.HasValue)
{
entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value));
@ -364,6 +450,8 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
private static WorkDayDocument Map(Document doc)
{
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
{
return MapLegacy(doc);
@ -392,7 +480,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
var calendarEvent = calendarEventsArray.GetDictionary(i);
if (calendarEvent is not null)
{
calendarEvents.Add(MapCalendarEvent(calendarEvent));
calendarEvents.Add(MapCalendarEvent(calendarEvent, date));
}
}
}
@ -400,7 +488,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return new WorkDayDocument
{
Id = doc.Id,
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
Date = date,
IsWeekend = doc.GetBoolean("isWeekend"),
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
WorkUnits = workUnits,
@ -593,12 +681,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent)
{
var description = CalendarEventFormatter.GetDisplayDescription(calendarEvent);
if (calendarEvent.StartTime.HasValue)
{
return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})";
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description} ({calendarEvent.StartTime:HH:mm})";
}
return $"{calendarEvent.EventType}: {calendarEvent.Description}";
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description}";
}
private static string FormatCompactHours(decimal value)
@ -701,13 +790,15 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return workUnit;
}
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent)
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent, DateOnly owningDate)
{
var entry = new CalendarEventDocument
{
Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"),
StartDate = ReadDateOnly(calendarEvent, "startDate") ?? owningDate,
EndDate = ReadDateOnly(calendarEvent, "endDate"),
EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic,
Description = calendarEvent.GetString("description") ?? "Calendar entry",
Description = calendarEvent.GetString("description") ?? string.Empty,
StartTime = ReadTimeOnly(calendarEvent, "startTime"),
EndTime = ReadTimeOnly(calendarEvent, "endTime"),
DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null,
@ -769,6 +860,7 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
var calendarEvent = new CalendarEventDocument
{
Id = "legacy",
StartDate = date,
EventType = MapLegacyEventType(dayType),
Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!,
CreatedAtUtc = day.CreatedAtUtc,
@ -810,6 +902,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: null;
}
private static DateOnly? ReadDateOnly(DictionaryObject doc, string key)
{
var value = doc.GetString(key);
return !string.IsNullOrEmpty(value) && DateOnly.TryParseExact(value, "yyyy-MM-dd", out var date)
? date
: null;
}
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
{
return doc.Contains(key)
@ -824,6 +924,148 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: defaultValue;
}
private IReadOnlyList<WorkDayDocument> GetAllDays(CancellationToken cancellationToken)
{
var query = QueryBuilder
.Select(SelectResult.Expression(Meta.ID))
.From(DataSource.Collection(workDaysCollection))
.OrderBy(Ordering.Expression(Meta.ID));
var results = new List<WorkDayDocument>();
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);