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