From 95e3f677e463b955e5b5b2cf803500935b5c5302 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 31 Aug 2026 13:21:16 +0200 Subject: [PATCH] =?UTF-8?q?Klassenlehrer:=20Fehlquote=20nutzt=20echten=20W?= =?UTF-8?q?ebUntis-Ferienkalender=20+=20Sommerferien-N=C3=A4herung,=20Diag?= =?UTF-8?q?nose-Logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten Ferienkalender für den Rest des Jahres. Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) - sonst wäre der falsche Wert wieder unbemerkt geblieben. Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 70 ++++++++++++++++- .../ClassTeacherOverviewViewModel.cs | 77 +++++++++++++------ TODO.md | 63 +++++++++------ 3 files changed, 160 insertions(+), 50 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 8fd3104..a359d63 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -184,7 +184,8 @@ public sealed class ClassTeacherViewModelsTests }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], - new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20)); + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, + termStart: new DateOnly(2026, 8, 1))); Assert.True(row.HasYearSummary); Assert.Equal(2, row.YearAbsenceDayCount); @@ -192,9 +193,29 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(10, row.YearAbsenceRatePercent); Assert.Contains("10 %", row.YearSummaryLabel); Assert.Contains("2 von 20", row.YearSummaryTooltip); + Assert.Contains("seit 01.08.", row.YearSummaryTooltip); Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip); } + [Fact] + public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip() + { + // Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste + // direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte. + var students = new[] { Student(1001, "Ada Müller") }; + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90, + ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false), + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7, + termStart: new DateOnly(2026, 8, 1))); + + Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip); + } + [Fact] public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote() { @@ -290,6 +311,53 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(21, schoolDays); } + [Fact] + public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse() + { + // Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen + // Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August- + // Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei + // Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den + // Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse + // nötig statt CountSchoolWeekdays allein. + var absences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45, + ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45, + ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), + }; + + var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1)); + + Assert.Equal(new DateOnly(2026, 8, 13), termStart); + } + + [Fact] + public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck() + { + var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1)); + + Assert.Equal(new DateOnly(2026, 8, 1), termStart); + } + + [Fact] + public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein() + { + // Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit + // Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im + // Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus + // EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf + // die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21). + var termStart = new DateOnly(2026, 8, 13); + var noHolidaysInRange = Array.Empty(); + + var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays( + termStart, new DateOnly(2026, 8, 31), noHolidaysInRange); + + Assert.Equal(13, schoolDays); + } + // ── Offene Entschuldigungen (Feature-Idee 2) ───────────────────────────── [Fact] diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 2be8809..67b05f0 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -71,19 +71,29 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b }; /// 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. + /// 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 mit Fehlzeit" + - (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : ""); + $"{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); @@ -94,7 +104,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b IReadOnlyList recentClassRegisterEntries, DateOnly? today = null, IReadOnlyList? yearAbsences = null, - int schoolDaysElapsed = 0) + 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) @@ -126,6 +138,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b YearAbsenceDayCount = yearEntries.Count, YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), SchoolDaysElapsed = schoolDaysElapsed, + HolidayWeekdaysExcluded = holidayWeekdaysExcluded, + TermStart = termStart, }; }) .OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList(); @@ -202,6 +216,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject private readonly IStudentRepository _students; private readonly IParticipationRepository _participation; private readonly IParticipationSessionRepository _participationSessions; + private readonly AppLogger? _logger; public ClassTeacherDetailsViewModel DetailsTab { get; } public ObservableCollection Roster { get; } = []; @@ -275,7 +290,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, IStudentRepository students, IParticipationRepository participation, - IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab) + IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab, + AppLogger? logger = null) { _settings = settings; _untis = untis; @@ -285,6 +301,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject _students = students; _participation = participation; _participationSessions = participationSessions; + _logger = logger; DetailsTab = detailsTab; } @@ -335,9 +352,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); - var schoolDaysElapsed = CountSchoolWeekdays(yearStart, today, holidaysTask.Result); + 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)) Roster.Add(row); + classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed, + holidayWeekdaysExcluded, termStart)) Roster.Add(row); StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); @@ -456,28 +477,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject 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; - } - /// 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 den echten Ferienkalender bereits (getHolidays-Bericht, hier zwischen den - /// Werktagen ausgeschlossen statt wie bisher grob geschätzt) — behebt zusätzlich zum - /// Schuljahresbeginn auch die kleineren Verzerrungen durch Herbst-/Weihnachts-/Osterferien - /// im weiteren Jahresverlauf (bislang bewusst hingenommene Vereinfachung, siehe TODO.md - /// "Klassenlehrer"-Feature — Trend & Fehlquote). + /// 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; @@ -489,6 +503,16 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject 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 @@ -511,10 +535,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject .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) + catch (WebUntisIntegrationException ex) { + _logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex); return cached ?? []; } } diff --git a/TODO.md b/TODO.md index 883f6be..867c946 100644 --- a/TODO.md +++ b/TODO.md @@ -1704,30 +1704,45 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk tatsächlichen Sommerferien enden je nach Bundesland/Jahr aber erst Wochen später, und kurz nach Schuljahresbeginn macht diese Restferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus (rechnerisch: absolut korrekte Fehlzeiten-Zähler, aber ein um mehrere Wochen zu - großer Nenner). Erster Fix-Versuch (`EstimateTermStart`, frühester Fehlzeiten-Eintrag der Klasse - als Näherung für den ersten Unterrichtstag) kam ohne zusätzlichen WebUntis-Abruf aus, war aber - nur eine Näherung. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits - (`getHolidays`-Bericht, in `LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon - implementiert, aber bis dahin nirgends im Desktop verdrahtet) — `EstimateTermStart` durch den - echten Kalender ersetzt: - - `WebUntisIntegrationService.GetHolidaysAsync` (neuer `UntisHolidayDto`) als dünner Wrapper, - gleiches Muster wie `GetSchoolYearsAsync`. - - Cache dafür bewusst nicht über die LiteDB-Tabellen von `UntisReportCacheService` (deren - heißes/kaltes Fenster ist auf sich laufend ändernde Fehlzeiten/Klassenbucheinträge - zugeschnitten) — Ferien ändern sich innerhalb eines Schuljahrs praktisch nie. Stattdessen - einfacher tagesgenauer Cache direkt in `WebUntisSettingsService` - (`CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt, kein Geheimnis anders als - iCal-URL/API-Zugangsdaten in derselben Datei). Schlägt der Live-Abruf fehl, wird der zuletzt - bekannte Stand weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren. - - `ClassTeacherOverviewViewModel.CountSchoolWeekdays` (ersetzt `CountWeekdays`) zählt Werktage - zwischen Schuljahresbeginn und heute abzüglich aller Ferienzeiträume — behebt damit nicht nur - die große Verzerrung kurz nach Schuljahresbeginn, sondern nebenbei auch die bislang akzeptierte - kleinere Ungenauigkeit durch Herbst-/Weihnachts-/Osterferien im weiteren Jahresverlauf (oben - unter "Trend & Fehlquote" noch als bewusste Vereinfachung dokumentiert — damit erledigt). - Reine, ohne ViewModel-Zustand testbare Kernlogik, gleiches Muster wie - `DetectWeekdayPatterns`/`DetectLatePatterns`. - Neue Abhängigkeit `WebUntisIntegrationService` in `ClassTeacherOverviewViewModel` (bereits als - Singleton registriert, nur Konstruktor-Injection ergänzt). + großer Nenner). Mehrstufig gelöst, jede Stufe durch Nutzer-Feedback ausgelöst: + 1. Erster Versuch: `EstimateTermStart` (frühester Fehlzeiten-Eintrag der ganzen Klasse als + Näherung für den tatsächlichen ersten Unterrichtstag) — ohne zusätzlichen WebUntis-Abruf, aber + nur eine Näherung. + 2. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits (`getHolidays`-Bericht, in + `LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon implementiert, aber bis dahin + nirgends im Desktop verdrahtet). Verdrahtet über `WebUntisIntegrationService.GetHolidaysAsync` + (neuer `UntisHolidayDto`, gleiches Muster wie `GetSchoolYearsAsync`), gecacht (nicht über die + LiteDB-Tabellen von `UntisReportCacheService` — deren heißes/kaltes Fenster ist auf sich + laufend ändernde Fehlzeiten zugeschnitten, Ferien ändern sich dagegen innerhalb eines + Schuljahrs praktisch nie — sondern einfacher tagesgenauer Cache direkt in + `WebUntisSettingsService`: `CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt, + kein Geheimnis anders als iCal-URL/API-Zugangsdaten in derselben Datei). Neue + `ClassTeacherOverviewViewModel.CountSchoolWeekdays` zieht Ferienzeiträume von der + Werktagszählung ab. + 3. **Nutzer-Verifikation deckte auf: `getHolidays` liefert für dieses Konto nie einen + Sommerferien-Eintrag** — geprüft anhand der tatsächlich gecachten Antwort (179 Einträge, + 11 Jahre Historie ab 2015): Herbst-/Weihnachts-/Osterferien und einzelne bewegliche + Ferientage sind lückenlos dabei, aber kein einziger Juli-/August-Zeitraum, in keinem der + 11 Jahre. Vermutlich weil die Sommerferien WebUntis-intern zwischen zwei + Schuljahres-Datensätzen liegen (die bei 1.8./31.7. enden) statt "in" einem davon — WebUntis + scheint sie deshalb keinem Schuljahr zuzuordnen. `CountSchoolWeekdays` (Ferien innerhalb eines + Zeitraums abziehen) kann diese Lücke also grundsätzlich nicht schließen, unabhängig von + Caching oder Implementierung. `EstimateTermStart` bleibt deshalb zusätzlich bestehen (behebt + die Sommerferien-Lücke am Startpunkt), `CountSchoolWeekdays` läuft ab diesem geschätzten + Starttag (behebt Herbst-/Weihnachts-/Osterferien & bewegliche Ferientage innerhalb des + restlichen Jahres — oben unter "Trend & Fehlquote" noch als bewusste Vereinfachung + dokumentiert, für den Teil jetzt erledigt). Beide Kombinationen als Regressionstest + festgehalten (`CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein`) + mit den vom Nutzer nachgezählten echten Werten (13 Schultage seit 13.08.2026). + - Herkunft des Nenners jetzt direkt im Tooltip nachvollziehbar statt nur intern verrechnet: + `ClassTeacherRosterRow.TermStart` ("seit dd.MM.") und `HolidayWeekdaysExcluded` + ("X Ferientage abgezogen") — Lehre aus diesem Vorfall, bei dem der falsche Wert sonst erneut + unbemerkt geblieben wäre. + - `AppLogger` (optional, DI) protokolliert jeden `GetHolidaysAsync`-Aufruf: bei Erfolg Name + + Zeitraum jedes geladenen Ferieneintrags, bei Fehlschlag die Exception — nächster Diagnoseschritt + wäre sonst wieder nur stilles Rätselraten gewesen. + Neue Abhängigkeiten `WebUntisIntegrationService`, `AppLogger?` in `ClassTeacherOverviewViewModel` + (beide bereits als Singleton registriert, nur Konstruktor-Injection ergänzt). **Nicht behoben, weil mit den vorhandenen Daten nicht erkennbar:** Kolleg*innen, die die Anwesenheitsliste nur sporadisch führen, drücken die Quote auf dieselbe Weise (fehlende Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus