Files
timetracker/timetracker.Client/Components/Pages/Home.razor
T
2026-06-08 16:24:51 +02:00

552 lines
29 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>KW @_kw Wochenübersicht Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Lade Wochendaten…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Wochen-Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
@_weekLabel
</MudText>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
@_weekSubLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (!IsCurrentWeek)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentWeek" Size="Size.Small">
Heute
</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Tageskarten ── *@
@foreach (var day in _days)
{
var isToday = day.Date == DateOnly.FromDateTime(DateTime.Today);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
var hasData = day.Start.HasValue || day.End.HasValue;
var overtime = GetOvertime(day);
var errors = GetDayErrors(day).ToList();
var borderColor = GetBorderColor(day, isWorkDay, errors);
var progressPct = GetProgressPercent(day);
var holidayName = _holidays.GetValueOrDefault(day.Date);
if (!isWorkDay)
{
@* ── Nicht-Arbeitstag: kompakt ── *@
<MudPaper @key="@day.Date" Elevation="1" Class="pa-3 rounded-lg"
Style="@($"border-left: 4px solid {(!string.IsNullOrEmpty(holidayName) ? "#009688" : "#CFD8DC")}; background:{(!string.IsNullOrEmpty(holidayName) ? "rgba(0,150,136,0.05)" : "#FAFAFA")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudText Typo="Typo.body1" Style="color:#90A4AE; font-weight:500">
@day.Date.ToString("dddd", _deCulture)
</MudText>
<MudText Typo="Typo.caption" Style="color:#B0BEC5">
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
</MudText>
</MudStack>
@if (!string.IsNullOrEmpty(holidayName))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Style="background:#009688; color:white; font-weight:600">
@holidayName
</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="color:#90A4AE; border-color:#CFD8DC;">
Kein Arbeitstag
</MudChip>
}
</MudStack>
</MudPaper>
}
else
{
@* ── Arbeitstag: vollständige Karte ── *@
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
Style="@($"border-left: 4px solid {borderColor};")">
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.h6" Style="font-weight:600">
@day.Date.ToString("dddd", _deCulture)
</MudText>
@if (isToday)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
Variant="Variant.Filled"
Style="height:20px; font-size:10px; font-weight:700">
HEUTE
</MudChip>
}
@if (!string.IsNullOrEmpty(holidayName))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Style="height:20px; font-size:10px; font-weight:700; background:#009688; color:white;">
@holidayName
</MudChip>
}
</MudStack>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
</MudText>
</MudStack>
@if (overtime.HasValue)
{
<MudChip T="string"
Color="@(overtime.Value > TimeSpan.Zero ? Color.Success : overtime.Value < TimeSpan.Zero ? Color.Warning : Color.Info)"
Variant="Variant.Filled" Size="Size.Medium">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@(overtime.Value >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Size="Size.Small" />
<MudText Typo="Typo.body2"><b>@FormatTs(overtime.Value, sign: true)</b></MudText>
</MudStack>
</MudChip>
}
else if (!hasData)
{
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
Nicht erfasst
</MudChip>
}
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Class="pt-2">
@* ── Zeit-Eingaben + Statistik ── *@
<MudGrid Spacing="3">
<MudItem xs="6" sm="3" md="2">
<MudTimePicker Label="Beginn"
Time="@day.Start"
TimeChanged="@(async v => await OnStartChanged(day, v))"
AmPm="false" Variant="Variant.Outlined"
Clearable="true" PickerVariant="PickerVariant.Dialog"
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
</MudItem>
<MudItem xs="6" sm="3" md="2">
<MudTimePicker Label="Ende"
Time="@day.End"
TimeChanged="@(async v => await OnEndChanged(day, v))"
AmPm="false" Variant="Variant.Outlined"
Clearable="true" PickerVariant="PickerVariant.Dialog"
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
</MudItem>
@if (day.GrossWork.HasValue)
{
<MudItem xs="12" md="8">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap" Class="h-100 pl-1">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Brutto</MudText>
<MudText Typo="Typo.body1">@FormatTs(day.GrossWork.Value)</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1"></MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
</MudStack>
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
@FormatTs(day.NetWork!.Value)
</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
</MudStack>
</MudStack>
</MudItem>
}
</MudGrid>
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
Rounded="true" Class="mt-3" Style="height:6px" />
</MudTooltip>
}
@* ── Pausen-Sektion ── *@
<MudDivider Class="mt-3 mb-2" />
<MudStack Spacing="2">
@for (int i = 0; i < day.Breaks.Count; i++)
{
var brk = day.Breaks[i];
var idx = i;
var brkError = (day.Start.HasValue && brk.Start.HasValue && brk.Start < day.Start)
|| (day.End.HasValue && brk.End.HasValue && brk.End > day.End);
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
<MudIcon Icon="@Icons.Material.Filled.FreeBreakfast"
Size="Size.Small" Color="Color.Secondary" Style="opacity:0.6" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width:55px">
Pause @(idx + 1)
</MudText>
<MudTimePicker Time="@brk.Start"
TimeChanged="@(async v => await OnBreakStartChanged(day, idx, v))"
AmPm="false" Variant="Variant.Outlined"
Style="width:300px" Clearable="true"
PickerVariant="PickerVariant.Inline"
Error="@brkError" />
<MudText Color="Color.Secondary"></MudText>
<MudTimePicker Time="@brk.End"
TimeChanged="@(async v => await OnBreakEndChanged(day, idx, v))"
AmPm="false" Variant="Variant.Outlined"
Style="width:300px" Clearable="true"
PickerVariant="PickerVariant.Inline"
Error="@brkError" />
@if (brk.Duration.HasValue)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Secondary">
@FormatTs(brk.Duration.Value)
</MudChip>
}
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline"
Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await RemoveBreak(day, idx))" />
</MudStack>
}
<MudButton StartIcon="@Icons.Material.Filled.AddCircleOutline"
Variant="Variant.Text" Color="Color.Primary"
Size="Size.Small" OnClick="@(() => AddBreak(day))">
Pause hinzufügen
</MudButton>
</MudStack>
@* ── Validierungsfehler ── *@
@if (errors.Count > 0)
{
<MudDivider Class="mt-2 mb-1" />
@foreach (var err in errors)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-1">@err</MudAlert>
}
}
</MudCardContent>
</MudCard>
}
}
@* ── Wochensumme ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
</MudStack>
<MudGrid Spacing="3">
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
</MudStack>
</MudItem>
<MudItem xs="6" sm="3">
<MudStack Spacing="0">
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
@FormatTs(WeekOvertime, sign: true)
</MudText>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@* ── Gleitzeitkonto ── *@
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
<MudStack Spacing="0">
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Arbeitstage</MudText>
</MudStack>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@(_totalOvertime >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" />
<MudText Typo="Typo.h4"
Style="@($"font-weight:700; color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
@FormatTs(_totalOvertime, sign: true)
</MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
private bool _loading = true;
private int _userId;
private DateOnly _monday;
private List<DayVm> _days = [];
private AppSettings _settings = new();
private string _weekLabel = "";
private string _weekSubLabel = "";
private int _kw => _monday == default ? 0 : _deCulture.Calendar.GetWeekOfYear(
_monday.ToDateTime(TimeOnly.MinValue),
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
private TimeSpan _totalOvertime;
private Dictionary<DateOnly, string> _holidays = [];
private int _holidayYear = -1;
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
protected override async Task OnInitializedAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
if (claim == null) return; // Prerender-Pass Circuit noch nicht authentifiziert
_userId = int.Parse(claim.Value);
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
_settings = await TrackerService.GetSettingsAsync(_userId);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
_loading = false;
}
private async Task LoadWeek()
{
if (_monday.Year != _holidayYear)
{
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
}).ToList();
BuildWeekLabels();
}
private void BuildWeekLabels()
{
var sunday = _monday.AddDays(6);
var kw = _deCulture.Calendar.GetWeekOfYear(
_monday.ToDateTime(TimeOnly.MinValue),
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
DayOfWeek.Monday);
_weekLabel = $"KW {kw} · {_monday:dd. MMM} {sunday:dd. MMM yyyy}";
var recorded = _days.Count(d => d.Start.HasValue || d.End.HasValue);
var workDays = _days.Count(d => _settings.IsWorkDay(d.Date.DayOfWeek));
_weekSubLabel = recorded == 0
? "Noch keine Einträge diese Woche"
: $"{recorded} von {workDays} Arbeitstagen erfasst";
}
// ── Navigation ──────────────────────────────────────────────
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
private static DateOnly GetMonday(DateOnly date)
{
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
return date.AddDays(-diff);
}
// ── Change Handlers ──────────────────────────────────────────
private async Task OnStartChanged(DayVm day, TimeSpan? v) { day.Start = v; await SaveDay(day); }
private async Task OnEndChanged(DayVm day, TimeSpan? v) { day.End = v; await SaveDay(day); }
private async Task OnBreakStartChanged(DayVm day, int idx, TimeSpan? v)
{ day.Breaks[idx].Start = v; await SaveDay(day); }
private async Task OnBreakEndChanged(DayVm day, int idx, TimeSpan? v)
{ day.Breaks[idx].End = v; await SaveDay(day); }
private void AddBreak(DayVm day)
{
day.Breaks.Add(new BreakVm());
StateHasChanged();
}
private async Task RemoveBreak(DayVm day, int idx)
{ day.Breaks.RemoveAt(idx); await SaveDay(day); }
private async Task SaveDay(DayVm day)
{
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
BuildWeekLabels();
}
// ── Berechnungen ─────────────────────────────────────────────
private TimeSpan? GetOvertime(DayVm day) =>
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
private int GetProgressPercent(DayVm day)
{
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
}
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!day.NetWork.HasValue) return "#B0BEC5";
return "#4CAF50";
}
private IEnumerable<string> GetDayErrors(DayVm day)
{
if (day.Start.HasValue && day.End.HasValue && day.Start >= day.End)
yield return "Endzeit muss nach Beginn liegen.";
for (int i = 0; i < day.Breaks.Count; i++)
{
var b = day.Breaks[i];
if (b.Start.HasValue && b.End.HasValue && b.Start >= b.End)
yield return $"Pause {i + 1}: Ende muss nach Start liegen.";
if (day.Start.HasValue && b.Start.HasValue && b.Start < day.Start)
yield return $"Pause {i + 1} beginnt vor Arbeitsbeginn.";
if (day.End.HasValue && b.End.HasValue && b.End > day.End)
yield return $"Pause {i + 1} endet nach Arbeitsende.";
for (int j = i + 1; j < day.Breaks.Count; j++)
{
var c = day.Breaks[j];
if (b.Start.HasValue && b.End.HasValue && c.Start.HasValue && c.End.HasValue
&& b.Start < c.End && c.Start < b.End)
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
}
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
private static string FormatTs(TimeSpan ts, bool sign = false)
{
if (ts == TimeSpan.Zero && sign) return "±0:00";
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "") : (ts < TimeSpan.Zero ? "" : "");
var abs = ts.Duration();
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
}
// ── ViewModels ────────────────────────────────────────────────
private sealed class DayVm
{
public int Id { get; set; }
public int UserId { get; set; }
public DateOnly Date { get; set; }
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public List<BreakVm> Breaks { get; set; } = [];
public TimeSpan? GrossWork =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
public TimeSpan TotalBreakTime => Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
public static DayVm From(WorkDay? wd, DateOnly date, int userId) => new()
{
Id = wd?.Id ?? 0,
UserId = wd?.UserId ?? userId,
Date = date,
Start = wd?.StartTime?.ToTimeSpan(),
End = wd?.EndTime?.ToTimeSpan(),
Breaks = wd?.Breaks.Select(b => new BreakVm
{
Id = b.Id,
Start = b.StartTime?.ToTimeSpan(),
End = b.EndTime?.ToTimeSpan()
}).ToList() ?? []
};
public WorkDay ToWorkDay() => new()
{
Id = Id,
UserId = UserId,
Date = Date,
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
Breaks = Breaks.Select(b => new BreakEntry
{
Id = b.Id,
StartTime = b.Start.HasValue ? TimeOnly.FromTimeSpan(b.Start.Value) : null,
EndTime = b.End.HasValue ? TimeOnly.FromTimeSpan(b.End.Value) : null
}).ToList()
};
}
private sealed class BreakVm
{
public int Id { get; set; }
public TimeSpan? Start { get; set; }
public TimeSpan? End { get; set; }
public TimeSpan? Duration =>
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
}
}