@page "/urlaub-maximizer" @rendermode InteractiveWebAssembly @attribute [Authorize] @inject ITimetrackerService TrackerService @inject IHolidayService HolidayService @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthStateProvider Urlaubs-Maximizer – Timetracker @if (_loading) { Berechne beste Urlaubskombinationen… } else { @* ── Header ── *@ Urlaubs-Maximizer @_year @_subLabel @if (_year != DateTime.Today.Year) { Heute } @* ── Info-Legende ── *@
Urlaubstag (einzutragen)
Feiertag
Bereits Urlaub
Wochenende / Frei
Noch @_remainingDays Urlaubstage verfügbar
@if (!_holidays.Any()) { Keine Feiertage für @_year geladen. Gehe zu Einstellungen → Feiertage und klicke „Von API laden" für optimale Vorschläge. } @if (_remainingDays <= 0) { Alle Urlaubstage sind bereits eingetragen! Du hast dein Urlaubskontingent für @_year vollständig verplant. } else if (_suggestions.Count == 0) { Keine Vorschläge gefunden Für das restliche Jahr @_year sind keine günstigen Brückentag-Kombinationen verfügbar. } else { @* ── Vorschläge gruppiert nach Urlaubstagen ── *@ @foreach (var group in _suggestions.GroupBy(s => s.VacationDaysNeeded).OrderBy(g => g.Key)) { var dayWord = group.Key == 1 ? "Urlaubstag" : "Urlaubstagen"; var bestEff = group.Max(s => s.Efficiency); MIT @group.Key.ToString().ToUpper() @dayWord.ToUpper() @group.Count() Vorschlag@(group.Count() == 1 ? "" : "schläge") @foreach (var s in group.OrderByDescending(x => x.Efficiency)) { var effColor = s.Efficiency >= 4.0 ? "#4CAF50" : s.Efficiency >= 3.0 ? "#2196F3" : s.Efficiency >= 2.0 ? "#FF9800" : "#9E9E9E"; var effLabel = s.Efficiency >= 4.0 ? "Jackpot" : s.Efficiency >= 3.0 ? "Sehr gut" : s.Efficiency >= 2.0 ? "Gut" : "OK"; @* Titel + Badge *@ @s.TotalFreeDays Tage frei für @s.VacationDaysNeeded @dayWord · +@s.BonusDays Bonustage @s.Efficiency.ToString("0.0")× @effLabel @* Datum *@ @s.SpanStart.ToString("ddd, dd. MMM", _deCulture)  –  @s.SpanEnd.ToString("ddd, dd. MMM yyyy", _deCulture) @* Tages-Kacheln *@
@foreach (var d in DaysInSpan(s)) { var isVac = s.VacationDaysToTake.Contains(d); var isHol = _holidays.ContainsKey(d); var isTaken = _vacationSet.Contains(d); var bg = isVac ? "#3F51B5" : isHol ? "#009688" : isTaken ? "#FF9800" : "#ECEFF1"; var fg = (isVac || isHol || isTaken) ? "white" : "#607D8B"; var tooltip = isVac ? "Urlaubstag eintragen" : isHol ? (_holidays.GetValueOrDefault(d) ?? "Feiertag") : isTaken ? "Bereits Urlaub" : "Wochenende";
@d.ToString("ddd", _deCulture).Substring(0, 2).ToUpper() @d.Day
}
Urlaub eintragen
}
} }
} @code { private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); private bool _loading = true; private int _year = DateTime.Today.Year; private AppSettings _settings = new(); private Dictionary _holidays = []; private HashSet _vacationSet = []; private int _remainingDays; private List _suggestions = []; private string _subLabel = ""; private int _userId; protected override async Task OnInitializedAsync() { var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier); if (claim == null) return; _userId = int.Parse(claim.Value); _settings = await TrackerService.GetSettingsAsync(_userId); await LoadYear(); _loading = false; } private async Task LoadYear() { var holidaysTask = HolidayService.GetHolidaysAsync(_year, _settings.GermanState); var vacationsTask = TrackerService.GetVacationDaysAsync(_userId, _year); await Task.WhenAll(holidaysTask, vacationsTask); var holidays = await holidaysTask; var vacations = await vacationsTask; _holidays = holidays.ToDictionary(h => h.Date, h => h.Name); _vacationSet = vacations.Select(v => v.Date).ToHashSet(); _remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count); _suggestions = ComputeSuggestions(); var count = _suggestions.Count; var bestEff = count > 0 ? _suggestions.Max(s => s.Efficiency) : 0; _subLabel = count == 0 ? "Keine Vorschläge verfügbar" : $"{count} Kombination{(count == 1 ? "" : "en")} · Beste: {bestEff:0.0}× Effizienz"; } private async Task PrevYear() { _year--; await LoadYear(); } private async Task NextYear() { _year++; await LoadYear(); } private async Task GoToCurrentYear() { _year = DateTime.Today.Year; await LoadYear(); } private async Task TakeSuggestion(Suggestion s) { foreach (var d in s.VacationDaysToTake.Where(d => !_vacationSet.Contains(d))) await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = d, Note = "Urlaubs-Maximizer" }); await LoadYear(); var word = s.VacationDaysNeeded == 1 ? "Urlaubstag" : "Urlaubstage"; Snackbar.Add($"{s.VacationDaysNeeded} {word} eingetragen – {s.TotalFreeDays} Tage frei!", Severity.Success); } // ── Algorithmus ────────────────────────────────────────────────────── private enum DayKind { Free, WorkAvailable, WorkTaken } private sealed record Suggestion( DateOnly SpanStart, DateOnly SpanEnd, List VacationDaysToTake, int VacationDaysNeeded, int TotalFreeDays, double Efficiency) { public int BonusDays => TotalFreeDays - VacationDaysNeeded; } private List ComputeSuggestions() { var startOfYear = new DateOnly(_year, 1, 1); var endOfYear = new DateOnly(_year, 12, 31); var today = DateOnly.FromDateTime(DateTime.Today); int n = endOfYear.DayNumber - startOfYear.DayNumber + 1; // Classify each day var kinds = new DayKind[n]; for (int i = 0; i < n; i++) { var d = startOfYear.AddDays(i); if (!_settings.IsWorkDay(d.DayOfWeek) || _holidays.ContainsKey(d)) kinds[i] = DayKind.Free; else if (_vacationSet.Contains(d)) kinds[i] = DayKind.WorkTaken; else kinds[i] = DayKind.WorkAvailable; } // Prefix sum: count of WorkAvailable days var psum = new int[n + 1]; for (int i = 0; i < n; i++) psum[i + 1] = psum[i] + (kinds[i] == DayKind.WorkAvailable ? 1 : 0); int CountAvail(int a, int b) => psum[b + 1] - psum[a]; int maxVac = Math.Min(_remainingDays, 5); if (maxVac <= 0) return []; // For each window [ws, we] of contiguous days, compute best span var best = new Dictionary<(int, int), Suggestion>(); for (int ws = 0; ws < n; ws++) { if (kinds[ws] != DayKind.WorkAvailable) continue; for (int we = ws; we < n; we++) { int vac = CountAvail(ws, we); if (vac > maxVac) break; if (vac == 0) continue; // Extend span outward through non-WorkAvailable days int ss = ws, se = we; while (ss > 0 && kinds[ss - 1] != DayKind.WorkAvailable) ss--; while (se < n - 1 && kinds[se + 1] != DayKind.WorkAvailable) se++; int total = se - ss + 1; if (total <= vac) continue; // No bonus days → skip if (startOfYear.AddDays(se) < today) continue; // Fully in the past → skip double eff = (double)total / vac; var key = (ss, se); var vacDays = Enumerable.Range(ws, we - ws + 1) .Where(i => kinds[i] == DayKind.WorkAvailable) .Select(i => startOfYear.AddDays(i)) .ToList(); var sug = new Suggestion( startOfYear.AddDays(ss), startOfYear.AddDays(se), vacDays, vac, total, eff); if (!best.TryGetValue(key, out var existing) || vac < existing.VacationDaysNeeded) best[key] = sug; } } // Group by vac days needed, keep top 4 per group by efficiency return [.. best.Values .GroupBy(s => s.VacationDaysNeeded) .OrderBy(g => g.Key) .SelectMany(g => g .OrderByDescending(s => s.Efficiency) .ThenByDescending(s => s.TotalFreeDays) .Take(4))]; } private static IEnumerable DaysInSpan(Suggestion s) { for (var d = s.SpanStart; d <= s.SpanEnd; d = d.AddDays(1)) yield return d; } }