@page "/"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
KW @_kw – Wochenübersicht – Timetracker
@if (_loading)
{
Lade Wochendaten…
}
else
{
@* ── Wochen-Header ── *@
@* ── 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 ── *@
@day.Date.ToString("dddd", _deCulture)
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
@if (!string.IsNullOrEmpty(holidayName))
{
@holidayName
}
else
{
Kein Arbeitstag
}
}
else
{
@* ── Arbeitstag: vollständige Karte ── *@
@day.Date.ToString("dddd", _deCulture)
@if (isToday)
{
HEUTE
}
@if (!string.IsNullOrEmpty(holidayName))
{
@holidayName
}
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
@if (overtime.HasValue)
{
@FormatTs(overtime.Value, sign: true)
}
else if (!hasData)
{
@if (day.IsVacation)
{
URLAUB
Urlaub entfernen
}
else
{
Nicht erfasst
Als Urlaub eintragen
}
}
@* ── Zeit-Eingaben + Statistik ── *@
@if (day.GrossWork.HasValue)
{
Brutto
@FormatTs(day.GrossWork.Value)
−
Pausen
@FormatTs(GetEffectiveBreakTime(day))
=
Netto
@FormatTs(GetEffectiveNetWork(day)!.Value)
Soll
@FormatTs(TimeSpan.FromHours(GetDailyTargetHours(day)))
Soll-Ende
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
}
else if (day.Start.HasValue)
{
Früheste Gehenszeit
@FormatTimeSpan(CalculateEarliestDeparture(day)) Uhr
(Bei Soll: @FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))h + @((int)GetRequiredBreakForTarget(day).TotalMinutes) Min. Pause)
}
@* ── Fortschrittsbalken ── *@
@if (progressPct >= 0)
{
}
@* ── Pausen-Sektion ── *@
@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);
Pause @(idx + 1)
–
@if (brk.Duration.HasValue)
{
@FormatTs(brk.Duration.Value)
}
}
Pause hinzufügen
@* ── Validierungsfehler ── *@
@if (errors.Count > 0)
{
@foreach (var err in errors)
{
@err
}
}
}
}
@* ── Wochensumme ── *@
Wochensumme
Brutto
@FormatTs(WeekGross)
Pausen
@FormatTs(WeekBreaks)
Netto
@FormatTs(WeekNet)
Gleitzeit
@FormatTs(WeekOvertime, sign: true)
@* ── Gleitzeitkonto ── *@
= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
Gleitzeitkonto
Gesamtsaldo aller erfassten Arbeitstage
= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" />
= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
@FormatTs(_totalOvertime, sign: true)
@if (_showOnboarding)
{
}
}
@code {
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
[PersistentState(AllowUpdates = true)]
public AppSettings? PersistentSettings { get; set; }
[PersistentState(AllowUpdates = true)]
public List? PersistentDays { get; set; }
[PersistentState(AllowUpdates = true)]
public TimeSpan? PersistentTotalOvertime { get; set; }
[PersistentState(AllowUpdates = true)]
public Dictionary? PersistentHolidays { get; set; }
[PersistentState(AllowUpdates = true)]
public int? PersistentHolidayYear { get; set; }
[PersistentState(AllowUpdates = true)]
public List? PersistentVacations { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekLabel { get; set; }
[PersistentState(AllowUpdates = true)]
public string? PersistentWeekSubLabel { get; set; }
private bool _loading = true;
private int _userId;
private DateOnly _monday;
private List _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 _holidays = [];
private int _holidayYear = -1;
private bool _showOnboarding;
private List _vacations = [];
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
protected override async Task OnInitializedAsync()
{
if (PersistentSettings != null && PersistentDays != null && PersistentTotalOvertime != null)
{
_settings = PersistentSettings;
_days = PersistentDays;
_totalOvertime = PersistentTotalOvertime.Value;
_holidays = PersistentHolidays ?? [];
_holidayYear = PersistentHolidayYear ?? -1;
_vacations = PersistentVacations ?? [];
_weekLabel = PersistentWeekLabel ?? "";
_weekSubLabel = PersistentWeekSubLabel ?? "";
_loading = false;
}
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));
if (PersistentSettings == null || PersistentDays == null || PersistentTotalOvertime == null)
{
_settings = await TrackerService.GetSettingsAsync(_userId);
var loadWeekTask = LoadWeek();
var overtimeTask = TrackerService.GetTotalOvertimeAsync(_userId, _settings);
await Task.WhenAll(loadWeekTask, overtimeTask);
_totalOvertime = await overtimeTask;
PersistentSettings = _settings;
PersistentDays = _days;
PersistentTotalOvertime = _totalOvertime;
PersistentHolidays = _holidays;
PersistentHolidayYear = _holidayYear;
PersistentVacations = _vacations;
PersistentWeekLabel = _weekLabel;
PersistentWeekSubLabel = _weekSubLabel;
_loading = false;
}
try
{
var showOnb = await JSRuntime.InvokeAsync("localStorage.getItem", "showOnboarding");
_showOnboarding = showOnb == "true";
}
catch
{
// Ignored during prerendering/SSR
}
}
private async Task HandleOnboardingFinished()
{
_showOnboarding = false;
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "false");
}
private async Task LoadWeek()
{
Task> holidaysTask;
if (_monday.Year != _holidayYear)
{
holidaysTask = HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
}
else
{
holidaysTask = Task.FromResult(new List());
}
var dbDaysTask = TrackerService.GetWeekAsync(_userId, _monday);
var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _monday.Year);
await Task.WhenAll(holidaysTask, dbDaysTask, vacationsTask);
if (_monday.Year != _holidayYear)
{
var list = await holidaysTask;
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
_holidayYear = _monday.Year;
}
_vacations = await vacationsTask;
var dbDays = await dbDaysTask;
_days = Enumerable.Range(0, 7).Select(i =>
{
var date = _monday.AddDays(i);
var dayVm = DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
dayVm.IsVacation = _vacations.Any(v => v.Date == date);
return dayVm;
}).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 void ExportWeekExcel()
{
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
}
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 GetEffectiveBreakTime(DayVm day)
{
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
if (!day.Start.HasValue || !day.End.HasValue)
return enteredBreak;
var gross = day.End.Value - day.Start.Value;
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, _settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
return enteredBreak > minBreak ? enteredBreak : minBreak;
}
private TimeSpan? GetEffectiveNetWork(DayVm day)
{
if (!day.GrossWork.HasValue) return null;
return day.GrossWork.Value - GetEffectiveBreakTime(day);
}
private double GetDailyTargetHours(DayVm day)
{
var isHoliday = _holidays.ContainsKey(day.Date);
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || day.IsVacation)
{
return 0.0;
}
return _settings.DailyTargetHours;
}
private TimeSpan? GetOvertime(DayVm day)
{
var net = GetEffectiveNetWork(day);
return net.HasValue ? net.Value - TimeSpan.FromHours(GetDailyTargetHours(day)) : null;
}
private int GetProgressPercent(DayVm day)
{
var net = GetEffectiveNetWork(day);
var target = GetDailyTargetHours(day);
if (!net.HasValue || target <= 0) return -1;
return (int)(net.Value.TotalHours / target * 100);
}
private string GetBorderColor(DayVm day, bool isWorkDay, List errors)
{
if (!isWorkDay) return "#CFD8DC";
if (errors.Count > 0) return "#EF5350";
if (day.IsVacation) return "#0EA5E9"; // Cool blue for vacation
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
if (!GetEffectiveNetWork(day).HasValue) return "#B0BEC5";
return "#4CAF50";
}
private IEnumerable 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.";
}
}
var net = GetEffectiveNetWork(day);
if (net.HasValue && net.Value.TotalHours > 10.0)
{
yield return "Die tägliche Arbeitszeit überschreitet das gesetzliche Maximum von 10 Stunden!";
}
}
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + GetEffectiveBreakTime(d));
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetEffectiveNetWork(d) ?? 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}";
}
private TimeSpan GetRequiredBreakForTarget(DayVm day)
{
var targetNet = _settings.DailyTargetHours;
var minMinutes = targetNet >= 8.5 ? 45 : 30;
var requiredBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, _settings.MinimumBreakMinutes));
var enteredBreak = day.Breaks
.Where(b => b.Duration.HasValue)
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
return enteredBreak > requiredBreak ? enteredBreak : requiredBreak;
}
private TimeSpan CalculateEarliestDeparture(DayVm day)
{
if (!day.Start.HasValue) return TimeSpan.Zero;
var target = TimeSpan.FromHours(_settings.DailyTargetHours);
var finalBreak = GetRequiredBreakForTarget(day);
return day.Start.Value + target + finalBreak;
}
private async Task MarkAsVacation(DayVm day)
{
var vacation = new VacationDay
{
UserId = _userId,
Date = day.Date,
Note = "Über Wochenübersicht eingetragen"
};
await TrackerService.AddVacationDayAsync(vacation);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"{day.Date:dd.MM.yyyy} als Urlaub eingetragen.", Severity.Success);
}
private async Task RemoveVacation(DayVm day)
{
var item = _vacations.FirstOrDefault(v => v.Date == day.Date);
if (item != null)
{
await TrackerService.RemoveVacationDayAsync(_userId, item.Id);
await LoadWeek();
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
Snackbar.Add($"Urlaub für {day.Date:dd.MM.yyyy} entfernt.", Severity.Info);
}
}
private static string FormatTimeSpan(TimeSpan? ts)
{
if (!ts.HasValue) return "";
var hours = ((int)ts.Value.TotalHours) % 24;
return $"{hours:D2}:{ts.Value.Minutes:D2}";
}
// ── ViewModels ────────────────────────────────────────────────
public 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 Breaks { get; set; } = [];
public bool IsVacation { 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()
};
}
public 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;
}
}