Compare commits

..

No commits in common. "c8c5f0071fad7d756b12a35f3529af5c0670b085" and "08e573d63c6fae9f60aecb0d8e011a1e0bfadf1c" have entirely different histories.

30 changed files with 416 additions and 2469 deletions

50
.vscode/launch.json vendored
View file

@ -1,50 +1,16 @@
{
"version": "0.2.0",
"compounds": [],
"configurations": [
"compounds": [
{
"name": "WorkTracker: Debug in Docker",
"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"
"configurations": [
"WorkTracker: Debug App in Docker",
"WorkTracker: Debug Edge"
],
"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"
},
"stopAll": true
}
],
"configurations": [
{
"name": "WorkTracker: Debug App in Docker",
"type": "coreclr",

View file

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

View file

@ -1,24 +1,12 @@
@inherits LayoutComponentBase
<div class="page @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
<div class="sidebar @(sidebarCollapsed ? "sidebar-collapsed" : string.Empty)">
<NavMenu IsCollapsed="sidebarCollapsed" />
<div class="page">
<div class="sidebar">
<NavMenu />
</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>
@ -33,12 +21,3 @@
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
private bool sidebarCollapsed = true;
private void ToggleSidebar()
{
sidebarCollapsed = !sidebarCollapsed;
}
}

View file

@ -6,7 +6,6 @@
main {
flex: 1;
min-width: 0;
}
.sidebar {
@ -37,27 +36,6 @@ 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;
@ -78,9 +56,6 @@ main {
height: 100vh;
position: sticky;
top: 0;
transition: width 0.2s ease;
overflow: hidden;
flex: 0 0 auto;
}
.top-row {
@ -99,14 +74,6 @@ 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,72 +2,65 @@
@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="" aria-label="WorkTracker home">
<span class="sidebar-brand-full">WorkTracker</span>
<span class="sidebar-brand-compact" aria-hidden="true">WT</span>
</a>
<a class="navbar-brand" href="">WorkTracker</a>
</div>
</div>
<div class="nav-scrollable">
<nav id="sidebar-navigation" class="nav flex-column" aria-label="Sidebar navigation">
<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-item px-3">
<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 class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
</NavLink>
</div>
<div class="nav-item px-3">
<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 class="nav-link" href="workday">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> New Entry
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="calendar" aria-label="Calendar" title="Calendar">
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span>
<span class="nav-label">Calendar</span>
<NavLink class="nav-link" href="grid">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Grid View
</NavLink>
</div>
<div class="nav-item px-3">
<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 class="nav-link" href="calendar">
<span class="bi bi-calendar3-nav-menu" aria-hidden="true"></span> Calendar
</NavLink>
</div>
<div class="nav-item px-3">
<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 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>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<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 class="nav-link" href="auth">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink>
</div>
@if (AppAuthOptions.Value.Enabled)
{
<div class="nav-item px-3">
<form action="/api/logout" method="post">
<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 type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
</button>
</form>
</div>
@ -75,15 +68,13 @@
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<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 class="nav-link" href="login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
</div>

View file

@ -1,3 +1,20 @@
.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);
@ -7,14 +24,6 @@
font-size: 1.1rem;
}
.sidebar-brand-compact {
display: none;
}
.nav-menu-shell {
height: 100%;
}
.bi {
display: inline-block;
position: relative;
@ -74,12 +83,6 @@
padding-bottom: 0.5rem;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
@ -111,59 +114,24 @@
}
.nav-scrollable {
display: block;
height: calc(100vh - 3.5rem);
overflow-y: auto;
display: none;
}
@media (max-width: 640.98px) {
.nav-menu-shell-collapsed .sidebar-brand-full,
.nav-menu-shell:not(.nav-menu-shell-collapsed) .sidebar-brand-compact {
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
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;
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View file

@ -1,232 +0,0 @@
@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,7 +6,6 @@
@inject IWorkDayService WorkDayService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Calendar</PageTitle>
@ -16,14 +15,8 @@
<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>
@ -55,67 +48,12 @@ else
}
else
{
<td class="calendar-cell @GetCellClass(cell) @(IsActiveCell(cell.Date) ? "calendar-cell-active" : string.Empty)" @onclick="() => TogglePopup(cell.Date)" role="button">
<td class="calendar-cell @GetCellClass(cell)" @onclick="() => NavigateToDay(cell.Date)" role="button">
<div class="calendar-day-number">@cell.Date.Day</div>
@foreach (var workUnit in cell.Entry?.WorkUnits ?? [])
@if (cell.Entry is not null)
{
<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>
<span class="badge @GetBadgeClass(cell.Entry.DayType)">@cell.Entry.DayType</span>
<div class="calendar-hours">@cell.Entry.WorkedHoursFinal.ToString("N1")h</div>
}
</td>
}
@ -129,69 +67,14 @@ else
<div class="mt-3">
<h3 class="h6">Legend</h3>
<div class="d-flex flex-wrap gap-2">
<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-primary">Work</span>
<span class="badge bg-success">Home</span>
<span class="badge bg-warning text-dark">Closure</span>
<span class="badge bg-secondary">Day off</span>
<span class="badge bg-info text-dark">Illness</span>
<span class="badge bg-secondary">DayOff</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 {
@ -201,10 +84,6 @@ 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()
{
@ -223,13 +102,11 @@ 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 = [];
@ -241,7 +118,6 @@ 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)
@ -264,191 +140,49 @@ 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 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 void NavigateToDay(DateOnly date) =>
Navigation.NavigateTo($"/workday/{date:yyyy-MM-dd}");
private string GetCellClass(CalendarCell cell)
{
if (cell.IsWeekend || cell.IsFestivity)
if (cell.IsWeekend || cell.IsFestivity) return "calendar-weekend";
if (cell.Entry is null) return "";
return cell.Entry.DayType switch
{
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;
DayType.Closure => "calendar-closure",
DayType.Illness => "calendar-illness",
DayType.DayOff => "calendar-dayoff",
DayType.Holiday => "calendar-holiday",
_ => ""
};
}
private static CalendarEventType? GetDominantEventType(WorkDayDocument? day)
private static string GetBadgeClass(DayType type) => type switch
{
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"
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"
};
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,10 +29,12 @@ else
<tr>
<th>Date</th>
<th>Day</th>
<th>Work Units</th>
<th>Calendar Events</th>
<th class="text-end">Counted</th>
<th class="text-end">Preview</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 class="text-end">Off</th>
<th class="text-end">Gross €</th>
<th class="text-end">Net €</th>
@ -45,42 +47,24 @@ 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>
@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>
<a href="workday/@row.Date.ToString("yyyy-MM-dd")" class="btn btn-sm btn-outline-primary">Edit</a>
</td>
</tr>
}
@ -152,93 +136,23 @@ else
{
if (row.IsWeekend || row.IsFestivity) return "table-danger";
if (row.Entry is null) return "";
if (row.Entry.CalendarEvents.Any(entry => entry.EventType == CalendarEventType.Holiday))
return row.Entry.DayType switch
{
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;
DayType.Closure => "table-warning",
DayType.Illness => "table-info",
DayType.DayOff => "table-secondary",
DayType.Holiday => "table-success",
DayType.Home => "table-light",
_ => ""
};
}
private static decimal GetCountedHours(CalendarDayRow row)
private static string FormatDelta(decimal d) => d switch
{
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}";
}
> 0 => $"+{d:N2}",
< 0 => d.ToString("N2"),
_ => "—"
};
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">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>
<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>
</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">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>
<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>
</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 counted hours, preview hours, income, and non-working events.</p>
<p class="mb-2">Totals for worked hours, income, and day types.</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 the standard daily target and the income coefficients.</p>
<p class="mb-2">Configure default rates, hours, and tax coefficients.</p>
<a href="settings" class="btn btn-outline-secondary">Open Settings</a>
</div>
</div>

View file

@ -3,8 +3,7 @@
@attribute [Authorize]
@rendermode InteractiveServer
@using System.Globalization
@inject global::WorkTracker.Services.WorkDays.IWorkDayService WorkDayService
@inject IWorkDayService WorkDayService
<PageTitle>Monthly Summary</PageTitle>
@ -16,21 +15,11 @@
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next &raquo;</button>
</div>
<div class="form-check mb-3">
<input id="include-preview" type="checkbox" class="form-check-input" checked="@includePreview" @onchange="OnIncludePreviewChanged" />
<label class="form-check-label" for="include-preview">Include preview work units in totals</label>
</div>
<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 (viewMode == SummaryViewMode.Cards && summary is not null)
else if (summary is not null)
{
<div class="row g-3">
<div class="col-6 col-md-4 col-xl-3">
@ -41,35 +30,11 @@ else if (viewMode == SummaryViewMode.Cards && 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">@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 class="fs-3 fw-bold">@summary.TotalWorkedHours.ToString("N1")h</div>
</div>
</div>
</div>
@ -77,7 +42,7 @@ else if (viewMode == SummaryViewMode.Cards && 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">@FormatHours(summary.TotalHoursOff)</div>
<div class="fs-3 fw-bold">@summary.TotalHoursOff.ToString("N1")h</div>
</div>
</div>
</div>
@ -124,8 +89,8 @@ else if (viewMode == SummaryViewMode.Cards && 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>
@ -140,82 +105,20 @@ else if (viewMode == SummaryViewMode.Cards && 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>
</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 bool includePreview;
private global::WorkTracker.Domain.MonthlySummaryModel? summary;
private global::WorkTracker.Domain.MonthlyTimesheetModel? timesheet;
private SummaryViewMode viewMode = SummaryViewMode.Timesheet;
private MonthlySummaryModel? summary;
protected override async Task OnInitializedAsync()
{
@ -231,17 +134,10 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet 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, includePreview);
timesheet = await WorkDayService.GetMonthlyTimesheetAsync(currentMonth.Year, currentMonth.Month, includePreview);
summary = await WorkDayService.GetMonthlySummaryAsync(currentMonth.Year, currentMonth.Month);
loading = false;
}
@ -256,65 +152,4 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet 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 compute manual work-unit totals and income.</p>
<p class="text-muted">Default values used to prefill each workday. Every day can still override these values.</p>
@if (settings is null)
{
@ -22,6 +22,10 @@ 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,17 +1,16 @@
@page "/work-unit"
@page "/work-unit/{DateStr}"
@page "/work-unit/{DateStr}/{UnitId}"
@page "/workday"
@page "/workday/{DateStr}"
@attribute [Authorize]
@rendermode InteractiveServer
@inject IWorkDayService WorkDayService
@inject IAppSettingsService AppSettingsService
@inject IItalianFestivitySource FestivitySource
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Work Unit</PageTitle>
<PageTitle>Work Day</PageTitle>
<h1>Work Unit</h1>
<h1>Work Day Entry</h1>
@if (!loaded)
{
@ -22,20 +21,22 @@ 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="@isExistingUnit" />
<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>
}
</div>
<div class="col-12 col-md-6 col-lg-4">
<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>())
<label class="form-label">Day Type</label>
<select class="form-select" value="@selectedDayType" @onchange="OnDayTypeChanged">
@foreach (var dt in Enum.GetValues<DayType>())
{
<option value="@item">@item</option>
<option value="@dt">@dt</option>
}
</select>
</div>
@ -46,20 +47,14 @@ else
</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" />
<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>
</div>
<div class="col-12 col-md-6 col-lg-4">
<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>
<label class="form-label">Extra hours delta</label>
<input type="number" class="form-control" step="0.25" value="@extraHoursDelta" @onchange="OnExtraDeltaChanged" />
</div>
<div class="col-12">
@ -73,12 +68,20 @@ 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">Calculated Hours</label>
<div class="form-control-plaintext fw-bold">@FormatHours(calculatedWorkedHours)</div>
<label class="form-label text-muted">Projected Exit</label>
<div class="form-control-plaintext fw-bold">@(projectedExitTime?.ToString("HH:mm") ?? "—")</div>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Difference</label>
<div class="form-control-plaintext fw-bold">@FormatSignedHours(workedHoursDelta)</div>
<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>
</div>
<div class="col-6 col-md-4 col-lg-3">
<label class="form-label text-muted">Gross Income</label>
@ -90,27 +93,8 @@ 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>
@ -120,31 +104,29 @@ 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 string unitId = string.Empty;
private string label = "Work unit";
private WorkUnitLocation location = WorkUnitLocation.Office;
private DayType selectedDayType = DayType.None;
private string? startTimeStr;
private string? endTimeStr;
private decimal manualWorkedHours;
private string manualWorkedHoursStr = "00:00";
private bool isPreview;
private string? actualExitTimeStr;
private decimal extraHoursDelta;
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 decimal? calculatedWorkedHours;
private decimal workedHoursDelta;
private decimal dayTotalHours;
private int dayWorkUnitCount;
private bool isWeekend;
private bool isFestivity;
// Loaded from settings
private AppSettingsDocument settings = new();
private IReadOnlyCollection<DateOnly> festivities = [];
protected override async Task OnInitializedAsync()
{
@ -154,41 +136,33 @@ else
}
settings = await AppSettingsService.GetAsync();
await LoadUnitAsync();
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
loaded = true;
}
private async Task LoadUnitAsync()
private async Task LoadExistingEntry()
{
if (string.IsNullOrWhiteSpace(UnitId))
{
selectedDay = await WorkDayService.GetAsync(selectedDate);
SetDefaults();
return;
}
selectedDay = await WorkDayService.GetAsync(selectedDate);
var existing = await WorkDayService.GetWorkUnitAsync(selectedDate, UnitId);
var existing = await WorkDayService.GetAsync(selectedDate);
if (existing is not null)
{
unitId = existing.Id;
label = existing.Label;
location = existing.Location;
selectedDayType = existing.DayType;
startTimeStr = existing.StartTime?.ToString("HH:mm");
endTimeStr = existing.EndTime?.ToString("HH:mm");
manualWorkedHours = existing.ManualWorkedHours;
manualWorkedHoursStr = FormatDurationHours(existing.ManualWorkedHours);
isPreview = existing.IsPreview;
actualExitTimeStr = existing.ActualExitTime?.ToString("HH:mm");
extraHoursDelta = existing.ExtraHoursDelta;
notes = existing.Notes;
isExistingUnit = true;
}
else
{
SetDefaults();
statusMessage = "The selected work unit was not found. A new unit will be created for this day.";
selectedDayType = DayType.None;
startTimeStr = null;
actualExitTimeStr = null;
extraHoursDelta = 0;
notes = null;
}
RecomputePreview();
}
private async Task OnDateChanged(ChangeEventArgs e)
@ -196,214 +170,127 @@ else
if (DateOnly.TryParse(e.Value?.ToString(), out var d))
{
selectedDate = d;
festivities = FestivitySource.GetFestivities(selectedDate.Year);
await LoadExistingEntry();
RecomputeFlags();
RecomputePreview();
statusMessage = null;
}
}
private Task OnStartTimeChanged(ChangeEventArgs e)
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)
{
startTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
RecomputePreview();
statusMessage = null;
return Task.CompletedTask;
}
private Task OnEndTimeChanged(ChangeEventArgs e)
private void OnActualExitChanged(ChangeEventArgs e)
{
endTimeStr = e.Value?.ToString();
SyncManualHoursToCalculated();
actualExitTimeStr = e.Value?.ToString();
statusMessage = null;
return Task.CompletedTask;
}
private Task OnManualWorkedHoursChanged(ChangeEventArgs e)
private void OnExtraDeltaChanged(ChangeEventArgs e)
{
var rawValue = e.Value?.ToString();
if (TryParseDurationHours(rawValue, out var parsedHours))
if (decimal.TryParse(e.Value?.ToString(), out var val))
{
manualWorkedHours = parsedHours;
manualWorkedHoursStr = FormatDurationHours(parsedHours);
extraHoursDelta = val;
}
else
{
manualWorkedHoursStr = FormatDurationHours(manualWorkedHours);
}
RecomputePreview();
statusMessage = null;
return Task.CompletedTask;
}
private void SetDefaults()
private void RecomputeFlags()
{
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();
isWeekend = selectedDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
isFestivity = festivities.Contains(selectedDate);
}
private void RecomputePreview()
{
calculatedWorkedHours = CalculateDuration(ParseTime(startTimeStr), ParseTime(endTimeStr));
workedHoursDelta = manualWorkedHours - (calculatedWorkedHours ?? 0m);
grossIncome = manualWorkedHours * settings.HourlyGrossRate;
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;
var taxableBase = grossIncome * settings.ProfitabilityCoefficient;
netIncome = grossIncome - (taxableBase * settings.InpsRate) - (taxableBase * settings.SubstituteTaxRate);
RecomputeDayTotals();
}
private async Task SaveAsync()
{
RecomputePreview();
TimeOnly? start = null;
TimeOnly? exit = null;
var workUnit = new WorkUnitDocument
if (!string.IsNullOrEmpty(startTimeStr) && TimeOnly.TryParse(startTimeStr, out var s))
{
Id = unitId,
Label = label,
Location = location,
StartTime = ParseTime(startTimeStr),
EndTime = ParseTime(endTimeStr),
ManualWorkedHours = Math.Max(0m, manualWorkedHours),
IsPreview = isPreview,
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,
Notes = notes
};
var saved = await WorkDayService.SaveWorkUnitAsync(selectedDate, workUnit);
var saved = await WorkDayService.SaveAsync(workDay);
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;
// Update preview with saved computed values
projectedExitTime = saved.ProjectedExitTime;
workedHoursBase = saved.WorkedHoursBase;
workedHoursFinal = saved.WorkedHoursFinal;
hoursOff = saved.HoursOff;
grossIncome = saved.GrossIncome;
netIncome = saved.NetIncome;
isWeekend = saved.IsWeekend;
isFestivity = saved.IsItalianFestivity;
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();
statusMessage = $"Saved at {DateTime.Now:t}";
}
}

View file

@ -6,6 +6,8 @@ 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

@ -1,20 +0,0 @@
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

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

View file

@ -4,6 +4,8 @@ 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,12 +8,6 @@ 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

@ -1,26 +0,0 @@
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

@ -1,46 +0,0 @@
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,13 +6,33 @@ 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 List<WorkUnitDocument> WorkUnits { get; set; } = [];
public string? Notes { get; set; }
public List<CalendarEventDocument> CalendarEvents { get; set; } = [];
public CoeffSnapshotDocument CoeffSnapshot { get; set; } = new();
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;

View file

@ -1,34 +0,0 @@
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

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

View file

@ -8,51 +8,6 @@ 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.
@ -84,7 +39,7 @@ Debugging in Docker from VS Code:
- Use the `WorkTracker: Debug in Docker` launch configuration.
- VS Code brings up the development container with `docker compose`, builds the app in `Debug`, and launches `WorkTracker.dll` under `vsdbg` inside the container.
- VS Code waits for the app to report that it is listening before opening Microsoft Edge in browser debug mode against http://localhost:8002.
- When the app reports that it is listening, VS Code automatically opens 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,6 +48,7 @@ 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));
@ -66,6 +67,7 @@ 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,132 +31,45 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult(doc is not null ? Map(doc) : null);
}
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)
public async Task<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var settings = await appSettingsService.GetAsync(cancellationToken);
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;
var festivities = festivitySource.GetFestivities(workDay.Date.Year);
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
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
{
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(workUnit);
Compute(workDay);
if (existingIndex >= 0)
// Preserve creation timestamp for existing documents
var existing = workDaysCollection.GetDocument(workDay.Id);
if (existing is not null)
{
day.WorkUnits[existingIndex] = workUnit;
workDay.CreatedAtUtc = ReadDateTimeOffset(existing, "createdAtUtc");
}
else
{
day.WorkUnits.Add(workUnit);
workDay.CreatedAtUtc = DateTimeOffset.UtcNow;
}
day.UpdatedAtUtc = now;
SortEntries(day);
SaveDocument(day);
return workUnit;
}
workDay.UpdatedAtUtc = DateTimeOffset.UtcNow;
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);
SaveDocument(workDay);
return workDay;
}
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
@ -177,180 +90,88 @@ public sealed class CouchbaseLiteWorkDayService : IWorkDayService
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(results);
}
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, 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 = 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()
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)
};
}
public async Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
private static void Compute(WorkDayDocument day)
{
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);
var coeff = day.CoeffSnapshot;
var daySummaries = new List<MonthlyTimesheetDaySummary>();
for (var date = from; date <= to; date = date.AddDays(1))
// Calculate projected exit time
if (day.StartTime.HasValue && day.DayType is DayType.Work or DayType.Home)
{
dayLookup.TryGetValue(date, out var day);
daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
var totalHours = coeff.StandardWorkHoursPerDay + coeff.LunchBreakHours;
day.ProjectedExitTime = day.StartTime.Value.Add(TimeSpan.FromHours((double)totalHours));
}
else
{
day.ProjectedExitTime = null;
}
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))
]
};
}
// Calculate worked hours
day.WorkedHoursBase = day.DayType is DayType.Work or DayType.Home
? coeff.StandardWorkHoursPerDay
: 0m;
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
day.WorkedHoursFinal = day.WorkedHoursBase + day.ExtraHoursDelta;
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;
// 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;
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;
// Income calculations
day.GrossIncome = day.WorkedHoursFinal * coeff.HourlyGrossRate;
var taxableBase = day.GrossIncome * coeff.ProfitabilityCoefficient;
day.NetIncome = day.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
}
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);
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);
// 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));
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
@ -358,424 +179,37 @@ 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"),
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),
Notes = doc.GetString("notes"),
CoeffSnapshot = new CoeffSnapshotDocument
{
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
HourlyGrossRate = settings.HourlyGrossRate,
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
InpsRate = settings.InpsRate,
SubstituteTaxRate = settings.SubstituteTaxRate
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)
},
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)
@ -786,14 +220,6 @@ 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)
@ -801,13 +227,6 @@ 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);
@ -815,12 +234,4 @@ 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,23 +6,9 @@ public interface IWorkDayService
{
Task<WorkDayDocument?> GetAsync(DateOnly date, 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<WorkDayDocument> SaveAsync(WorkDayDocument workDay, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, 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);
Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, CancellationToken cancellationToken = default);
}

View file

@ -5,27 +5,16 @@ services:
dockerfile: Dockerfile
image: ${IMAGE_REGISTRY:-worktracker}:${IMAGE_TAG:-latest}
environment:
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}
ASPNETCORE_ENVIRONMENT: Production
UseHttpsRedirection: "false"
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:
- "${WORKTRACKER_PORT:-8002}:8080"
- "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: ${WORKTRACKER_HEALTHCHECK_INTERVAL:-30s}
timeout: ${WORKTRACKER_HEALTHCHECK_TIMEOUT:-5s}
start_period: ${WORKTRACKER_HEALTHCHECK_START_PERIOD:-10s}
retries: ${WORKTRACKER_HEALTHCHECK_RETRIES:-3}
interval: 30s
timeout: 5s
retries: 3

View file

@ -1,30 +1,5 @@
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('/');
@ -39,7 +14,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: '/work-unit', heading: 'Work Unit' },
{ path: '/workday', heading: 'Work Day Entry' },
{ path: '/auth', heading: 'You are authenticated' }
];

View file

@ -61,35 +61,20 @@ h1:focus {
/* Calendar view */
.calendar-table td.calendar-cell {
height: 10rem;
height: 5rem;
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 {
@ -97,118 +82,6 @@ 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;
}
@ -227,136 +100,4 @@ 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;
}
}