using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.ViewModels.ClassTeacher; /// Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im /// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML /// (Styles/SemanticBrushes.axaml), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt — /// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen. public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger } public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday, string? AbsenceTooltip, bool HasRecentClassRegisterEntry) { public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; } public bool HasClassRegisterToday { get; init; } public bool IsLate => TodayAbsence is { } a && (a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason)); public bool IsUnexcused => TodayAbsence?.Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true; public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry; public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 : HasRecentClassRegisterEntry ? 3 : 4; public string Initials { get { var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries); return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0]))); } } public string StatusText { get { if (TodayAbsence is null) return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit"; var core = IsLate ? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet" : TodayAbsence.CountsAsFullDay ? "Ganzer Fehltag" : $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}"; return IsUnexcused ? $"{core} · Unentschuldigt" : core; } } public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger : HasAbsenceToday ? ClassTeacherStatusKind.Warning : HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok; public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger; public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning; public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info; public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" : HasRecentClassRegisterEntry ? "Eintrag diese Woche" : ""; /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// sagt für Zeugnis/Attestpflicht wenig aus). zählt nur /// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag. public int YearAbsenceDayCount { get; init; } public int YearUnexcusedDayCount { get; init; } public int SchoolDaysElapsed { get; init; } public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0; public int YearAbsenceRatePercent => SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); public string YearSummaryLabel => HasYearSummary ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : ""; public string? YearSummaryTooltip => !HasYearSummary ? null : $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" + (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : ""); private static bool IsLateReason(string reason) => reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, IReadOnlyList recentClassRegisterEntries, DateOnly? today = null, IReadOnlyList? yearAbsences = null, int schoolDaysElapsed = 0) { var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First()); var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName)) .ToDictionary(g => g.Key, g => g.First()); var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) .ToDictionary(g => g.Key, g => g.ToList()); var yearRows = yearAbsences ?? []; var yearByKey = yearRows.Where(a => a.ExternKey is not null) .GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList()); var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName)) .ToDictionary(g => g.Key, g => g.ToList()); return students.Select(student => { var nameKey = UntisNameMatching.NameKey(student.DisplayName); var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) ?? absenceByName.GetValueOrDefault(nameKey); var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? []; var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null) ?? yearByName.GetValueOrDefault(nameKey) ?? []; return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", registerEntries.Count > 0) { TodayAbsence = absence, HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), YearAbsenceDayCount = yearEntries.Count, YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), SchoolDaysElapsed = schoolDaysElapsed, }; }) .OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList(); } private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); } /// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige /// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl /// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei /// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang /// vor Verspätet, wie bei ) und ihre Breiten /// summieren sich exakt zu . public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateExcusedCount, int ExcusedCount, double UnexcusedBarWidth, double LateExcusedBarWidth, double ExcusedBarWidth) { public double TotalBarWidth => UnexcusedBarWidth + LateExcusedBarWidth + ExcusedBarWidth; } public sealed record ClassTeacherPatternNotice(string StudentName, string Message, ClassTeacherStatusKind Kind) { public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger; public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning; public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info; } public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; private readonly UntisReportCacheService _cache; private readonly SchoolYearService _schoolYear; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; public ObservableCollection PrimaryRoster { get; } = []; public ObservableCollection SecondaryRoster { get; } = []; public ObservableCollection TrendDays { get; } = []; public ObservableCollection PatternNotices { get; } = []; [ObservableProperty] private string? _homeroomClassName; [ObservableProperty] private int _activeTabIndex; [ObservableProperty] private string _status = ""; [ObservableProperty] private bool _busy; [ObservableProperty] private string _searchText = ""; [ObservableProperty] private int _selectedRosterFilter; [ObservableProperty] private string _primarySectionTitle = "Heute auffällig"; [ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen"; [ObservableProperty] private int _studentCount; [ObservableProperty] private int _todayAlertCount; [ObservableProperty] private int _todayUnexcusedCount; [ObservableProperty] private int _recentClassRegisterCount; [ObservableProperty] private int _presentCount; [ObservableProperty] private int _lateCount; [ObservableProperty] private int _excusedAbsenceCount; [ObservableProperty] private int _unexcusedAbsenceCount; [ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert"; public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName); public bool HasPrimaryRoster => PrimaryRoster.Count > 0; public bool HasSecondaryRoster => SecondaryRoster.Count > 0; public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0; public bool HasPatternNotices => PatternNotices.Count > 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool AllFilterSelected => SelectedRosterFilter == 2; public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount); public int LatePercent => Percent(LateCount); public int PresentPercent => Percent(PresentCount); public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount); public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount); public Func? OnNavigateToSettings { get; set; } public Func? OnNavigateToWorkload { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, UntisReportCacheService cache, SchoolYearService schoolYear, ClassTeacherDetailsViewModel detailsTab) { _settings = settings; _cache = cache; _schoolYear = schoolYear; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnSearchTextChanged(string value) => ApplyRosterFilter(); partial void OnSelectedRosterFilterChanged(int value) { OnPropertyChanged(nameof(AlertsFilterSelected)); OnPropertyChanged(nameof(ClassRegisterFilterSelected)); OnPropertyChanged(nameof(AllFilterSelected)); ApplyRosterFilter(); } [RelayCommand] private async Task Load() { HomeroomClassName = _settings.HomeroomClassName; ActiveTabIndex = 0; Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear(); if (!HomeroomClassConfigured) { Status = "Noch keine Klasse ausgewählt."; NotifyRosterState(); return; } var className = HomeroomClassName!; DetailsTab.Initialize(className); Busy = true; NotifyRosterState(); try { var today = DateOnly.FromDateTime(DateTime.Today); var sevenDayStart = today.AddDays(-6); var trendDays = LastSchoolDays(today, 7); // Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die // Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf. // UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) — // teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht. var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); var studentsTask = _cache.GetStudentRosterAsync(className); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList(); var schoolDaysElapsed = CountWeekdays(yearStart, today); foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row); StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused); LateCount = Roster.Count(r => r.IsLate); PresentCount = Roster.Count(r => !r.HasAbsenceToday); ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused); RecentClassRegisterCount = classRegisterTask.Result.Count; BuildTrend(absenceDaysYear, trendDays); BuildPatternNotices(weekAbsenceDays); LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}"; Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig"; ApplyRosterFilter(); NotifySummary(); } catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); } finally { Busy = false; NotifyRosterState(); } } [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; [RelayCommand] private void OpenClassRegister() { DetailsTab.StudentFilter = ""; ActiveTabIndex = 1; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private void OpenAbsences() { DetailsTab.StudentFilter = ""; ActiveTabIndex = 2; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private void ShowDetailsForStudent(ClassTeacherRosterRow? row) { if (row is null) return; DetailsTab.StudentFilter = row.StudentName; ActiveTabIndex = row.HasAbsenceToday ? 2 : 1; DetailsTab.LoadCommand.Execute(null); } [RelayCommand] private async Task GoToSettings() { if (OnNavigateToSettings is not null) await OnNavigateToSettings(); } [RelayCommand] private async Task GoToWorkload() { if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); } private void ApplyRosterFilter() { PrimaryRoster.Clear(); SecondaryRoster.Clear(); var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) || r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase)); if (SelectedRosterFilter == 0) { PrimarySectionTitle = "Heute auffällig"; foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row); } else if (SelectedRosterFilter == 1) { PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage"; foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row); } else { PrimarySectionTitle = "Heute auffällig"; SecondarySectionTitle = "Weitere Schüler*innen"; foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row); foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row); } NotifyRosterState(); } /// Letzte statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von /// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne /// Ferienkalender — siehe . private void BuildTrend(IReadOnlyList absenceDays, IReadOnlyList days) { var today = days[^1]; var counts = days.Select(date => { var rows = absenceDays.Where(r => r.Date == date).ToList(); var unexcused = rows.Count(r => r.IsUnexcused); var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate); var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate); return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused, Total: unexcused + lateExcused + excused); }).ToList(); var max = Math.Max(1, counts.Max(c => c.Total)); foreach (var day in counts) TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"), day.Total, day.Unexcused, day.LateExcused, day.Excused, 112d * day.Unexcused / max, 112d * day.LateExcused / max, 112d * day.Excused / max)); } /// Letzte Werktage bis einschließlich , ohne /// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine /// Schulferien, nur Wochenenden. private static IReadOnlyList LastSchoolDays(DateOnly end, int count) { var days = new List(); for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1)) if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) days.Add(cursor); days.Reverse(); return days; } /// Nenner für — Werktage zwischen /// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender. private static int CountWeekdays(DateOnly start, DateOnly end) { if (end < start) return 0; var count = 0; for (var d = start; d <= end; d = d.AddDays(1)) if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++; return count; } private void BuildPatternNotices(IReadOnlyList absenceDays) { foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) { var rows = group.ToList(); var displayName = Roster.FirstOrDefault(row => UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName; var lateDays = rows.Count(row => row.IsLate); var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate); if (unexcusedDays >= 2) PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger)); else if (lateDays >= 2) PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning)); } OnPropertyChanged(nameof(HasPatternNotices)); } private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount); private void NotifySummary() { OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent)); OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent)); OnPropertyChanged(nameof(UnexcusedAbsencePercent)); } private void NotifyRosterState() { OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster)); OnPropertyChanged(nameof(HasNoFilterResults)); } }