WASM Mode activated

This commit is contained in:
MarcWieland
2026-06-08 16:24:51 +02:00
parent fe294e288a
commit 58e562adb1
118 changed files with 1038 additions and 470 deletions
@@ -0,0 +1,375 @@
@page "/urlaub-maximizer"
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Urlaubs-Maximizer Timetracker</PageTitle>
@if (_loading)
{
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
<MudProgressCircular Color="Color.Warning" Indeterminate="true" Size="Size.Large" />
<MudText Color="Color.Secondary">Berechne beste Urlaubskombinationen…</MudText>
</MudStack>
}
else
{
<MudStack Spacing="3">
@* ── Header ── *@
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 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="PrevYear" />
<MudStack Spacing="0" AlignItems="AlignItems.Center">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color:white" />
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
Urlaubs-Maximizer @_year
</MudText>
</MudStack>
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.75)">
@_subLabel
</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
@if (_year != DateTime.Today.Year)
{
<MudButton Variant="Variant.Text" Style="color:white"
OnClick="GoToCurrentYear" Size="Size.Small">Heute</MudButton>
}
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
Style="color:white" Size="Size.Large" OnClick="NextYear" />
</MudStack>
</MudStack>
</MudPaper>
@* ── Info-Legende ── *@
<MudPaper Elevation="1" Class="pa-3 rounded-xl">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Wrap="Wrap.Wrap">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#3F51B5;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstag (einzutragen)</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#009688;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertag</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#FF9800;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Bereits Urlaub</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<div style="width:16px;height:16px;border-radius:4px;background:#ECEFF1;border:1px solid #CFD8DC;"></div>
<MudText Typo="Typo.caption" Color="Color.Secondary">Wochenende / Frei</MudText>
</MudStack>
<MudDivider Vertical="true" FlexItem="true" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Noch <b>@_remainingDays</b> Urlaubstage verfügbar
</MudText>
</MudStack>
</MudPaper>
@if (!_holidays.Any())
{
<MudAlert Severity="Severity.Info" Class="rounded-xl">
Keine Feiertage für @_year geladen. Gehe zu
<b>Einstellungen → Feiertage</b> und klicke „Von API laden" für optimale Vorschläge.
</MudAlert>
}
@if (_remainingDays <= 0)
{
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
Style="font-size:4rem; color:#FFB74D;" Class="mb-3" />
<MudText Typo="Typo.h6">Alle Urlaubstage sind bereits eingetragen!</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
Du hast dein Urlaubskontingent für @_year vollständig verplant.
</MudText>
</MudPaper>
}
else if (_suggestions.Count == 0)
{
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.SearchOff"
Style="font-size:4rem; color:#CFD8DC;" Class="mb-3" />
<MudText Typo="Typo.h6" Color="Color.Secondary">Keine Vorschläge gefunden</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
Für das restliche Jahr @_year sind keine günstigen Brückentag-Kombinationen verfügbar.
</MudText>
</MudPaper>
}
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);
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudText Typo="Typo.overline" Style="font-weight:700; letter-spacing:2px; color:#E65100;">
MIT @group.Key.ToString().ToUpper() @dayWord.ToUpper()
</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
Style="color:#E65100; border-color:#E65100; height:20px; font-size:11px">
@group.Count() Vorschlag@(group.Count() == 1 ? "" : "schläge")
</MudChip>
</MudStack>
<MudGrid Spacing="3">
@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";
<MudItem xs="12" sm="6" lg="4">
<MudCard Elevation="3" Class="rounded-xl h-100"
Style="@($"border-top: 4px solid {effColor};")">
<MudCardContent Class="pa-4">
<MudStack Spacing="3">
@* Titel + Badge *@
<MudStack Row="true" AlignItems="AlignItems.Start"
Justify="Justify.SpaceBetween">
<MudStack Spacing="0">
<MudText Typo="Typo.h5" Style="font-weight:800; line-height:1.1">
@s.TotalFreeDays Tage frei
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
für @s.VacationDaysNeeded @dayWord
· +@s.BonusDays Bonustage
</MudText>
</MudStack>
<MudStack Spacing="0" AlignItems="AlignItems.End">
<MudText Typo="Typo.h6"
Style="@($"font-weight:800; color:{effColor}")">
@s.Efficiency.ToString("0.0")×
</MudText>
<MudText Typo="Typo.caption"
Style="@($"color:{effColor}; font-weight:600")">
@effLabel
</MudText>
</MudStack>
</MudStack>
@* Datum *@
<MudText Typo="Typo.body2" Color="Color.Secondary">
@s.SpanStart.ToString("ddd, dd. MMM", _deCulture)
&nbsp;&nbsp;
@s.SpanEnd.ToString("ddd, dd. MMM yyyy", _deCulture)
</MudText>
@* Tages-Kacheln *@
<div style="display:flex; flex-wrap:wrap; gap:4px;">
@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";
<MudTooltip Text="@tooltip">
<div style="@($"display:flex;flex-direction:column;align-items:center;justify-content:center;width:36px;height:44px;border-radius:8px;background:{bg};color:{fg};")">
<span style="font-size:0.55rem;font-weight:700;letter-spacing:0.5px;line-height:1.2">
@d.ToString("ddd", _deCulture).Substring(0, 2).ToUpper()
</span>
<span style="font-size:0.85rem;font-weight:700;line-height:1.2">
@d.Day
</span>
</div>
</MudTooltip>
}
</div>
</MudStack>
</MudCardContent>
<MudCardActions Class="pa-3 pt-0">
<MudButton Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.BeachAccess"
Style="@($"background:{effColor}; color:white;")"
Size="Size.Small" FullWidth="true"
OnClick="@(async () => await TakeSuggestion(s))">
Urlaub eintragen
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
</MudStack>
}
}
</MudStack>
}
@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<DateOnly, string> _holidays = [];
private HashSet<DateOnly> _vacationSet = [];
private int _remainingDays;
private List<Suggestion> _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 holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
_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<DateOnly> VacationDaysToTake,
int VacationDaysNeeded,
int TotalFreeDays,
double Efficiency)
{
public int BonusDays => TotalFreeDays - VacationDaysNeeded;
}
private List<Suggestion> 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<DateOnly> DaysInSpan(Suggestion s)
{
for (var d = s.SpanStart; d <= s.SpanEnd; d = d.AddDays(1))
yield return d;
}
}