using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; 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" : ""; /// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des /// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr /// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste). public string StatusTextWithGlyph => StatusKind switch { ClassTeacherStatusKind.Danger => $"✕ {StatusText}", ClassTeacherStatusKind.Warning => $"△ {StatusText}", _ => StatusText, }; /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// sagt für Zeugnis/Attestpflicht wenig aus). zählt Werktage /// abzüglich der über WebUntis geladenen Ferien (). public int YearAbsenceDayCount { get; init; } public int YearUnexcusedDayCount { get; init; } public int SchoolDaysElapsed { get; init; } /// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die /// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat /// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet. public int HolidayWeekdaysExcluded { get; init; } /// Angenommener erster Unterrichtstag (), /// aus demselben Nachvollziehbarkeits-Grund wie im Tooltip /// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md), /// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin. public DateOnly TermStart { 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 seit {TermStart:dd.MM.} mit Fehlzeit" + (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") + (HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); 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, int holidayWeekdaysExcluded = 0, DateOnly termStart = default) { 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, HolidayWeekdaysExcluded = holidayWeekdaysExcluded, TermStart = termStart, }; }) .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 ). public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount, int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction, double ExcusedFraction) { /// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten /// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart /// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML /// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding /// () mit der tatsächlich gerenderten Breite /// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere /// Breite bekommt. Grid.ColumnDefinitions ließ sich dafür nicht binden (kein Setter bei /// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten. public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction; public string DetailTooltip => $"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt"; } public sealed record ClassTeacherPatternNotice(string StudentName, string Message, ClassTeacherStatusKind Kind, bool CanCreateReminder = false) { public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger; public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning; public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info; } /// Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird — /// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend, /// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin /// für die Jahresfehlquote geladenen Daten (), /// kein zusätzlicher Abruf. public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen) { /// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen" /// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische /// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche /// Regelung kann je nach Bundesland/Schulordnung abweichen. public const int DeadlineDays = 3; public string DateLabel => Date.ToString("dd.MM."); public bool IsOverdue => DaysOpen > DeadlineDays; public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt"; public static IReadOnlyList Build( IReadOnlyList absenceDays, DateOnly today) => // Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine // "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen // laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices). absenceDays.Where(a => a.IsUnexcused && !a.IsLate) .Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date, today.DayNumber - a.Date.DayNumber)) .OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName) .ToList(); } public partial class ClassTeacherOverviewViewModel : ObservableObject { private readonly WebUntisSettingsService _settings; private readonly WebUntisIntegrationService _untis; private readonly UntisReportCacheService _cache; private readonly SchoolYearService _schoolYear; private readonly IWorkTaskRepository _workTasks; private readonly IStudentRepository _students; private readonly IParticipationRepository _participation; private readonly IParticipationSessionRepository _participationSessions; private readonly AppLogger? _logger; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; public ObservableCollection PrimaryRoster { get; } = []; public ObservableCollection SecondaryRoster { get; } = []; public ObservableCollection TrendDays { get; } = []; public ObservableCollection PatternNotices { get; } = []; public ObservableCollection OpenExcuses { 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"; [ObservableProperty] private int _openExcuseOverflowCount; 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 HasOpenExcuses => OpenExcuses.Count > 0; public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 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); /// Quick-Win: wurde berechnet, aber nirgends gebunden — /// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar. public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %"; /// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken /// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer /// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe /// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in . public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %"; public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %"; public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %"; public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %"; /// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding /// mit der gerenderten Track-Breite, siehe /// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich /// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence. public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount; public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount; public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount; public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount; public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " + $"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt"; public Func? OnNavigateToSettings { get; set; } public Func? OnNavigateToWorkload { get; set; } public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, IStudentRepository students, IParticipationRepository participation, IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, AppLogger? logger = null) { _settings = settings; _untis = untis; _cache = cache; _schoolYear = schoolYear; _workTasks = workTasks; _students = students; _participation = participation; _participationSessions = participationSessions; _logger = logger; DetailsTab = detailsTab; } partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow)); 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(); OpenExcuses.Clear(); OpenExcuseOverflowCount = 0; 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); var holidaysTask = GetHolidaysAsync(); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); var termStart = EstimateTermStart(absenceDaysYear, yearStart); var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed, holidayWeekdaysExcluded, termStart)) 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(absenceDaysYear, sevenDayStart); BuildWeekdayPatternNotices(absenceDaysYear); BuildAttendanceParticipationNotices(); BuildOpenExcuses(absenceDaysYear, today); 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, (double)day.Unexcused / max, (double)day.LateExcused / max, (double)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; } /// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag /// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der /// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August /// () als "Schultag" mit — die Sommerferien /// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht /// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis /// kennt einen Teil des echten Ferienkalenders (getHolidays-Bericht, hier zwischen den /// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/ /// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert /// getHolidays nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre /// zurück kein einziger Sommerferien-Eintrag, siehe ) — sie /// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7. /// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin /// nötig, das den Startpunkt selbst korrigiert statt Tage /// innerhalb des Zeitraums abzuziehen. public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList holidays) { 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) && !holidays.Any(h => d >= h.Start && d <= h.End)) count++; return count; } /// Ergänzt um genau die Lücke, die WebUntis' echter /// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem /// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten /// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem /// Unterricht entstehen) — Rückfall auf , wenn noch keine /// Fehlzeiten vorliegen (dann bleibt /// ohnehin ausgeblendet). public static DateOnly EstimateTermStart(IReadOnlyList absenceDaysYear, DateOnly fallback) => absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback; /// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die /// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik /// aus , sondern ein einfacher tagesgenauer Cache über /// — kein zusätzliches LiteDB-Repository nötig). Schlägt /// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand /// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie /// erfolgreich abgerufen worden, bleibt die Liste leer und /// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug). private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1); private async Task> GetHolidaysAsync() { var cached = _settings.GetCachedHolidays(); if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt && DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval) return cached; try { var fresh = (await _untis.GetHolidaysAsync()) .Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate))) .ToList(); _settings.SetCachedHolidays(fresh, DateTime.UtcNow); _logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " + string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}"))); return fresh; } catch (WebUntisIntegrationException ex) { _logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex); return cached ?? []; } } private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd"); private const int LateYearEscalationThreshold = 5; private void BuildPatternNotices(IReadOnlyList absenceDaysYear, DateOnly sevenDayStart) { var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName)) .ToDictionary(g => g.Key, g => g.First().StudentName); foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart)) PatternNotices.Add(notice); OnPropertyChanged(nameof(HasPatternNotices)); } /// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe ) /// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt /// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster /// (..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe /// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option /// (). Reine, ohne ViewModel-Zustand /// testbare Kernlogik (gleiches Muster wie ). public static IReadOnlyList DetectLatePatterns( IReadOnlyList absenceDaysYear, IReadOnlyDictionary displayNamesByKey, DateOnly sevenDayStart, int lateYearEscalationThreshold = LateYearEscalationThreshold) { var notices = new List(); foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) { var rows = group.ToList(); var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName; var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList(); var lateDaysWeek = weekRows.Count(row => row.IsLate); var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate); var lateDaysYear = rows.Count(row => row.IsLate); if (unexcusedDaysWeek >= 2) notices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger)); else if (lateDaysYear >= lateYearEscalationThreshold) notices.Add(new ClassTeacherPatternNotice(displayName, $"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen", ClassTeacherStatusKind.Danger, CanCreateReminder: true)); else if (lateDaysWeek >= 2) notices.Add(new ClassTeacherPatternNotice(displayName, $"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning)); } return notices; } /// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten /// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren /// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares /// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7 /// Tagen. Wer schon eine Notiz aus hat, wird hier /// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt. private void BuildWeekdayPatternNotices(IReadOnlyList absenceDays) { var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName)) .ToDictionary(g => g.Key, g => g.First().StudentName); var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet(); foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted)) PatternNotices.Add(notice); OnPropertyChanged(nameof(HasPatternNotices)); } /// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie /// ). verhindert, /// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten; /// verlangt eine deutliche Häufung, nicht nur eine leichte /// Mehrheit. public static IReadOnlyList DetectWeekdayPatterns( IReadOnlyList absenceDays, IReadOnlyDictionary displayNamesByKey, ISet excludeDisplayNames, int minSampleSize = 3, double clusterThreshold = 0.6) { var notices = new List(); foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName))) { var rows = group.ToList(); if (rows.Count < minSampleSize) continue; var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName; if (excludeDisplayNames.Contains(displayName)) continue; var top = rows.GroupBy(r => r.Date.DayOfWeek) .Select(g => (Day: g.Key, Count: g.Count())) .OrderByDescending(g => g.Count).First(); if ((double)top.Count / rows.Count < clusterThreshold) continue; notices.Add(new ClassTeacherPatternNotice(displayName, $"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}", ClassTeacherStatusKind.Info)); } return notices; } /// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das /// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote /// () mit dem Mitarbeit-Trend aus /// dem Dokumentations-/Mitarbeitsmodul (, gruppen- /// übergreifend über GetByStudent, gleiches Muster wie die Fehlzeitenbilanz in /// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote /// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche /// simple Heuristik wie ParticipationGradeRow.ComputeTrend), sonst würde die Liste bei /// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb /// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden /// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow): /// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt /// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist /// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren. private const int CorrelationAbsenceRateThreshold = 15; private void BuildAttendanceParticipationNotices() { var students = _students.GetAll(); foreach (var row in Roster.Where(r => r.HasYearSummary && r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold)) { if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue; var student = MatchStudent(row.StudentName, students); if (student is null) continue; var points = _participation.GetByStudent(student.Id) .Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e)) .Where(x => x.Session is not null && x.Entry.Ratings.Count > 0) .OrderBy(x => x.Session!.Date) .TakeLast(8) .Select(x => x.Entry.Ratings.Average(r => r.Value)) .ToList(); if (points.Count < 4) continue; var mid = points.Count / 2; var diff = points.Skip(mid).Average() - points.Take(mid).Average(); if (diff <= -0.4) PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName, $"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit", ClassTeacherStatusKind.Info)); } OnPropertyChanged(nameof(HasPatternNotices)); } /// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt FirstName/LastName /// statt , weil dessen "Nachname, Vorname"-Format mit Komma den /// leerzeichenbasierten Wortabgleich in verfälschen würde /// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen). public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList students) { var key = UntisNameMatching.NameKey(webUntisDisplayName); return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key); } private static string WeekdayLabel(DayOfWeek day) => day switch { DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag", DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag", DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", _ => "Sonntag", }; /// Zeigt höchstens Zeilen — Nutzer-Feedback zu den /// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest /// zählt in und wird als "+N weitere" angezeigt. private const int OpenExcuseDisplayLimit = 6; private void BuildOpenExcuses(IReadOnlyList absenceDays, DateOnly today) { var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today); foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row); OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit); } [RelayCommand] private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row) { if (row is null) return; DetailsTab.StudentFilter = row.StudentName; ActiveTabIndex = 2; DetailsTab.LoadCommand.Execute(null); } /// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche /// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen. /// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen — /// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe /// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist /// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben). [RelayCommand] private void CreateReminderForStudent(ClassTeacherRosterRow? row) { if (row is null) return; SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused); } [RelayCommand] private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row) { if (row is null) return; SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).", urgent: row.IsOverdue); } [RelayCommand] private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice) { if (notice is null || !notice.CanCreateReminder) return; SaveReminder(notice.StudentName, notice.Message, urgent: true); } private void SaveReminder(string studentName, string notes, bool urgent) { var task = new WorkTask { Title = $"Eltern kontaktieren – {studentName}", Notes = notes, Kind = TaskKind.Reminder, Priority = urgent ? TaskPriority.High : TaskPriority.Normal, DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1), }; _workTasks.Save(task); Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen."; } 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)); OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel)); OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel)); OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction)); OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction)); OnPropertyChanged(nameof(DayOverviewTooltip)); } private void NotifyRosterState() { OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster)); OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses)); } }