Refactor AppSettingsDocument and CoeffSnapshotDocument: Remove LunchBreakHours property

Add CalendarEventDocument and CalendarEventType enum for event management

Update WorkDayDocument to include WorkUnitDocument and CalendarEventDocument lists

Enhance CouchbaseLiteWorkDayService with methods for managing WorkUnit and CalendarEvent

Revise MonthlySummaryModel to track preview worked hours and counted work units

Improve CSS for calendar view, including responsive design and new item styles
This commit is contained in:
Marco 2026-04-20 16:11:27 +02:00
commit cab549ab3a
22 changed files with 1725 additions and 356 deletions

50
.vscode/launch.json vendored
View file

@ -1,16 +1,50 @@
{
"version": "0.2.0",
"compounds": [
"compounds": [],
"configurations": [
{
"name": "WorkTracker: Debug in Docker",
"configurations": [
"WorkTracker: Debug App in Docker",
"WorkTracker: Debug Edge"
"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"
],
"stopAll": true
}
],
"configurations": [
"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": "debugWithEdge",
"pattern": "Now listening on:\\s+https?://\\S+:(\\d+)",
"uriFormat": "http://localhost:8002/?ready=%s"
},
"justMyCode": true,
"requireExactSource": false,
"console": "internalConsole"
},
{
"name": "WorkTracker: Debug App in Docker",
"type": "coreclr",

View file

@ -18,12 +18,6 @@
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="workday">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New Entry
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="grid">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Grid View

View file

@ -0,0 +1,232 @@
@page "/calendar-event"
@page "/calendar-event/{DateStr}"
@page "/calendar-event/{DateStr}/{EventId}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Calendar Event</PageTitle>
<h1>Calendar Event</h1>
@if (!loaded)
{
<p><em>Loading...</em></p>
}
else
{
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingEvent" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Entry Type</label>
<select class="form-select" @bind="eventType">
@foreach (var item in Enum.GetValues<CalendarEventType>())
{
<option value="@item">@item</option>
}
</select>
</div>
<div class="col-12 col-lg-8">
<label class="form-label">Description</label>
<input class="form-control" @bind="description" maxlength="120" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Start Time</label>
<input type="time" class="form-control" value="@startTimeStr" @onchange="OnStartTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label text-muted">Duration</label>
<div class="form-control-plaintext fw-bold">@FormatDuration()</div>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-4">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
@if (isExistingEvent)
{
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<span class="text-success">@statusMessage</span>
}
</div>
}
@code {
[Parameter] public string? DateStr { get; set; }
[Parameter] public string? EventId { get; set; }
private bool loaded;
private bool isExistingEvent;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private string eventId = string.Empty;
private CalendarEventType eventType = CalendarEventType.Generic;
private string description = "Calendar entry";
private string? startTimeStr;
private string? endTimeStr;
private string? statusMessage;
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(DateStr) && DateOnly.TryParseExact(DateStr, "yyyy-MM-dd", out var parsed))
{
selectedDate = parsed;
}
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;
eventType = existing.EventType;
description = existing.Description;
startTimeStr = existing.StartTime?.ToString("HH:mm");
endTimeStr = existing.EndTime?.ToString("HH:mm");
isExistingEvent = true;
}
else
{
SetDefaults();
statusMessage = "The selected calendar event was not found. A new event will be created for this day.";
}
}
private void SetDefaults()
{
eventId = string.Empty;
eventType = CalendarEventType.Generic;
description = "Calendar entry";
startTimeStr = null;
endTimeStr = null;
isExistingEvent = false;
}
private Task OnDateChanged(ChangeEventArgs e)
{
if (DateOnly.TryParse(e.Value?.ToString(), out var parsed))
{
selectedDate = parsed;
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,
EventType = eventType,
Description = description,
StartTime = ParseTime(startTimeStr),
EndTime = ParseTime(endTimeStr)
};
var saved = await WorkDayService.SaveCalendarEventAsync(selectedDate, calendarEvent);
eventId = saved.Id;
isExistingEvent = true;
startTimeStr = saved.StartTime?.ToString("HH:mm");
endTimeStr = saved.EndTime?.ToString("HH:mm");
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
private async Task DeleteAsync()
{
if (!isExistingEvent || string.IsNullOrWhiteSpace(eventId))
{
return;
}
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete calendar event '{description}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
if (!confirmed)
{
return;
}
var deleted = await WorkDayService.DeleteCalendarEventAsync(selectedDate, eventId);
if (deleted)
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
return;
}
statusMessage = "Unable to delete the calendar event.";
}
private void BackToCalendar()
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
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;
}
}

View file

@ -6,6 +6,7 @@
@inject IWorkDayService WorkDayService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Calendar</PageTitle>
@ -15,8 +16,14 @@
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">&laquo; Prev</button>
<h2 class="h5 mb-0">@firstOfMonth.ToString("MMMM yyyy")</h2>
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
<button class="btn btn-primary btn-sm ms-auto" @onclick="GeneratePreviewWorkUnitsAsync">Generate Preview Units</button>
</div>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<div class="alert alert-info py-2">@statusMessage</div>
}
@if (loading)
{
<p><em>Loading...</em></p>
@ -48,12 +55,67 @@ else
}
else
{
<td class="calendar-cell @GetCellClass(cell)" @onclick="() => NavigateToDay(cell.Date)" role="button">
<td class="calendar-cell @GetCellClass(cell) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
<div class="calendar-day-number">@cell.Date.Day</div>
@if (cell.Entry is not null)
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<span class="badge @GetBadgeClass(cell.Entry.DayType)">@cell.Entry.DayType</span>
<div class="calendar-hours">@cell.Entry.WorkedHoursFinal.ToString("N1")h</div>
<button type="button" class="calendar-item calendar-item-work @GetWorkUnitClass(workUnit)" @onclick:stopPropagation="true" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
<span>@workUnit.Label</span>
<span>@FormatWorkUnit(workUnit)</span>
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-item calendar-item-event @GetCalendarEventClass(calendarEvent)" @onclick:stopPropagation="true" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
<span>@calendarEvent.Description</span>
@if (calendarEvent.StartTime.HasValue)
{
<span>@calendarEvent.StartTime.Value.ToString("HH:mm")</span>
}
</button>
}
<div class="calendar-day-total">@FormatHours(GetDayTotalHours(cell.Entry, includePreviewTotals))</div>
@if (IsActiveCell(cell.Date))
{
<div class="calendar-popup" @onclick:stopPropagation="true">
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div>
<div class="fw-semibold">@cell.Date.ToString("dddd d MMMM")</div>
<div class="small text-muted">Select an existing entry or create a new one.</div>
</div>
<button type="button" class="btn-close btn-sm" aria-label="Close" @onclick="ClosePopup"></button>
</div>
<div class="calendar-popup-section">
@if ((cell.Entry?.WorkUnits.Count ?? 0) == 0 && (cell.Entry?.CalendarEvents.Count ?? 0) == 0)
{
<div class="small text-muted">No entries for this day.</div>
}
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenWorkUnit(cell.Date, workUnit.Id)">
Work unit: @workUnit.Label (@FormatWorkUnit(workUnit))
</button>
}
@foreach (var calendarEvent in cell.Entry?.CalendarEvents ?? [])
{
<button type="button" class="calendar-popup-link" @onclick="() => OpenCalendarEvent(cell.Date, calendarEvent.Id)">
Calendar event: @calendarEvent.Description
</button>
}
</div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-sm btn-primary" @onclick="() => CreateWorkUnit(cell.Date)">New Work Unit</button>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="() => CreateCalendarEvent(cell.Date)">New Calendar Event</button>
</div>
</div>
}
</td>
}
@ -67,14 +129,69 @@ else
<div class="mt-3">
<h3 class="h6">Legend</h3>
<div class="d-flex flex-wrap gap-2">
<span class="badge bg-primary">Work</span>
<span class="badge bg-success">Home</span>
<span class="badge calendar-legend-work">Office work unit</span>
<span class="badge calendar-legend-home">Home work unit</span>
<span class="badge calendar-legend-preview">Preview work unit</span>
<span class="badge bg-warning text-dark">Closure</span>
<span class="badge bg-info text-dark">Illness</span>
<span class="badge bg-secondary">DayOff</span>
<span class="badge bg-secondary">Day off</span>
<span class="badge bg-danger">Holiday</span>
</div>
</div>
@if (monthTotals is not null)
{
<div class="calendar-month-summary mt-4">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<h3 class="h6 mb-0">Month Totals</h3>
<div class="form-check mb-0">
<input id="calendar-include-preview" type="checkbox" class="form-check-input" checked="@includePreviewTotals" @onchange="OnIncludePreviewTotalsChanged" />
<label class="form-check-label" for="calendar-include-preview">Include preview work units</label>
</div>
</div>
<div class="row g-3">
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Worked Hours</div>
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalWorkedHours)</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Counted Units</div>
<div class="fs-4 fw-bold">@monthTotals.CountedWorkUnits</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-2">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Hours Off</div>
<div class="fs-4 fw-bold">@FormatHours(monthTotals.TotalHoursOff)</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100 border-success">
<div class="card-body">
<div class="text-muted small">Gross Income</div>
<div class="fs-4 fw-bold text-success">€@monthTotals.TotalGrossIncome.ToString("N2")</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100 border-primary">
<div class="card-body">
<div class="text-muted small">Net Income</div>
<div class="fs-4 fw-bold text-primary">€@monthTotals.TotalNetIncome.ToString("N2")</div>
</div>
</div>
</div>
</div>
</div>
}
}
@code {
@ -84,6 +201,10 @@ else
private bool loading = true;
private List<CalendarCell?[]> weeks = [];
private IReadOnlyCollection<DateOnly> festivities = [];
private DateOnly? activeDate;
private bool includePreviewTotals;
private MonthlySummaryModel? monthTotals;
private string? statusMessage;
protected override async Task OnInitializedAsync()
{
@ -102,11 +223,13 @@ else
private async Task LoadMonth()
{
loading = true;
activeDate = null;
festivities = FestivitySource.GetFestivities(firstOfMonth.Year);
var lastDay = firstOfMonth.AddMonths(1).AddDays(-1);
var entries = await WorkDayService.GetRangeAsync(firstOfMonth, lastDay);
var lookup = entries.ToDictionary(e => e.Date);
monthTotals = await WorkDayService.GetMonthlySummaryAsync(firstOfMonth.Year, firstOfMonth.Month, includePreviewTotals);
// Build calendar grid (ISO weeks: Monday = 0)
weeks = [];
@ -140,46 +263,177 @@ else
loading = false;
}
private async Task OnIncludePreviewTotalsChanged(ChangeEventArgs e)
{
includePreviewTotals = e.Value is bool value && value;
await LoadMonth();
}
private async Task PreviousMonth()
{
firstOfMonth = firstOfMonth.AddMonths(-1);
statusMessage = null;
await LoadMonth();
}
private async Task NextMonth()
{
firstOfMonth = firstOfMonth.AddMonths(1);
statusMessage = null;
await LoadMonth();
}
private void NavigateToDay(DateOnly date) =>
Navigation.NavigateTo($"/workday/{date:yyyy-MM-dd}");
private void TogglePopup(DateOnly date)
{
activeDate = activeDate == date ? null : date;
}
private void ClosePopup()
{
activeDate = null;
}
private bool IsActiveCell(DateOnly date) => activeDate == date;
private void CreateWorkUnit(DateOnly date)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}");
}
private void CreateCalendarEvent(DateOnly date)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}");
}
private void OpenWorkUnit(DateOnly date, string workUnitId)
{
Navigation.NavigateTo($"/work-unit/{date:yyyy-MM-dd}/{workUnitId}");
}
private void OpenCalendarEvent(DateOnly date, string eventId)
{
Navigation.NavigateTo($"/calendar-event/{date:yyyy-MM-dd}/{eventId}");
}
private async Task GeneratePreviewWorkUnitsAsync()
{
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Generate preview work units for {firstOfMonth:MMMM yyyy}? Existing work-unit days will be left unchanged.");
if (!confirmed)
{
return;
}
var createdDays = await WorkDayService.GenerateMonthlyPreviewWorkUnitsAsync(firstOfMonth.Year, firstOfMonth.Month);
statusMessage = createdDays == 0
? "No preview work units were created. Every eligible day already had work units or a blocking calendar event."
: $"Created preview work units for {createdDays} day(s).";
await LoadMonth();
}
private string GetCellClass(CalendarCell cell)
{
if (cell.IsWeekend || cell.IsFestivity) return "calendar-weekend";
if (cell.Entry is null) return "";
return cell.Entry.DayType switch
if (cell.IsWeekend || cell.IsFestivity)
{
DayType.Closure => "calendar-closure",
DayType.Illness => "calendar-illness",
DayType.DayOff => "calendar-dayoff",
DayType.Holiday => "calendar-holiday",
_ => ""
};
return "calendar-weekend";
}
var eventType = GetDominantEventType(cell.Entry);
if (eventType.HasValue)
{
return eventType.Value switch
{
CalendarEventType.Closure => "calendar-closure",
CalendarEventType.Illness => "calendar-illness",
CalendarEventType.DayOff => "calendar-dayoff",
CalendarEventType.Holiday => "calendar-holiday",
_ => string.Empty
};
}
return string.Empty;
}
private static string GetBadgeClass(DayType type) => type switch
private static CalendarEventType? GetDominantEventType(WorkDayDocument? day)
{
DayType.Work => "bg-primary",
DayType.Home => "bg-success",
DayType.Closure => "bg-warning text-dark",
DayType.Illness => "bg-info text-dark",
DayType.DayOff => "bg-secondary",
DayType.Holiday => "bg-danger",
_ => "bg-light text-dark"
if (day is null || day.CalendarEvents.Count == 0)
{
return null;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
{
return CalendarEventType.Holiday;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
{
return CalendarEventType.Closure;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
{
return CalendarEventType.DayOff;
}
if (day.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
{
return CalendarEventType.Illness;
}
return null;
}
private static string GetWorkUnitClass(WorkUnitDocument workUnit)
{
if (workUnit.IsPreview)
{
return workUnit.Location == WorkUnitLocation.Home
? "calendar-item-preview-home"
: "calendar-item-preview-office";
}
return workUnit.Location == WorkUnitLocation.Home ? "calendar-item-home" : "calendar-item-office";
}
private static string GetCalendarEventClass(CalendarEventDocument calendarEvent) => calendarEvent.EventType switch
{
CalendarEventType.Holiday => "calendar-item-holiday",
CalendarEventType.Closure => "calendar-item-closure",
CalendarEventType.DayOff => "calendar-item-dayoff",
CalendarEventType.Illness => "calendar-item-illness",
_ => "calendar-item-generic"
};
private static string FormatWorkUnit(WorkUnitDocument workUnit)
{
var hours = FormatHours(workUnit.ManualWorkedHours);
var timeRange = workUnit.StartTime.HasValue && workUnit.EndTime.HasValue
? $"{workUnit.StartTime:HH:mm}-{workUnit.EndTime:HH:mm}"
: hours;
return workUnit.IsPreview ? $"{timeRange} preview" : timeRange;
}
private static decimal GetDayTotalHours(WorkDayDocument? day, bool includePreview)
{
if (day is null)
{
return 0m;
}
return day.WorkUnits
.Where(unit => includePreview || !unit.IsPreview)
.Sum(unit => unit.ManualWorkedHours);
}
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
}
private sealed class CalendarCell
{
public DateOnly Date { get; set; }

View file

@ -29,12 +29,10 @@ else
<tr>
<th>Date</th>
<th>Day</th>
<th>Type</th>
<th>Start</th>
<th>Projected</th>
<th>Actual</th>
<th class="text-end">Worked</th>
<th class="text-end">Extra</th>
<th>Work Units</th>
<th>Calendar Events</th>
<th class="text-end">Counted</th>
<th class="text-end">Preview</th>
<th class="text-end">Off</th>
<th class="text-end">Gross €</th>
<th class="text-end">Net €</th>
@ -47,24 +45,42 @@ else
<tr class="@GetRowClass(row)">
<td>@row.Date.ToString("dd")</td>
<td>@row.Date.ToString("ddd")</td>
@if (row.Entry is not null)
{
<td>@row.Entry.DayType</td>
<td>@(row.Entry.StartTime?.ToString("HH:mm") ?? "")</td>
<td>@(row.Entry.ProjectedExitTime?.ToString("HH:mm") ?? "")</td>
<td>@(row.Entry.ActualExitTime?.ToString("HH:mm") ?? "")</td>
<td class="text-end">@row.Entry.WorkedHoursFinal.ToString("N2")</td>
<td class="text-end">@FormatDelta(row.Entry.ExtraHoursDelta)</td>
<td class="text-end">@row.Entry.HoursOff.ToString("N2")</td>
<td class="text-end">@row.Entry.GrossIncome.ToString("N2")</td>
<td class="text-end">@row.Entry.NetIncome.ToString("N2")</td>
}
else
{
<td colspan="9" class="text-muted">—</td>
}
<td>
<a href="workday/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Edit</a>
@if (row.Entry?.WorkUnits.Count > 0)
{
@foreach (var unit in row.Entry.WorkUnits)
{
<div class="small mb-1">@unit.Label: @FormatTimeRange(unit.StartTime, unit.EndTime) (@FormatHours(unit.ManualWorkedHours)@(unit.IsPreview ? ", preview" : ""))</div>
}
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (row.Entry?.CalendarEvents.Count > 0)
{
@foreach (var calendarEvent in row.Entry.CalendarEvents)
{
<div class="small mb-1">@calendarEvent.EventType: @calendarEvent.Description</div>
}
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-end">@FormatHours(GetCountedHours(row))</td>
<td class="text-end">@FormatHours(GetPreviewHours(row))</td>
<td class="text-end">@FormatHours(GetHoursOff(row))</td>
<td class="text-end">@GetGrossIncome(row).ToString("N2")</td>
<td class="text-end">@GetNetIncome(row).ToString("N2")</td>
<td>
<div class="d-flex gap-2">
<a href="work-unit/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Unit</a>
<a href="calendar-event/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-secondary">Event</a>
</div>
</td>
</tr>
}
@ -136,23 +152,93 @@ else
{
if (row.IsWeekend || row.IsFestivity) return "table-danger";
if (row.Entry is null) return "";
return row.Entry.DayType switch
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
{
DayType.Closure => "table-warning",
DayType.Illness => "table-info",
DayType.DayOff => "table-secondary",
DayType.Holiday => "table-success",
DayType.Home => "table-light",
_ => ""
};
return "table-success";
}
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Closure))
{
return "table-warning";
}
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Illness))
{
return "table-info";
}
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.DayOff))
{
return "table-secondary";
}
if (row.Entry.WorkUnits.Any(entry => entry.Location == WorkUnitLocation.Home))
{
return "table-light";
}
return string.Empty;
}
private static string FormatDelta(decimal d) => d switch
private static decimal GetCountedHours(CalendarDayRow row)
{
> 0 => $"+{d:N2}",
< 0 => d.ToString("N2"),
_ => "—"
};
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
}
private static decimal GetPreviewHours(CalendarDayRow row)
{
return row.Entry?.WorkUnits.Where(unit => unit.IsPreview).Sum(unit => unit.ManualWorkedHours) ?? 0m;
}
private static decimal GetHoursOff(CalendarDayRow row)
{
if (row.Entry is null || row.Entry.WorkUnits.Count == 0)
{
return 0m;
}
var countedUnits = row.Entry.WorkUnits.Where(unit => !unit.IsPreview).ToList();
if (countedUnits.Count == 0)
{
return 0m;
}
var standardHours = countedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
return Math.Max(0m, standardHours - countedUnits.Sum(unit => unit.ManualWorkedHours));
}
private static decimal GetGrossIncome(CalendarDayRow row)
{
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.GrossIncome) ?? 0m;
}
private static decimal GetNetIncome(CalendarDayRow row)
{
return row.Entry?.WorkUnits.Where(unit => !unit.IsPreview).Sum(unit => unit.NetIncome) ?? 0m;
}
private static string FormatTimeRange(TimeOnly? startTime, TimeOnly? endTime)
{
if (startTime.HasValue && endTime.HasValue)
{
return $"{startTime:HH:mm}-{endTime:HH:mm}";
}
if (startTime.HasValue)
{
return startTime.Value.ToString("HH:mm");
}
return "No time range";
}
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
}
private sealed class CalendarDayRow
{

View file

@ -8,9 +8,9 @@
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Today</h2>
<p class="mb-2">Quick-add or edit today's work entry.</p>
<a href="workday" class="btn btn-primary">Open Today</a>
<h2 class="h5 card-title">Calendar</h2>
<p class="mb-2">Open the calendar and create work units or calendar events from each day cell.</p>
<a href="calendar" class="btn btn-primary">Open Calendar</a>
</div>
</div>
</div>
@ -26,9 +26,9 @@
<div class="col-12 col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Calendar</h2>
<p class="mb-2">Visual calendar with day-type badges.</p>
<a href="calendar" class="btn btn-outline-primary">Open Calendar</a>
<h2 class="h5 card-title">Grid View</h2>
<p class="mb-2">Tabular view of daily work-unit and calendar-event details.</p>
<a href="grid" class="btn btn-outline-primary">Open Grid</a>
</div>
</div>
</div>
@ -36,7 +36,7 @@
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Monthly Summary</h2>
<p class="mb-2">Totals for worked hours, income, and day types.</p>
<p class="mb-2">Totals for counted hours, preview hours, income, and non-working events.</p>
<a href="summary" class="btn btn-outline-primary">Open Summary</a>
</div>
</div>
@ -45,7 +45,7 @@
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title">Settings</h2>
<p class="mb-2">Configure default rates, hours, and tax coefficients.</p>
<p class="mb-2">Configure the standard daily target and the income coefficients.</p>
<a href="settings" class="btn btn-outline-secondary">Open Settings</a>
</div>
</div>

View file

@ -15,6 +15,11 @@
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
</div>
<div class="form-check mb-3">
<input id="include-preview" type="checkbox" class="form-check-input" checked="@includePreview" @onchange="OnIncludePreviewChanged" />
<label class="form-check-label" for="include-preview">Include preview work units in totals</label>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
@ -30,11 +35,35 @@ else if (summary is not null)
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Counted Work Units</div>
<div class="fs-3 fw-bold">@summary.CountedWorkUnits</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Total Worked Hours</div>
<div class="fs-3 fw-bold">@summary.TotalWorkedHours.ToString("N1")h</div>
<div class="fs-3 fw-bold">@FormatHours(summary.TotalWorkedHours)</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Preview Hours</div>
<div class="fs-3 fw-bold">@FormatHours(summary.TotalPreviewWorkedHours)</div>
</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Preview Units</div>
<div class="fs-3 fw-bold">@summary.PreviewWorkUnits</div>
</div>
</div>
</div>
@ -42,7 +71,7 @@ else if (summary is not null)
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Hours Off</div>
<div class="fs-3 fw-bold">@summary.TotalHoursOff.ToString("N1")h</div>
<div class="fs-3 fw-bold">@FormatHours(summary.TotalHoursOff)</div>
</div>
</div>
</div>
@ -89,8 +118,8 @@ else if (summary is not null)
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Sick Days</div>
<div class="fs-3 fw-bold">@summary.SickDays</div>
<div class="text-muted small">Closure Days</div>
<div class="fs-3 fw-bold">@summary.ClosureDays</div>
</div>
</div>
</div>
@ -105,8 +134,8 @@ else if (summary is not null)
<div class="col-6 col-md-4 col-xl-3">
<div class="card text-center h-100">
<div class="card-body">
<div class="text-muted small">Closure Days</div>
<div class="fs-3 fw-bold">@summary.ClosureDays</div>
<div class="text-muted small">Sick Days</div>
<div class="fs-3 fw-bold">@summary.SickDays</div>
</div>
</div>
</div>
@ -118,6 +147,7 @@ else if (summary is not null)
private DateOnly currentMonth;
private bool loading = true;
private bool includePreview;
private MonthlySummaryModel? summary;
protected override async Task OnInitializedAsync()
@ -134,10 +164,16 @@ else if (summary is not null)
await LoadSummary();
}
private async Task OnIncludePreviewChanged(ChangeEventArgs e)
{
includePreview = e.Value is bool value && value;
await LoadSummary();
}
private async Task LoadSummary()
{
loading = true;
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month);
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month, includePreview);
loading = false;
}
@ -152,4 +188,12 @@ else if (summary is not null)
currentMonth = currentMonth.AddMonths(1);
await LoadSummary();
}
private static string FormatHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{hours:00}:{minutes:00}";
}
}

View file

@ -6,7 +6,7 @@
<PageTitle>Settings</PageTitle>
<h1>Settings</h1>
<p class="text-muted">Default values used to prefill each workday. Every day can still override these values.</p>
<p class="text-muted">Default values used to compute manual work-unit totals and income.</p>
@if (settings is null)
{
@ -22,10 +22,6 @@ else
<label class="form-label">Standard work hours/day</label>
<InputNumber class="form-control" @bind-Value="settings.StandardWorkHoursPerDay" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Lunch break hours</label>
<InputNumber class="form-control" @bind-Value="settings.LunchBreakHours" />
</div>
<div class="col-12 col-md-6">
<label class="form-label">Hourly gross rate (€)</label>
<InputNumber class="form-control" @bind-Value="settings.HourlyGrossRate" />

View file

@ -1,16 +1,17 @@
@page "/workday"
@page "/workday/{DateStr}"
@page "/work-unit"
@page "/work-unit/{DateStr}"
@page "/work-unit/{DateStr}/{UnitId}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Work Day</PageTitle>
<PageTitle>Work Unit</PageTitle>
<h1>Work Day Entry</h1>
<h1>Work Unit</h1>
@if (!loaded)
{
@ -21,22 +22,20 @@ else
<div class="row g-3">
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Date</label>
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" />
@if (isWeekend || isFestivity)
{
<div class="mt-1">
@if (isWeekend) { <span class="badge bg-danger me-1">Weekend</span> }
@if (isFestivity) { <span class="badge bg-warning text-dark">Festivity</span> }
</div>
}
<input type="date" class="form-control" value="@selectedDate.ToString("yyyy-MM-dd")" @onchange="OnDateChanged" disabled="@isExistingUnit" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Day Type</label>
<select class="form-select" value="@selectedDayType" @onchange="OnDayTypeChanged">
@foreach (var dt in Enum.GetValues<DayType>())
<label class="form-label">Label</label>
<input class="form-control" @bind="label" maxlength="40" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Location</label>
<select class="form-select" @bind="location">
@foreach (var item in Enum.GetValues<WorkUnitLocation>())
{
<option value="@dt">@dt</option>
<option value="@item">@item</option>
}
</select>
</div>
@ -47,14 +46,20 @@ else
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Actual Exit Time</label>
<input type="time" class="form-control" value="@actualExitTimeStr" @onchange="OnActualExitChanged" />
<div class="form-text">Informational only, not used in calculations.</div>
<label class="form-label">End Time</label>
<input type="time" class="form-control" value="@endTimeStr" @onchange="OnEndTimeChanged" />
</div>
<div class="col-12 col-md-6 col-lg-4">
<label class="form-label">Extra hours delta</label>
<input type="number" class="form-control" step="0.25" value="@extraHoursDelta" @onchange="OnExtraDeltaChanged" />
<label class="form-label">Counted Hours</label>
<input type="text" class="form-control" value="@manualWorkedHoursStr" @onchange="OnManualWorkedHoursChanged" placeholder="00:00" inputmode="numeric" />
</div>
<div class="col-12 col-md-6 col-lg-4 d-flex align-items-end">
<div class="form-check mb-2">
<input id="preview-checkbox" type="checkbox" class="form-check-input" @bind="isPreview" />
<label class="form-check-label" for="preview-checkbox">Preview work unit</label>
</div>
</div>
<div class="col-12">
@ -68,20 +73,12 @@ else
<h2 class="h5">Computed values</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Projected Exit</label>
<div class="form-control-plaintext fw-bold">@(projectedExitTime?.ToString("HH:mm") ?? "—")</div>
<label class="form-label text-muted">Calculated Hours</label>
<div class="form-control-plaintext fw-bold">@FormatHours(calculatedWorkedHours)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Worked (base)</label>
<div class="form-control-plaintext fw-bold">@workedHoursBase.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Worked (final)</label>
<div class="form-control-plaintext fw-bold">@workedHoursFinal.ToString("N2")h</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Hours Off</label>
<div class="form-control-plaintext fw-bold">@hoursOff.ToString("N2")h</div>
<label class="form-label text-muted">Difference</label>
<div class="form-control-plaintext fw-bold">@FormatSignedHours(workedHoursDelta)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Gross Income</label>
@ -93,8 +90,27 @@ else
</div>
</div>
<div class="mt-4">
<h2 class="h5">Day Total</h2>
<div class="row g-3">
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Total Hours For Day</label>
<div class="form-control-plaintext fw-bold">@FormatHours(dayTotalHours)</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Work Units Counted</label>
<div class="form-control-plaintext fw-bold">@dayWorkUnitCount</div>
</div>
</div>
</div>
<div class="d-flex align-items-center gap-2 mt-4">
<button class="btn btn-primary" @onclick="SaveAsync">Save</button>
@if (isExistingUnit)
{
<button class="btn btn-outline-danger" @onclick="DeleteAsync">Delete</button>
}
<button class="btn btn-outline-secondary" @onclick="BackToCalendar">Back to Calendar</button>
@if (!string.IsNullOrWhiteSpace(statusMessage))
{
<span class="text-success">@statusMessage</span>
@ -104,29 +120,31 @@ else
@code {
[Parameter] public string? DateStr { get; set; }
[Parameter] public string? UnitId { get; set; }
private bool loaded;
private DateOnly selectedDate = DateOnly.FromDateTime(DateTime.Today);
private DayType selectedDayType = DayType.None;
private string unitId = string.Empty;
private string label = "Work unit";
private WorkUnitLocation location = WorkUnitLocation.Office;
private string? startTimeStr;
private string? actualExitTimeStr;
private decimal extraHoursDelta;
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;
// Computed preview
private TimeOnly? projectedExitTime;
private decimal workedHoursBase;
private decimal workedHoursFinal;
private decimal hoursOff;
private decimal grossIncome;
private decimal netIncome;
private bool isWeekend;
private bool isFestivity;
private decimal? calculatedWorkedHours;
private decimal workedHoursDelta;
private decimal dayTotalHours;
private int dayWorkUnitCount;
// Loaded from settings
private AppSettingsDocument settings = new();
private IReadOnlyCollection<DateOnly> festivities = [];
protected override async Task OnInitializedAsync()
{
@ -136,33 +154,41 @@ else
}
settings = await AppSettingsService.GetAsync();
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
await LoadUnitAsync();
loaded = true;
}
private async Task LoadExistingEntry()
private async Task LoadUnitAsync()
{
var existing = await WorkDayService.GetAsync(selectedDate);
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)
{
selectedDayType = existing.DayType;
unitId = existing.Id;
label = existing.Label;
location = existing.Location;
startTimeStr = existing.StartTime?.ToString("HH:mm");
actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
extraHoursDelta = existing.ExtraHoursDelta;
endTimeStr = existing.EndTime?.ToString("HH:mm");
manualWorkedHours = existing.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
isPreview = existing.IsPreview;
notes = existing.Notes;
isExistingUnit = true;
}
else
{
selectedDayType = DayType.None;
startTimeStr = null;
actualExitTimeStr = null;
extraHoursDelta = 0;
notes = null;
SetDefaults();
statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
}
RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
@ -170,127 +196,214 @@ else
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
statusMessage = null;
}
}
private void OnDayTypeChanged(ChangeEventArgs e)
{
if (Enum.TryParse<DayType>(e.Value?.ToString(), out var dt))
{
selectedDayType = dt;
RecomputePreview();
statusMessage = null;
}
}
private void OnStartTimeChanged(ChangeEventArgs e)
private Task OnStartTimeChanged(ChangeEventArgs e)
{
startTimeStr = e.Value?.ToString();
RecomputePreview();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private void OnActualExitChanged(ChangeEventArgs e)
private Task OnEndTimeChanged(ChangeEventArgs e)
{
actualExitTimeStr = e.Value?.ToString();
endTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
statusMessage = null;
return Task.CompletedTask;
}
private void OnExtraDeltaChanged(ChangeEventArgs e)
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
{
if (decimal.TryParse(e.Value?.ToString(), out var val))
var rawValue = e.Value?.ToString();
if (TryParseDurationHours(rawValue, out var parsedHours))
{
extraHoursDelta = val;
manualWorkedHours = parsedHours;
manualWorkedHoursStr = FormatDurationHours(parsedHours);
}
else
{
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
}
RecomputePreview();
statusMessage = null;
return Task.CompletedTask;
}
private void RecomputeFlags()
private void SetDefaults()
{
isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
isFestivity = festivities.Contains(selectedDate);
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()
{
TimeOnly? start = null;
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
start = s;
}
if (selectedDayType is DayType.Work or DayType.Home)
{
workedHoursBase = settings.StandardWorkHoursPerDay;
if (start.HasValue)
{
var totalHours = settings.StandardWorkHoursPerDay + settings.LunchBreakHours;
projectedExitTime = start.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
projectedExitTime = null;
}
}
else
{
workedHoursBase = 0;
projectedExitTime = null;
}
workedHoursFinal = workedHoursBase + extraHoursDelta;
hoursOff = selectedDayType is DayType.Work or DayType.Home
? Math.Max(0, settings.StandardWorkHoursPerDay - workedHoursFinal)
: 0;
grossIncome = workedHoursFinal * settings.HourlyGrossRate;
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()
{
TimeOnly? start = null;
TimeOnly? exit = null;
RecomputePreview();
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
var workUnit = new WorkUnitDocument
{
start = s;
}
if (!string.IsNullOrEmpty(actualExitTimeStr) && TimeOnly.TryParse(actualExitTimeStr, out var e2))
{
exit = e2;
}
var workDay = new WorkDayDocument
{
Date = selectedDate,
DayType = selectedDayType,
StartTime = start,
ActualExitTime = exit,
ExtraHoursDelta = extraHoursDelta,
Id = unitId,
Label = label,
Location = location,
StartTime = ParseTime(startTimeStr),
EndTime = ParseTime(endTimeStr),
ManualWorkedHours = Math.Max(0m, manualWorkedHours),
IsPreview = isPreview,
Notes = notes
};
var saved = await WorkDayService.SaveAsync(workDay);
var saved = await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
// Update preview with saved computed values
projectedExitTime = saved.ProjectedExitTime;
workedHoursBase = saved.WorkedHoursBase;
workedHoursFinal = saved.WorkedHoursFinal;
hoursOff = saved.HoursOff;
unitId = saved.Id;
isExistingUnit = true;
label = saved.Label;
location = saved.Location;
startTimeStr = saved.StartTime?.ToString("HH:mm");
endTimeStr = saved.EndTime?.ToString("HH:mm");
manualWorkedHours = saved.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(saved.ManualWorkedHours);
isPreview = saved.IsPreview;
notes = saved.Notes;
calculatedWorkedHours = saved.CalculatedWorkedHours;
workedHoursDelta = saved.WorkedHoursDelta;
grossIncome = saved.GrossIncome;
netIncome = saved.NetIncome;
isWeekend = saved.IsWeekend;
isFestivity = saved.IsItalianFestivity;
statusMessage = $"Saved at {DateTime.Now:t}";
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
private async Task DeleteAsync()
{
if (!isExistingUnit || string.IsNullOrWhiteSpace(unitId))
{
return;
}
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Delete work unit '{label}' on {selectedDate:dddd d MMMM}?\nThis cannot be undone.");
if (!confirmed)
{
return;
}
var deleted = await WorkDayService.DeleteWorkUnitAsync(selectedDate, unitId);
if (deleted)
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
return;
}
statusMessage = "Unable to delete the work unit.";
}
private void BackToCalendar()
{
Navigation.NavigateTo($"/calendar/{selectedDate:yyyy-MM}");
}
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 bool TryParseDurationHours(string? value, out decimal hours)
{
hours = 0m;
if (string.IsNullOrWhiteSpace(value))
{
return true;
}
if (TimeSpan.TryParseExact(value, [@"h\:mm", @"hh\:mm"], null, out var timeSpan))
{
hours = Math.Round((decimal)timeSpan.TotalMinutes / 60m, 2, MidpointRounding.AwayFromZero);
return true;
}
if (decimal.TryParse(value, out var decimalHours))
{
hours = Math.Max(0m, decimalHours);
return true;
}
return false;
}
private static string FormatHours(decimal? value) => value.HasValue ? FormatDurationHours(value.Value) : "—";
private static string FormatSignedHours(decimal value) => value switch
{
> 0 => $"+{FormatDurationHours(value)}",
< 0 => $"-{FormatDurationHours(Math.Abs(value))}",
_ => "00:00"
};
private static string FormatDurationHours(decimal value)
{
var totalMinutes = (int)Math.Round(value * 60m, MidpointRounding.AwayFromZero);
var sign = totalMinutes < 0 ? "-" : string.Empty;
totalMinutes = Math.Abs(totalMinutes);
var hours = totalMinutes / 60;
var minutes = totalMinutes % 60;
return $"{sign}{hours:00}:{minutes:00}";
}
protected override void OnParametersSet()
{
RecomputePreview();
}
}

View file

@ -6,8 +6,6 @@ public sealed class AppSettingsDocument
public decimal StandardWorkHoursPerDay { get; set; } = 8m;
public decimal LunchBreakHours { get; set; } = 1m;
public decimal HourlyGrossRate { get; set; } = 17.5m;
public decimal ProfitabilityCoefficient { get; set; } = 0.67m;

View file

@ -0,0 +1,20 @@
namespace WorkTracker.Domain;
public sealed class CalendarEventDocument
{
public string Id { get; set; } = string.Empty;
public CalendarEventType EventType { get; set; } = CalendarEventType.Generic;
public string Description { get; set; } = string.Empty;
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public decimal? DurationHours { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}

View file

@ -0,0 +1,10 @@
namespace WorkTracker.Domain;
public enum CalendarEventType
{
Generic = 0,
DayOff = 1,
Closure = 2,
Holiday = 3,
Illness = 4
}

View file

@ -4,8 +4,6 @@ public sealed class CoeffSnapshotDocument
{
public decimal StandardWorkHoursPerDay { get; set; } = 8m;
public decimal LunchBreakHours { get; set; } = 1m;
public decimal HourlyGrossRate { get; set; } = 17.5m;
public decimal ProfitabilityCoefficient { get; set; } = 0.67m;

View file

@ -8,6 +8,12 @@ public sealed class MonthlySummaryModel
public decimal TotalWorkedHours { get; set; }
public decimal TotalPreviewWorkedHours { get; set; }
public int CountedWorkUnits { get; set; }
public int PreviewWorkUnits { get; set; }
public int OfficeDays { get; set; }
public int HomeDays { get; set; }

View file

@ -6,33 +6,13 @@ public sealed class WorkDayDocument
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? ProjectedExitTime { get; set; }
public TimeOnly? ActualExitTime { get; set; }
public DayType DayType { get; set; } = DayType.None;
public decimal ExtraHoursDelta { get; set; }
public decimal WorkedHoursBase { get; set; }
public decimal WorkedHoursFinal { get; set; }
public decimal HoursOff { get; set; }
public decimal GrossIncome { get; set; }
public decimal NetIncome { get; set; }
public bool IsWeekend { get; set; }
public bool IsItalianFestivity { get; set; }
public string? Notes { get; set; }
public List<WorkUnitDocument> WorkUnits { get; set; } = [];
public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
public List<CalendarEventDocument> CalendarEvents { get; set; } = [];
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;

View file

@ -0,0 +1,34 @@
namespace WorkTracker.Domain;
public sealed class WorkUnitDocument
{
public string Id { get; set; } = string.Empty;
public string Label { get; set; } = "Work unit";
public WorkUnitLocation Location { get; set; } = WorkUnitLocation.Office;
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public bool IsPreview { get; set; }
public decimal ManualWorkedHours { get; set; }
public decimal CalculatedWorkedHours { get; set; }
public decimal WorkedHoursDelta { get; set; }
public decimal GrossIncome { get; set; }
public decimal NetIncome { get; set; }
public string? Notes { get; set; }
public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}

View file

@ -0,0 +1,7 @@
namespace WorkTracker.Domain;
public enum WorkUnitLocation
{
Office = 1,
Home = 2
}

View file

@ -39,7 +39,7 @@ Debugging in Docker from VS Code:
- Use the `WorkTracker: Debug in Docker` launch configuration.
- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
- When the app reports that it is listening, VS Code automatically opens Microsoft Edge in browser debug mode against http://localhost:8002.
- VS Code waits for the app to report that it is listening before opening Microsoft Edge in browser debug mode against http://localhost:8002.
- The app remains available at http://localhost:8002 while the debugger is attached.
- Stopping the debug session runs `docker compose down` for the debug stack.

View file

@ -48,7 +48,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
{
var document = new MutableDocument(DefaultSettingsId);
document.SetDouble("standardWorkHoursPerDay", Decimal.ToDouble(settings.StandardWorkHoursPerDay));
document.SetDouble("lunchBreakHours", Decimal.ToDouble(settings.LunchBreakHours));
document.SetDouble("hourlyGrossRate", Decimal.ToDouble(settings.HourlyGrossRate));
document.SetDouble("profitabilityCoefficient", Decimal.ToDouble(settings.ProfitabilityCoefficient));
document.SetDouble("inpsRate", Decimal.ToDouble(settings.InpsRate));
@ -67,7 +66,6 @@ public sealed class CouchbaseLiteAppSettingsService : IAppSettingsService
{
Id = document.Id,
StandardWorkHoursPerDay = ReadDecimal(document, "standardWorkHoursPerDay", 8m),
LunchBreakHours = ReadDecimal(document, "lunchBreakHours", 1m),
HourlyGrossRate = ReadDecimal(document, "hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(document, "profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(document, "inpsRate", 0.2607m),

View file

@ -31,45 +31,132 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult(doc is not null ? Map(doc) : null);
}
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
public async Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var day = await GetAsync(date, cancellationToken);
return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
}
public async Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var day = await GetAsync(date, cancellationToken);
return day?.CalendarEvents.FirstOrDefault(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
}
public async Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var settings = await appSettingsService.GetAsync(cancellationToken);
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
var day = await GetOrCreateDayAsync(date, cancellationToken);
var now = DateTimeOffset.UtcNow;
var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal));
var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now;
workDay.Id = workDay.Date.ToString("yyyy-MM-dd");
workDay.IsWeekend = workDay.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
workDay.IsItalianFestivity = festivities.Contains(workDay.Date);
// Snapshot coefficients from current settings
workDay.CoeffSnapshot = new CoeffSnapshotDocument
workUnit.Id = string.IsNullOrWhiteSpace(workUnit.Id) ? Guid.NewGuid().ToString("N") : workUnit.Id;
workUnit.Label = string.IsNullOrWhiteSpace(workUnit.Label) ? "Work unit" : workUnit.Label.Trim();
workUnit.ManualWorkedHours = Math.Max(0m, workUnit.ManualWorkedHours);
workUnit.CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
LunchBreakHours = settings.LunchBreakHours,
HourlyGrossRate = settings.HourlyGrossRate,
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
InpsRate = settings.InpsRate,
SubstituteTaxRate = settings.SubstituteTaxRate
};
workUnit.CreatedAtUtc = existingCreatedAt;
workUnit.UpdatedAtUtc = now;
Compute(workDay);
Compute(workUnit);
// Preserve creation timestamp for existing documents
var existing = workDaysCollection.GetDocument(workDay.Id);
if (existing is not null)
if (existingIndex >= 0)
{
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
day.WorkUnits[existingIndex] = workUnit;
}
else
{
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
day.WorkUnits.Add(workUnit);
}
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
day.UpdatedAtUtc = now;
SortEntries(day);
SaveDocument(day);
return workUnit;
}
SaveDocument(workDay);
return workDay;
public async Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default)
{
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;
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.UpdatedAtUtc = now;
Compute(calendarEvent);
if (existingIndex >= 0)
{
day.CalendarEvents[existingIndex] = calendarEvent;
}
else
{
day.CalendarEvents.Add(calendarEvent);
}
day.UpdatedAtUtc = now;
SortEntries(day);
SaveDocument(day);
return calendarEvent;
}
public async Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var day = await GetAsync(date, cancellationToken);
if (day is null)
{
return false;
}
var removed = day.WorkUnits.RemoveAll(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
if (removed == 0)
{
return false;
}
return DeleteOrSaveDay(day);
}
public async Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var day = await GetAsync(date, cancellationToken);
if (day 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);
}
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
@ -90,88 +177,131 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
}
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default)
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
{
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1).AddDays(-1);
var days = await GetRangeAsync(from, to, cancellationToken);
var includedUnits = days
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
.ToList();
var previewUnits = days
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
.ToList();
return new MonthlySummaryModel
{
Year = year,
Month = month,
TotalWorkedHours = days.Sum(d => d.WorkedHoursFinal),
OfficeDays = days.Count(d => d.DayType == DayType.Work),
HomeDays = days.Count(d => d.DayType == DayType.Home),
HolidayDays = days.Count(d => d.DayType == DayType.Holiday),
SickDays = days.Count(d => d.DayType == DayType.Illness),
DaysOff = days.Count(d => d.DayType == DayType.DayOff),
ClosureDays = days.Count(d => d.DayType == DayType.Closure),
TotalHoursOff = days.Sum(d => d.HoursOff),
TotalGrossIncome = days.Sum(d => d.GrossIncome),
TotalNetIncome = days.Sum(d => d.NetIncome),
TotalWorkingDays = days.Count(d => d.DayType is DayType.Work or DayType.Home)
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
CountedWorkUnits = includedUnits.Count,
PreviewWorkUnits = previewUnits.Count,
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
};
}
private static void Compute(WorkDayDocument day)
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
{
var coeff = day.CoeffSnapshot;
cancellationToken.ThrowIfCancellationRequested();
// Calculate projected exit time
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
var settings = await appSettingsService.GetAsync(cancellationToken);
var festivities = festivitySource.GetFestivities(year);
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1).AddDays(-1);
var createdDays = 0;
for (var date = from; date <= to; date = date.AddDays(1))
{
var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
day.ProjectedExitTime = null;
cancellationToken.ThrowIfCancellationRequested();
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || festivities.Contains(date))
{
continue;
}
var day = await GetOrCreateDayAsync(date, cancellationToken);
if (day.WorkUnits.Count > 0 || day.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)))
{
continue;
}
day.WorkUnits.Add(CreatePreviewWorkUnit("Morning", new TimeOnly(8, 30), new TimeOnly(13, 0), settings));
day.WorkUnits.Add(CreatePreviewWorkUnit("Afternoon", new TimeOnly(14, 0), new TimeOnly(17, 30), settings));
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
SortEntries(day);
SaveDocument(day);
createdDays++;
}
// Calculate worked hours
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
? coeff.StandardWorkHoursPerDay
: 0m;
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
// Hours off (only for work/home days)
day.HoursOff = day.DayType is DayType.Work or DayType.Home
? Math.Max(0m, coeff.StandardWorkHoursPerDay - day.WorkedHoursFinal)
: 0m;
// Income calculations
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
return createdDays;
}
private void SaveDocument(WorkDayDocument day)
{
var doc = new MutableDocument(day.Id);
doc.SetString("date", day.Date.ToString("yyyy-MM-dd"));
doc.SetString("startTime", day.StartTime?.ToString("HH:mm"));
doc.SetString("projectedExitTime", day.ProjectedExitTime?.ToString("HH:mm"));
doc.SetString("actualExitTime", day.ActualExitTime?.ToString("HH:mm"));
doc.SetInt("dayType", (int)day.DayType);
doc.SetDouble("extraHoursDelta", decimal.ToDouble(day.ExtraHoursDelta));
doc.SetDouble("workedHoursBase", decimal.ToDouble(day.WorkedHoursBase));
doc.SetDouble("workedHoursFinal", decimal.ToDouble(day.WorkedHoursFinal));
doc.SetDouble("hoursOff", decimal.ToDouble(day.HoursOff));
doc.SetDouble("grossIncome", decimal.ToDouble(day.GrossIncome));
doc.SetDouble("netIncome", decimal.ToDouble(day.NetIncome));
doc.SetBoolean("isWeekend", day.IsWeekend);
doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity);
doc.SetString("notes", day.Notes);
// Coefficient snapshot
doc.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(day.CoeffSnapshot.StandardWorkHoursPerDay));
doc.SetDouble("coeff_lunchBreakHours", decimal.ToDouble(day.CoeffSnapshot.LunchBreakHours));
doc.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(day.CoeffSnapshot.HourlyGrossRate));
doc.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(day.CoeffSnapshot.ProfitabilityCoefficient));
doc.SetDouble("coeff_inpsRate", decimal.ToDouble(day.CoeffSnapshot.InpsRate));
doc.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(day.CoeffSnapshot.SubstituteTaxRate));
var workUnits = new MutableArrayObject();
foreach (var unit in day.WorkUnits)
{
var entry = new MutableDictionaryObject();
entry.SetString("id", unit.Id);
entry.SetString("label", unit.Label);
entry.SetInt("location", (int)unit.Location);
entry.SetString("startTime", unit.StartTime?.ToString("HH:mm"));
entry.SetString("endTime", unit.EndTime?.ToString("HH:mm"));
entry.SetBoolean("isPreview", unit.IsPreview);
entry.SetDouble("manualWorkedHours", decimal.ToDouble(unit.ManualWorkedHours));
entry.SetDouble("calculatedWorkedHours", decimal.ToDouble(unit.CalculatedWorkedHours));
entry.SetDouble("workedHoursDelta", decimal.ToDouble(unit.WorkedHoursDelta));
entry.SetDouble("grossIncome", decimal.ToDouble(unit.GrossIncome));
entry.SetDouble("netIncome", decimal.ToDouble(unit.NetIncome));
entry.SetString("notes", unit.Notes);
entry.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(unit.CoeffSnapshot.StandardWorkHoursPerDay));
entry.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(unit.CoeffSnapshot.HourlyGrossRate));
entry.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(unit.CoeffSnapshot.ProfitabilityCoefficient));
entry.SetDouble("coeff_inpsRate", decimal.ToDouble(unit.CoeffSnapshot.InpsRate));
entry.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(unit.CoeffSnapshot.SubstituteTaxRate));
entry.SetString("createdAtUtc", unit.CreatedAtUtc.ToString("O"));
entry.SetString("updatedAtUtc", unit.UpdatedAtUtc.ToString("O"));
workUnits.AddDictionary(entry);
}
var calendarEvents = new MutableArrayObject();
foreach (var calendarEvent in day.CalendarEvents)
{
var entry = new MutableDictionaryObject();
entry.SetString("id", calendarEvent.Id);
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.DurationHours.HasValue)
{
entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value));
}
entry.SetString("createdAtUtc", calendarEvent.CreatedAtUtc.ToString("O"));
entry.SetString("updatedAtUtc", calendarEvent.UpdatedAtUtc.ToString("O"));
calendarEvents.AddDictionary(entry);
}
doc.SetArray("workUnits", workUnits);
doc.SetArray("calendarEvents", calendarEvents);
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
@ -179,39 +309,311 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
workDaysCollection.Save(doc);
}
private bool DeleteOrSaveDay(WorkDayDocument day)
{
if (day.WorkUnits.Count == 0 && day.CalendarEvents.Count == 0)
{
var existing = workDaysCollection.GetDocument(day.Id);
if (existing is null)
{
return false;
}
workDaysCollection.Delete(existing);
return true;
}
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
SortEntries(day);
SaveDocument(day);
return true;
}
private static WorkDayDocument Map(Document doc)
{
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
{
return MapLegacy(doc);
}
var workUnits = new List<WorkUnitDocument>();
var workUnitsArray = doc.GetArray("workUnits");
if (workUnitsArray is not null)
{
for (var i = 0; i < workUnitsArray.Count; i++)
{
var unit = workUnitsArray.GetDictionary(i);
if (unit is not null)
{
workUnits.Add(MapWorkUnit(unit));
}
}
}
var calendarEvents = new List<CalendarEventDocument>();
var calendarEventsArray = doc.GetArray("calendarEvents");
if (calendarEventsArray is not null)
{
for (var i = 0; i < calendarEventsArray.Count; i++)
{
var calendarEvent = calendarEventsArray.GetDictionary(i);
if (calendarEvent is not null)
{
calendarEvents.Add(MapCalendarEvent(calendarEvent));
}
}
}
return new WorkDayDocument
{
Id = doc.Id,
Date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd"),
StartTime = ReadTimeOnly(doc, "startTime"),
ProjectedExitTime = ReadTimeOnly(doc, "projectedExitTime"),
ActualExitTime = ReadTimeOnly(doc, "actualExitTime"),
DayType = (DayType)doc.GetInt("dayType"),
ExtraHoursDelta = Convert.ToDecimal(doc.GetDouble("extraHoursDelta")),
WorkedHoursBase = Convert.ToDecimal(doc.GetDouble("workedHoursBase")),
WorkedHoursFinal = Convert.ToDecimal(doc.GetDouble("workedHoursFinal")),
HoursOff = Convert.ToDecimal(doc.GetDouble("hoursOff")),
GrossIncome = Convert.ToDecimal(doc.GetDouble("grossIncome")),
NetIncome = Convert.ToDecimal(doc.GetDouble("netIncome")),
IsWeekend = doc.GetBoolean("isWeekend"),
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
Notes = doc.GetString("notes"),
CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
LunchBreakHours = ReadDecimal(doc, "coeff_lunchBreakHours", 1m),
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
},
WorkUnits = workUnits,
CalendarEvents = calendarEvents,
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
};
}
private async Task<WorkDayDocument> GetOrCreateDayAsync(DateOnly date, CancellationToken cancellationToken)
{
var existing = await GetAsync(date, cancellationToken);
if (existing is not null)
{
existing.IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
existing.IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date);
existing.Id = date.ToString("yyyy-MM-dd");
existing.Date = date;
return existing;
}
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),
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
}
private static void Compute(WorkUnitDocument unit)
{
unit.CalculatedWorkedHours = CalculateDuration(unit.StartTime, unit.EndTime) ?? 0m;
unit.WorkedHoursDelta = unit.ManualWorkedHours - unit.CalculatedWorkedHours;
var coeff = unit.CoeffSnapshot;
unit.GrossIncome = unit.ManualWorkedHours * coeff.HourlyGrossRate;
var taxableBase = unit.GrossIncome * coeff.ProfitabilityCoefficient;
unit.NetIncome = unit.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
}
private static void Compute(CalendarEventDocument calendarEvent)
{
calendarEvent.DurationHours = CalculateDuration(calendarEvent.StartTime, calendarEvent.EndTime);
}
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 int CountDaysWithEvent(IEnumerable<WorkDayDocument> days, CalendarEventType eventType)
{
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
}
private static decimal GetHoursOff(WorkDayDocument day, bool includePreview)
{
var includedUnits = day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList();
if (includedUnits.Count == 0)
{
return 0m;
}
var standardHours = includedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
var countedHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
return Math.Max(0m, standardHours - countedHours);
}
private static bool IsNonWorkingEvent(CalendarEventType eventType)
{
return eventType is CalendarEventType.DayOff or CalendarEventType.Closure or CalendarEventType.Holiday or CalendarEventType.Illness;
}
private static WorkUnitDocument CreatePreviewWorkUnit(string label, TimeOnly startTime, TimeOnly endTime, AppSettingsDocument settings)
{
var workUnit = new WorkUnitDocument
{
Id = Guid.NewGuid().ToString("N"),
Label = label,
Location = WorkUnitLocation.Office,
StartTime = startTime,
EndTime = endTime,
IsPreview = true,
ManualWorkedHours = Math.Round((decimal)(endTime - startTime).TotalHours, 2, MidpointRounding.AwayFromZero),
CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
HourlyGrossRate = settings.HourlyGrossRate,
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
InpsRate = settings.InpsRate,
SubstituteTaxRate = settings.SubstituteTaxRate
},
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow
};
Compute(workUnit);
return workUnit;
}
private static void SortEntries(WorkDayDocument day)
{
day.WorkUnits = day.WorkUnits
.OrderBy(unit => unit.StartTime ?? TimeOnly.MaxValue)
.ThenBy(unit => unit.Label, StringComparer.CurrentCultureIgnoreCase)
.ToList();
day.CalendarEvents = day.CalendarEvents
.OrderBy(calendarEvent => calendarEvent.StartTime ?? TimeOnly.MaxValue)
.ThenBy(calendarEvent => calendarEvent.Description, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private static WorkUnitDocument MapWorkUnit(DictionaryObject unit)
{
var workUnit = new WorkUnitDocument
{
Id = unit.GetString("id") ?? Guid.NewGuid().ToString("N"),
Label = unit.GetString("label") ?? "Work unit",
Location = unit.Contains("location") ? (WorkUnitLocation)unit.GetInt("location") : WorkUnitLocation.Office,
StartTime = ReadTimeOnly(unit, "startTime"),
EndTime = ReadTimeOnly(unit, "endTime"),
IsPreview = unit.GetBoolean("isPreview"),
ManualWorkedHours = ReadDecimal(unit, "manualWorkedHours", 0m),
CalculatedWorkedHours = ReadDecimal(unit, "calculatedWorkedHours", 0m),
WorkedHoursDelta = ReadDecimal(unit, "workedHoursDelta", 0m),
GrossIncome = ReadDecimal(unit, "grossIncome", 0m),
NetIncome = ReadDecimal(unit, "netIncome", 0m),
Notes = unit.GetString("notes"),
CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = ReadDecimal(unit, "coeff_standardWorkHoursPerDay", 8m),
HourlyGrossRate = ReadDecimal(unit, "coeff_hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(unit, "coeff_profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(unit, "coeff_inpsRate", 0.2607m),
SubstituteTaxRate = ReadDecimal(unit, "coeff_substituteTaxRate", 0.15m)
},
CreatedAtUtc = ReadDateTimeOffset(unit, "createdAtUtc"),
UpdatedAtUtc = ReadDateTimeOffset(unit, "updatedAtUtc")
};
Compute(workUnit);
return workUnit;
}
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent)
{
var entry = new CalendarEventDocument
{
Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"),
EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic,
Description = calendarEvent.GetString("description") ?? "Calendar entry",
StartTime = ReadTimeOnly(calendarEvent, "startTime"),
EndTime = ReadTimeOnly(calendarEvent, "endTime"),
DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null,
CreatedAtUtc = ReadDateTimeOffset(calendarEvent, "createdAtUtc"),
UpdatedAtUtc = ReadDateTimeOffset(calendarEvent, "updatedAtUtc")
};
Compute(entry);
return entry;
}
private static WorkDayDocument MapLegacy(Document doc)
{
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
var dayType = doc.Contains("dayType") ? (DayType)doc.GetInt("dayType") : DayType.None;
var day = new WorkDayDocument
{
Id = doc.Id,
Date = date,
IsWeekend = doc.GetBoolean("isWeekend"),
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
};
var coeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
};
if (dayType is DayType.Work or DayType.Home)
{
var workUnit = new WorkUnitDocument
{
Id = "legacy",
Label = "Legacy entry",
Location = dayType == DayType.Home ? WorkUnitLocation.Home : WorkUnitLocation.Office,
StartTime = ReadTimeOnly(doc, "startTime"),
EndTime = ReadTimeOnly(doc, "actualExitTime") ?? ReadTimeOnly(doc, "projectedExitTime"),
IsPreview = false,
ManualWorkedHours = ReadDecimal(doc, "workedHoursFinal", ReadDecimal(doc, "workedHoursBase", 0m)),
GrossIncome = ReadDecimal(doc, "grossIncome", 0m),
NetIncome = ReadDecimal(doc, "netIncome", 0m),
Notes = doc.GetString("notes"),
CoeffSnapshot = coeffSnapshot,
CreatedAtUtc = day.CreatedAtUtc,
UpdatedAtUtc = day.UpdatedAtUtc
};
Compute(workUnit);
day.WorkUnits.Add(workUnit);
}
else if (dayType != DayType.None)
{
var calendarEvent = new CalendarEventDocument
{
Id = "legacy",
EventType = MapLegacyEventType(dayType),
Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!,
CreatedAtUtc = day.CreatedAtUtc,
UpdatedAtUtc = day.UpdatedAtUtc
};
Compute(calendarEvent);
day.CalendarEvents.Add(calendarEvent);
}
return day;
}
private static CalendarEventType MapLegacyEventType(DayType dayType)
{
return dayType switch
{
DayType.DayOff => CalendarEventType.DayOff,
DayType.Closure => CalendarEventType.Closure,
DayType.Holiday => CalendarEventType.Holiday,
DayType.Illness => CalendarEventType.Illness,
_ => CalendarEventType.Generic
};
}
private static TimeOnly? ReadTimeOnly(Document doc, string key)
{
var value = doc.GetString(key);
@ -220,6 +622,14 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: null;
}
private static TimeOnly? ReadTimeOnly(DictionaryObject doc, string key)
{
var value = doc.GetString(key);
return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time)
? time
: null;
}
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
{
return doc.Contains(key)
@ -227,6 +637,13 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
: defaultValue;
}
private static decimal ReadDecimal(DictionaryObject doc, string key, decimal defaultValue)
{
return doc.Contains(key)
? Convert.ToDecimal(doc.GetDouble(key))
: defaultValue;
}
private static DateTimeOffset ReadDateTimeOffset(Document doc, string key)
{
var value = doc.GetString(key);
@ -234,4 +651,12 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
? dt
: DateTimeOffset.UtcNow;
}
private static DateTimeOffset ReadDateTimeOffset(DictionaryObject doc, string key)
{
var value = doc.GetString(key);
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
? dt
: DateTimeOffset.UtcNow;
}
}

View file

@ -6,9 +6,21 @@ public interface IWorkDayService
{
Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default);
Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default);
Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default);
Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default);
Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
}

View file

@ -61,20 +61,35 @@ h1:focus {
/* Calendar view */
.calendar-table td.calendar-cell {
height: 5rem;
height: 10rem;
vertical-align: top;
padding: 0.25rem 0.4rem;
cursor: pointer;
min-width: 5rem;
position: relative;
}
.calendar-table td.calendar-cell:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.calendar-cell-active {
box-shadow: inset 0 0 0 0.15rem #1b6ec2;
}
.calendar-day-number {
font-weight: bold;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.calendar-day-total {
margin-top: auto;
padding-top: 0.25rem;
font-size: 0.72rem;
font-weight: 700;
text-align: right;
color: #334155;
}
.calendar-hours {
@ -82,6 +97,107 @@ h1:focus {
color: #666;
}
.calendar-item {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
border: 0;
border-radius: 0.45rem;
font-size: 0.72rem;
margin-bottom: 0.2rem;
padding: 0.2rem 0.35rem;
text-align: left;
}
.calendar-item-work {
color: #14213d;
}
.calendar-item-office {
background-color: #cfe2ff;
}
.calendar-item-home {
background-color: #d1e7dd;
}
.calendar-item-preview-office {
background-color: rgba(207, 226, 255, 0.55);
border: 1px dashed #6c8ebf;
}
.calendar-item-preview-home {
background-color: rgba(209, 231, 221, 0.55);
border: 1px dashed #5b8a72;
}
.calendar-item-event {
color: #fff;
}
.calendar-item-generic {
background-color: #6c757d;
}
.calendar-item-dayoff {
background-color: #6c757d;
}
.calendar-item-closure {
background-color: #b08900;
}
.calendar-item-holiday {
background-color: #b02a37;
}
.calendar-item-illness {
background-color: #0c8599;
}
.calendar-popup {
position: absolute;
top: 2rem;
left: 0.35rem;
z-index: 20;
width: min(18rem, calc(100vw - 3rem));
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.75rem;
box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.18);
padding: 0.75rem;
}
.calendar-popup-section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.calendar-popup-link {
border: 0;
border-radius: 0.5rem;
background: #f1f3f5;
padding: 0.45rem 0.6rem;
text-align: left;
}
.calendar-legend-work {
background-color: #cfe2ff;
color: #14213d;
}
.calendar-legend-home {
background-color: #d1e7dd;
color: #1d3b2a;
}
.calendar-legend-preview {
background-color: #fff3cd;
color: #6b4f00;
}
.calendar-weekend {
background-color: #ffe0e0 !important;
}
@ -100,4 +216,16 @@ h1:focus {
.calendar-holiday {
background-color: #d4edda !important;
}
@media (max-width: 767.98px) {
.calendar-table td.calendar-cell {
height: 8rem;
min-width: 7rem;
}
.calendar-popup {
left: 0;
width: calc(100vw - 2rem);
}
}