Compare commits

...

3 commits

Author SHA1 Message Date
Marco
c8c5f0071f feat: Enhance Docker deployment configuration with parameterized environment variables and detailed README
All checks were successful
Publish Container / publish (push) Successful in 3m40s
2026-04-20 17:33:07 +02:00
Marco
a7f8dfba01 feat: Implement sidebar toggle functionality and enhance Monthly Timesheet summary view 2026-04-20 17:23:54 +02:00
Marco
cab549ab3a 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
2026-04-20 16:11:27 +02:00
30 changed files with 2469 additions and 416 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

@ -14,7 +14,7 @@
</head>
<body>
<Routes />
<Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script>
</body>

View file

@ -1,12 +1,24 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
<div class="page @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
<div class="sidebar @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
<NavMenu IsCollapsed="sidebarCollapsed" />
</div>
<main>
<div class="top-row px-4">
<button
type="button"
class="sidebar-toggle"
@onclick="ToggleSidebar"
aria-label="Toggle sidebar"
aria-controls="sidebar-navigation"
aria-expanded="@(sidebarCollapsed ? "false" : "true")"
title="Toggle sidebar">
<span class="sidebar-toggle-bar"></span>
<span class="sidebar-toggle-bar"></span>
<span class="sidebar-toggle-bar"></span>
</button>
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
@ -21,3 +33,12 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private bool sidebarCollapsed = true;
private void ToggleSidebar()
{
sidebarCollapsed = !sidebarCollapsed;
}
}

View file

@ -6,6 +6,7 @@
main {
flex: 1;
min-width: 0;
}
.sidebar {
@ -36,6 +37,27 @@ main {
text-overflow: ellipsis;
}
.sidebar-toggle {
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.2rem;
width: 2.5rem;
height: 2.5rem;
margin-right: auto;
border: 1px solid #d6d5d5;
border-radius: 0.65rem;
background: #fff;
}
.sidebar-toggle-bar {
width: 1rem;
height: 2px;
background: #334155;
border-radius: 999px;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
@ -56,6 +78,9 @@ main {
height: 100vh;
position: sticky;
top: 0;
transition: width 0.2s ease;
overflow: hidden;
flex: 0 0 auto;
}
.top-row {
@ -74,6 +99,14 @@ main {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
.sidebar-toggle {
display: inline-flex;
}
.sidebar.sidebar-collapsed {
width: 5rem;
}
}
#blazor-error-ui {

View file

@ -2,65 +2,72 @@
@using WorkTracker.Configuration
@inject IOptions<AppAuthOptions> AppAuthOptions
@code {
[Parameter] public bool IsCollapsed { get; set; }
}
<div class="nav-menu-shell @(IsCollapsed ? "nav-menu-shell-collapsed" : string.Empty)" data-testid="sidebar-shell" data-collapsed="@(IsCollapsed ? "true" : "false")">
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">WorkTracker</a>
<a class="navbar-brand" href="" aria-label="WorkTracker home">
<span class="sidebar-brand-full">WorkTracker</span>
<span class="sidebar-brand-compact" aria-hidden="true">WT</span>
</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-scrollable">
<nav id="sidebar-navigation" class="nav flex-column" aria-label="Sidebar navigation">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
<NavLink class="nav-link" href="" Match="NavLinkMatch.All" aria-label="Dashboard" title="Dashboard">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Dashboard</span>
</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 class="nav-link" href="grid" aria-label="Grid View" title="Grid View">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Grid View</span>
</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
<NavLink class="nav-link" href="calendar" aria-label="Calendar" title="Calendar">
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Calendar</span>
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="calendar">
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span> Calendar
<NavLink class="nav-link" href="summary" aria-label="Summary" title="Summary">
<span class="bi bi-bar-chart-fill-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Summary</span>
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="summary">
<span class="bi bi-bar-chart-fill-nav-menu" aria-hidden="true"></span> Summary
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span> Settings
<NavLink class="nav-link" href="settings" aria-label="Settings" title="Settings">
<span class="bi bi-gear-fill-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Settings</span>
</NavLink>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="auth">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
<NavLink class="nav-link" href="auth" aria-label="@context.User.Identity?.Name" title="@context.User.Identity?.Name">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span>
<span class="nav-label">@context.User.Identity?.Name</span>
</NavLink>
</div>
@if (AppAuthOptions.Value.Enabled)
{
<div class="nav-item px-3">
<form action="/api/logout" method="post">
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
<button type="submit" class="nav-link" aria-label="Logout" title="Logout">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Logout</span>
</button>
</form>
</div>
@ -68,13 +75,15 @@
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
<NavLink class="nav-link" href="login" aria-label="Login" title="Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Login</span>
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
</div>

View file

@ -1,20 +1,3 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
@ -24,6 +7,14 @@
font-size: 1.1rem;
}
.sidebar-brand-compact {
display: none;
}
.nav-menu-shell {
height: 100%;
}
.bi {
display: inline-block;
position: relative;
@ -83,6 +74,12 @@
padding-bottom: 0.5rem;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
@ -114,24 +111,59 @@
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
@media (min-width: 641px) {
.navbar-toggler {
@media (max-width: 640.98px) {
.nav-menu-shell-collapsed .sidebar-brand-full,
.nav-menu-shell:not(.nav-menu-shell-collapsed) .sidebar-brand-compact {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
height: auto;
}
}
@media (min-width: 641px) {
.nav-menu-shell-collapsed .top-row {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.nav-menu-shell-collapsed .container-fluid {
justify-content: center;
}
.nav-menu-shell-collapsed .sidebar-brand-full,
.nav-menu-shell:not(.nav-menu-shell-collapsed) .sidebar-brand-compact,
.nav-menu-shell-collapsed .nav-label {
display: none;
}
.nav-menu-shell-collapsed .nav-item {
padding-bottom: 0.3rem;
}
.nav-menu-shell-collapsed .nav-item.px-3 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.nav-menu-shell-collapsed .nav-item ::deep .nav-link {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.nav-menu-shell-collapsed .nav-item ::deep .nav-link,
.nav-menu-shell-collapsed .nav-item ::deep button.nav-link {
min-width: 0;
}
.nav-menu-shell-collapsed .bi {
margin-right: 0;
}
}

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 @GetPopupClass(cell)" @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 = [];
@ -118,6 +241,7 @@ else
currentWeek[dayOfWeek] = new CalendarCell
{
Date = d,
ColumnIndex = dayOfWeek,
IsWeekend = d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsFestivity = festivities.Contains(d),
Entry = lookup.GetValueOrDefault(d)
@ -140,49 +264,191 @@ 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 static string GetPopupClass(CalendarCell cell)
{
if (cell.ColumnIndex == 0)
{
return "calendar-popup-left";
}
return cell.ColumnIndex >= 5 ? "calendar-popup-right" : string.Empty;
}
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; }
public int ColumnIndex { get; set; }
public bool IsWeekend { get; set; }
public bool IsFestivity { get; set; }
public WorkDayDocument? Entry { 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

@ -3,7 +3,8 @@
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@using System.Globalization
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
<PageTitle>Monthly Summary</PageTitle>
@ -15,11 +16,21 @@
<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>
<div class="btn-group mb-3" role="group" aria-label="Summary view selector">
<button type="button" class="btn @(viewMode == SummaryViewMode.Cards ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Cards)">Cards</button>
<button type="button" class="btn @(viewMode == SummaryViewMode.Timesheet ? "btn-primary" : "btn-outline-primary")" @onclick="() => SetViewMode(SummaryViewMode.Timesheet)">Timesheet</button>
</div>
@if (loading)
{
<p><em>Loading...</em></p>
}
else if (summary is not null)
else if (viewMode == SummaryViewMode.Cards && summary is not null)
{
<div class="row g-3">
<div class="col-6 col-md-4 col-xl-3">
@ -30,11 +41,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 +77,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 +124,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,20 +140,82 @@ 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>
</div>
}
else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
{
<div class="timesheet-summary-card card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-bordered table-sm align-middle mb-0 timesheet-summary-table">
<thead>
<tr>
<th class="timesheet-summary-sticky-column">Categoria</th>
@for (var i = 0; i < timesheet.Days.Count; i++)
{
var day = timesheet.Days[i];
<th class="text-center timesheet-summary-day-header @GetDayColumnClass(day) @GetDayPopupClass(i, timesheet.Days.Count)">
<div>@day.Date.Day</div>
<div class="small text-muted">@GetDayHeader(day.Date)</div>
<div class="timesheet-summary-day-popup">
<div class="fw-semibold mb-2">@day.Date.ToString("dddd d MMMM", ItalianCulture)</div>
@if (day.WorkUnitSummaries.Count == 0 && day.EventSummaries.Count == 0)
{
<div class="small text-muted">Nessun elemento registrato.</div>
}
else
{
@foreach (var workUnit in day.WorkUnitSummaries)
{
<div class="timesheet-summary-day-popup-item">@workUnit</div>
}
@foreach (var calendarEvent in day.EventSummaries)
{
<div class="timesheet-summary-day-popup-item timesheet-summary-day-popup-item-event">@calendarEvent</div>
}
}
</div>
</th>
}
<th class="text-center timesheet-summary-total-column">Totale</th>
</tr>
</thead>
<tbody>
@foreach (var row in timesheet.Rows)
{
<tr>
<th scope="row" class="timesheet-summary-sticky-column">@row.Label</th>
@for (var i = 0; i < row.DailyValues.Count; i++)
{
<td class="text-center @GetDayColumnClass(timesheet.Days[i])">@FormatTimesheetValue(row.DailyValues[i], row.ValueFormat)</td>
}
<td class="text-center fw-semibold timesheet-summary-total-column">@FormatTimesheetValue(row.Total, row.ValueFormat)</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@code {
[Parameter] public string? YearMonth { get; set; }
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
private DateOnly currentMonth;
private bool loading = true;
private MonthlySummaryModel? summary;
private bool includePreview;
private global::WorkTracker.Domain.MonthlySummaryModel? summary;
private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
private SummaryViewMode viewMode = SummaryViewMode.Timesheet;
protected override async Task OnInitializedAsync()
{
@ -134,10 +231,17 @@ 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);
timesheet = await WorkDayService.GetMonthlyTimesheetAsync(currentMonth.Year, currentMonth.Month, includePreview);
loading = false;
}
@ -152,4 +256,65 @@ else if (summary is not null)
currentMonth = currentMonth.AddMonths(1);
await LoadSummary();
}
private void SetViewMode(SummaryViewMode mode)
{
viewMode = mode;
}
private static string GetDayHeader(DateOnly date)
{
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
}
private static string GetDayColumnClass(global::WorkTracker.Domain.MonthlyTimesheetDayModel day)
{
if (day.IsWeekend || day.IsHoliday)
{
return "timesheet-summary-day-danger";
}
return day.IsClosure ? "timesheet-summary-day-closure" : string.Empty;
}
private static string GetDayPopupClass(int index, int totalDays)
{
if (index == 0)
{
return "timesheet-summary-day-popup-left";
}
return index >= totalDays - 2 ? "timesheet-summary-day-popup-right" : string.Empty;
}
private static string FormatTimesheetValue(decimal? value, global::WorkTracker.Domain.MonthlyTimesheetValueFormat valueFormat)
{
if (!value.HasValue || value.Value <= 0m)
{
return string.Empty;
}
return valueFormat == global::WorkTracker.Domain.MonthlyTimesheetValueFormat.Days
? value.Value.ToString("0.##", ItalianCulture)
: FormatDecimalHours(value.Value);
}
private static string FormatDecimalHours(decimal value)
{
return value.ToString("0.##", ItalianCulture);
}
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 enum SummaryViewMode
{
Cards,
Timesheet
}
}

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

@ -0,0 +1,26 @@
namespace WorkTracker.Domain;
public sealed class MonthlyTimesheetDaySummary
{
public DateOnly Date { get; set; }
public decimal OfficeHours { get; set; }
public decimal HomeHours { get; set; }
public decimal OvertimeHours { get; set; }
public decimal WeekendHours { get; set; }
public decimal NightHours { get; set; }
public decimal VacationDays { get; set; }
public decimal PermitHours { get; set; }
public decimal CompensatoryRestDays { get; set; }
public decimal SickDays { get; set; }
public decimal HolidayDays { get; set; }
}

View file

@ -0,0 +1,46 @@
namespace WorkTracker.Domain;
public sealed class MonthlyTimesheetModel
{
public int Year { get; set; }
public int Month { get; set; }
public List<MonthlyTimesheetDayModel> Days { get; set; } = [];
public List<MonthlyTimesheetRowModel> Rows { get; set; } = [];
}
public sealed class MonthlyTimesheetDayModel
{
public DateOnly Date { get; set; }
public bool IsWeekend { get; set; }
public bool IsHoliday { get; set; }
public bool IsClosure { get; set; }
public List<string> WorkUnitSummaries { get; set; } = [];
public List<string> EventSummaries { get; set; } = [];
}
public sealed class MonthlyTimesheetRowModel
{
public string Key { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public MonthlyTimesheetValueFormat ValueFormat { get; set; }
public List<decimal?> DailyValues { get; set; } = [];
public decimal? Total { get; set; }
}
public enum MonthlyTimesheetValueFormat
{
Hours = 0,
Days = 1
}

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

@ -8,6 +8,51 @@ Quick run (Docker Engine required):
2. App will be available on host port 8002 -> container 8080 (http://localhost:8002).
Production deployment:
- Put deployment values in a `.env` file next to `docker-compose.yml` or export them in the shell before running Docker Compose.
- The base compose file is now parameterized for host port, persisted storage path, image tag, auth mode, seeded user credentials, allowed hosts, and healthcheck timings.
- For a direct production deployment, define at least `WORKTRACKER_DATA_PATH`. If you enable the built-in login flow, also define `APPAUTH_ENABLED=true` and a strong `SINGLEUSER_PASSWORD` before first startup.
Deployment variables:
| Variable | Required for production | Default | Purpose |
| --- | --- | --- | --- |
| `WORKTRACKER_DATA_PATH` | Yes | `./.docker-data/couchbase` | Host path mounted to `/data/couchbase` so the embedded Couchbase Lite database survives container replacement. |
| `WORKTRACKER_PORT` | Usually | `8002` | Host port published for the app container. |
| `IMAGE_REGISTRY` | No | `worktracker` | Image repository/name used by the `worktracker` service, for example `ghcr.io/your-org/worktracker`. |
| `IMAGE_TAG` | Usually | `latest` | Image tag to deploy. Pin this to a release tag instead of using `latest`. |
| `ASPNETCORE_ENVIRONMENT` | No | `Production` | ASP.NET Core environment name. Keep this as `Production` for real deployments. |
| `ASPNETCORE_FORWARDEDHEADERS_ENABLED` | Recommended | `true` | Enables forwarded header handling when the app runs behind a reverse proxy or Zero Trust tunnel. |
| `ALLOWED_HOSTS` | Recommended | `*` | ASP.NET Core allowed hostnames. Set this to your public hostname instead of leaving it wide open. |
| `USE_HTTPS_REDIRECTION` | Depends | `false` | Enables ASP.NET Core HTTPS redirection. Leave this `false` when TLS terminates upstream unless you have verified forwarded headers and redirect behavior. |
| `COUCHBASELITE_DATABASE_NAME` | No | `worktracker` | Embedded Couchbase Lite database name. |
| `APPAUTH_ENABLED` | Yes, choose a mode explicitly | `false` | Enables the built-in login flow when `true`. Leave `false` only if access is protected upstream and you want zero-trust style default-admin passthrough. |
| `APPAUTH_DEFAULT_USERNAME` | When `APPAUTH_ENABLED=false` | `Admin` | Display name injected for every request while built-in auth is disabled. |
| `APPAUTH_DEFAULT_USERID` | When `APPAUTH_ENABLED=false` | `ADMIN` | User identifier injected for every request while built-in auth is disabled. |
| `SINGLEUSER_SEED_ON_STARTUP` | No | `true` | Seeds the built-in admin account into Couchbase Lite on first startup if it does not exist yet. |
| `SINGLEUSER_USERNAME` | When `APPAUTH_ENABLED=true` | `Admin` | Username for the seeded built-in account. |
| `SINGLEUSER_PASSWORD` | When `APPAUTH_ENABLED=true` and the database is new | `Disagio` | Initial password for the seeded built-in account. Set this to a strong secret before the first production start. |
| `WORKTRACKER_HEALTHCHECK_INTERVAL` | No | `30s` | Docker healthcheck interval. |
| `WORKTRACKER_HEALTHCHECK_TIMEOUT` | No | `5s` | Docker healthcheck timeout. |
| `WORKTRACKER_HEALTHCHECK_START_PERIOD` | No | `10s` | Grace period before Docker starts evaluating health. |
| `WORKTRACKER_HEALTHCHECK_RETRIES` | No | `3` | Consecutive healthcheck failures before the container is marked unhealthy. |
Example production `.env`:
```dotenv
WORKTRACKER_DATA_PATH=/srv/worktracker/couchbase
WORKTRACKER_PORT=8002
IMAGE_REGISTRY=ghcr.io/your-org/worktracker
IMAGE_TAG=2026.04.20
ALLOWED_HOSTS=worktracker.example.com
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
USE_HTTPS_REDIRECTION=false
APPAUTH_ENABLED=true
SINGLEUSER_USERNAME=admin
SINGLEUSER_PASSWORD=replace-with-a-strong-secret
```
Authentication mode:
- Authentication is disabled by default and every request runs as the configured default admin user. This is intended for deployments fronted by Cloudflare Zero Trust.
@ -39,7 +84,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,180 @@ 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<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
{
var coeff = day.CoeffSnapshot;
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1).AddDays(-1);
var days = await GetRangeAsync(from, to, cancellationToken);
var dayLookup = days.ToDictionary(day => day.Date);
var settings = await appSettingsService.GetAsync(cancellationToken);
// Calculate projected exit time
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
var daySummaries = new List<MonthlyTimesheetDaySummary>();
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;
dayLookup.TryGetValue(date, out var day);
daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
}
// Calculate worked hours
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
? coeff.StandardWorkHoursPerDay
: 0m;
return new MonthlyTimesheetModel
{
Year = year,
Month = month,
Days = daySummaries.Select(summary => new MonthlyTimesheetDayModel
{
Date = summary.Date,
IsWeekend = summary.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
IsHoliday = summary.HolidayDays > 0m || dayLookup.GetValueOrDefault(summary.Date)?.IsItalianFestivity == true,
IsClosure = summary.VacationDays > 0m && HasEventType(dayLookup.GetValueOrDefault(summary.Date), CalendarEventType.Closure),
WorkUnitSummaries = dayLookup.GetValueOrDefault(summary.Date)?.WorkUnits
.Where(unit => includePreview || !unit.IsPreview)
.Select(FormatTimesheetWorkUnitSummary)
.ToList() ?? [],
EventSummaries = dayLookup.GetValueOrDefault(summary.Date)?.CalendarEvents
.Select(FormatTimesheetEventSummary)
.ToList() ?? []
}).ToList(),
Rows =
[
CreateTimesheetRow("office", "Ore lavorative in presenza", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OfficeHours)),
CreateTimesheetRow("home", "Ore lavorative in smart working", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.HomeHours)),
CreateTimesheetRow("overtime", "Straordinari", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OvertimeHours)),
CreateTimesheetRow("weekend", "Weekend", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.WeekendHours)),
CreateTimesheetRow("night", "Notturni (22-06)", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.NightHours)),
CreateTimesheetRow("vacation", "Giorni di ferie", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.VacationDays)),
CreateTimesheetRow("permit", "Ore di permesso", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.PermitHours)),
CreateTimesheetRow("compensatory-rest", "Riposo compensativo", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.CompensatoryRestDays), includeZeroTotal: false),
CreateTimesheetRow("sick", "Giorni di malattia", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.SickDays)),
CreateTimesheetRow("holiday", "Festività", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.HolidayDays))
]
};
}
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// 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;
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;
// Income calculations
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
for (var date = from; date <= to; date = date.AddDays(1))
{
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++;
}
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 +358,426 @@ 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 MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours)
{
var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? [];
var totalHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
var explicitHoliday = HasEventType(day, CalendarEventType.Holiday);
var illness = HasEventType(day, CalendarEventType.Illness);
var dayOff = HasEventType(day, CalendarEventType.DayOff);
var closure = HasEventType(day, CalendarEventType.Closure);
var isWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
var isAutomaticHoliday = day?.IsItalianFestivity ?? false;
var standardHours = includedUnits.FirstOrDefault()?.CoeffSnapshot.StandardWorkHoursPerDay ?? defaultStandardHours;
var nightHours = includedUnits.Sum(GetNightHours);
var weekdayDaytimeHours = isWeekend ? 0m : Math.Max(0m, totalHours - nightHours);
var suppressVacation = isWeekend || explicitHoliday || isAutomaticHoliday || illness;
var hasNonWorkingEvent = explicitHoliday || illness || dayOff || closure;
var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && totalHours < standardHours
? standardHours - totalHours
: 0m;
return new MonthlyTimesheetDaySummary
{
Date = date,
OfficeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Office).Sum(unit => unit.ManualWorkedHours),
HomeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Home).Sum(unit => unit.ManualWorkedHours),
OvertimeHours = Math.Max(0m, weekdayDaytimeHours - standardHours),
WeekendHours = isWeekend ? totalHours : 0m,
NightHours = nightHours,
VacationDays = (dayOff || closure) && !suppressVacation ? 1m : 0m,
PermitHours = Math.Max(0m, permitHours),
CompensatoryRestDays = 0m,
SickDays = illness ? 1m : 0m,
HolidayDays = explicitHoliday && !isWeekend ? 1m : 0m
};
}
private static MonthlyTimesheetRowModel CreateTimesheetRow(
string key,
string label,
MonthlyTimesheetValueFormat valueFormat,
IEnumerable<decimal> values,
bool includeZeroTotal = true)
{
var dailyValues = values
.Select(value => value > 0m ? value : (decimal?)null)
.ToList();
var total = dailyValues.Where(value => value.HasValue).Sum(value => value ?? 0m);
return new MonthlyTimesheetRowModel
{
Key = key,
Label = label,
ValueFormat = valueFormat,
DailyValues = dailyValues,
Total = includeZeroTotal || total > 0m ? total : null
};
}
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 bool HasEventType(WorkDayDocument? day, CalendarEventType eventType)
{
return day?.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType) == true;
}
private static string FormatTimesheetWorkUnitSummary(WorkUnitDocument unit)
{
var prefix = unit.Location == WorkUnitLocation.Home ? "SW" : "Pres";
var hours = FormatCompactHours(unit.ManualWorkedHours);
if (unit.StartTime.HasValue && unit.EndTime.HasValue)
{
return $"{prefix}: {unit.Label} ({unit.StartTime:HH:mm}-{unit.EndTime:HH:mm}, {hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
}
return $"{prefix}: {unit.Label} ({hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
}
private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent)
{
if (calendarEvent.StartTime.HasValue)
{
return $"{calendarEvent.EventType}: {calendarEvent.Description} ({calendarEvent.StartTime:HH:mm})";
}
return $"{calendarEvent.EventType}: {calendarEvent.Description}";
}
private static string FormatCompactHours(decimal value)
{
return value == decimal.Truncate(value)
? value.ToString("0")
: value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
}
private static decimal GetNightHours(WorkUnitDocument unit)
{
if (!unit.StartTime.HasValue || !unit.EndTime.HasValue || unit.EndTime <= unit.StartTime)
{
return 0m;
}
return GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(0, 0), new TimeOnly(6, 0))
+ GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(22, 0), new TimeOnly(23, 59, 59));
}
private static decimal GetOverlapHours(TimeOnly rangeStart, TimeOnly rangeEnd, TimeOnly windowStart, TimeOnly windowEnd)
{
var overlapStart = rangeStart > windowStart ? rangeStart : windowStart;
var overlapEnd = rangeEnd < windowEnd ? rangeEnd : windowEnd;
if (overlapEnd <= overlapStart)
{
return 0m;
}
return Math.Round((decimal)(overlapEnd - overlapStart).TotalHours, 2, MidpointRounding.AwayFromZero);
}
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 +786,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 +801,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 +815,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,23 @@ 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<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default);
}

View file

@ -5,16 +5,27 @@ services:
dockerfile: Dockerfile
image: ${IMAGE_REGISTRY:-worktracker}:${IMAGE_TAG:-latest}
environment:
ASPNETCORE_ENVIRONMENT: Production
UseHttpsRedirection: "false"
ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT:-Production}
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: ${ASPNETCORE_FORWARDEDHEADERS_ENABLED:-true}
AllowedHosts: ${ALLOWED_HOSTS:-*}
UseHttpsRedirection: ${USE_HTTPS_REDIRECTION:-false}
CouchbaseLite__DatabaseName: ${COUCHBASELITE_DATABASE_NAME:-worktracker}
CouchbaseLite__Directory: /data/couchbase
AppAuth__Enabled: ${APPAUTH_ENABLED:-false}
AppAuth__DefaultUsername: ${APPAUTH_DEFAULT_USERNAME:-Admin}
AppAuth__DefaultUserId: ${APPAUTH_DEFAULT_USERID:-ADMIN}
SingleUser__SeedOnStartup: ${SINGLEUSER_SEED_ON_STARTUP:-true}
SingleUser__Username: ${SINGLEUSER_USERNAME:-Admin}
SingleUser__Password: ${SINGLEUSER_PASSWORD:-Disagio}
ports:
- "8002:8080"
- "${WORKTRACKER_PORT:-8002}:8080"
volumes:
- ${WORKTRACKER_DATA_PATH:-./.docker-data/couchbase}:/data/couchbase
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
interval: ${WORKTRACKER_HEALTHCHECK_INTERVAL:-30s}
timeout: ${WORKTRACKER_HEALTHCHECK_TIMEOUT:-5s}
start_period: ${WORKTRACKER_HEALTHCHECK_START_PERIOD:-10s}
retries: ${WORKTRACKER_HEALTHCHECK_RETRIES:-3}

View file

@ -1,5 +1,30 @@
import { expect, test } from '@playwright/test';
test.describe('sidebar collapse', () => {
test('starts collapsed and expands through the toggle button', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto('/');
const sidebar = page.getByTestId('sidebar-shell');
const toggle = page.getByRole('button', { name: 'Toggle sidebar' });
await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await toggle.click();
await expect(sidebar).toHaveAttribute('data-collapsed', 'false');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByText('Dashboard')).toBeVisible();
await toggle.click();
await expect(sidebar).toHaveAttribute('data-collapsed', 'true');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
});
test('home loads without a login screen', async ({ page }) => {
await page.goto('/');
@ -14,7 +39,7 @@ test('protected pages are directly available without redirecting to login', asyn
{ path: '/calendar', heading: 'Calendar' },
{ path: '/summary', heading: 'Monthly Summary' },
{ path: '/settings', heading: 'Settings' },
{ path: '/workday', heading: 'Work Day Entry' },
{ path: '/work-unit', heading: 'Work Unit' },
{ path: '/auth', heading: 'You are authenticated' }
];

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,118 @@ 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(16rem, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
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-right {
left: auto;
right: 0.35rem;
}
.calendar-popup-left {
left: 0.35rem;
right: auto;
}
.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 +227,136 @@ 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);
}
}
/* Monthly timesheet summary */
.timesheet-summary-card {
overflow: hidden;
}
.timesheet-summary-table {
min-width: max-content;
}
.timesheet-summary-table thead th {
background-color: #f8f9fa;
white-space: nowrap;
}
.timesheet-summary-table th,
.timesheet-summary-table td {
min-width: 2.2rem;
padding: 0.25rem 0.12rem;
vertical-align: middle;
}
.timesheet-summary-sticky-column {
position: sticky;
left: 0;
z-index: 2;
min-width: 15rem !important;
background-color: #fff;
}
.timesheet-summary-table thead .timesheet-summary-sticky-column {
z-index: 3;
background-color: #f8f9fa;
}
.timesheet-summary-total-column {
background-color: #f8f9fa;
min-width: 3.3rem !important;
}
.timesheet-summary-table tbody tr:nth-child(odd) td,
.timesheet-summary-table tbody tr:nth-child(odd) .timesheet-summary-sticky-column {
background-color: #fcfcfd;
}
.timesheet-summary-table .timesheet-summary-day-danger {
background-color: #f8d7da !important;
}
.timesheet-summary-table .timesheet-summary-day-closure {
background-color: #e2e3e5 !important;
}
.timesheet-summary-day-header {
position: relative;
cursor: default;
}
.timesheet-summary-day-popup {
position: absolute;
top: calc(100% + 0.35rem);
left: 50%;
z-index: 15;
width: min(18rem, calc(100vw - 2rem));
max-width: calc(100vw - 2rem);
padding: 0.65rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0.7rem;
background: #fff;
box-shadow: 0 0.75rem 2rem rgba(15, 23, 42, 0.18);
text-align: left;
transform: translateX(-50%);
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.timesheet-summary-day-header:hover .timesheet-summary-day-popup,
.timesheet-summary-day-header:focus-within .timesheet-summary-day-popup {
opacity: 1;
visibility: visible;
}
.timesheet-summary-day-popup-left .timesheet-summary-day-popup {
left: 0;
transform: none;
}
.timesheet-summary-day-popup-right .timesheet-summary-day-popup {
left: auto;
right: 0;
transform: none;
}
.timesheet-summary-day-popup-item {
font-size: 0.75rem;
line-height: 1.35;
color: #1f2937;
}
.timesheet-summary-day-popup-item + .timesheet-summary-day-popup-item {
margin-top: 0.35rem;
}
.timesheet-summary-day-popup-item-event {
color: #475569;
}
@media (max-width: 767.98px) {
.timesheet-summary-sticky-column {
min-width: 12rem !important;
}
.timesheet-summary-day-popup {
left: 0;
right: 0;
width: auto;
transform: none;
}
}