From 69cc16bbd29aa8c2ccbe137a90037cb52b0fb833 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 25 Aug 2026 23:27:14 +0200 Subject: [PATCH] fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Bugs behoben: - Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer falsch). - Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay gebunden. Explizit ergänzt. Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar). Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 29 +++++++++++++++-- .../UntisNameMatchingTests.cs | 21 +++++++++++++ .../ClassTeacherDetailsViewModel.cs | 13 +++++--- .../ClassTeacherOverviewViewModel.cs | 19 +++++++----- .../ClassTeacher/UntisNameMatching.cs | 22 +++++++++++++ .../ClassTeacherDetailsView.axaml | 31 ++++++++++--------- .../ClassTeacherOverviewView.axaml | 2 +- TODO.md | 23 ++++++++++++++ 8 files changed, 131 insertions(+), 29 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index b3505bd..799b6cc 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -100,8 +100,33 @@ public sealed class ClassTeacherViewModelsTests Assert.False(cem.HasRecentClassRegisterEntry); } - private static UntisStudentDto Student(int externKey, string displayName) => new( - UntisId: externKey, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null, + [Fact] + public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge() + { + // WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/ + // Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich + // muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug). + var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") }; + var todayAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45, + ["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false), + }; + var recentClassRegister = new[] + { + new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller", + "Vergessen", "Organisation", "Buch vergessen"), + }; + + var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister); + + var ben = Assert.Single(roster); + Assert.True(ben.HasAbsenceToday); + Assert.True(ben.HasRecentClassRegisterEntry); + } + + private static UntisStudentDto Student(int? externKey, string displayName) => new( + UntisId: externKey ?? 0, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null, ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null, EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null, MedicalReportDuty: null, Schulpflicht: null, Majority: null, diff --git a/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs new file mode 100644 index 0000000..37dc141 --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisNameMatchingTests.cs @@ -0,0 +1,21 @@ +using LehrerApp.Desktop.ViewModels.ClassTeacher; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class UntisNameMatchingTests +{ + [Theory] + [InlineData("Ben Schmidt", "Schmidt Ben")] + [InlineData("Ada Müller", "MÜLLER ADA")] + [InlineData("Max Mustermann", "mustermann max")] + public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) => + Assert.True(UntisNameMatching.NamesMatch(a, b)); + + [Theory] + [InlineData("Ben Schmidt", "Ben Müller")] + [InlineData(null, "Ben Schmidt")] + [InlineData("Ben Schmidt", "")] + public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) => + Assert.False(UntisNameMatching.NamesMatch(a, b)); +} diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index 5420f82..7dc1fc1 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -29,6 +29,10 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order()); public string StatusLabel => string.Join(", ", Statuses); public string ReasonLabel => string.Join("; ", AbsenceReasons); + /// Nutzer-Feedback: ein ganzer Fehltag ist in der Liste sonst nicht auf einen Blick erkennbar - + /// dezente Hervorhebung statt einer weiteren Text-/Icon-Spalte. + public string RowBackground => CountsAsFullDay ? "#1FFFA000" : "Transparent"; + public string? FullDayTooltip => CountsAsFullDay ? "Ganzer Fehltag" : null; public static IReadOnlyList GroupByStudentAndDay( IEnumerable entries) => entries @@ -138,13 +142,14 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject finally { Busy = false; } } + // WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem + // StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein + // exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen. private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) => - string.IsNullOrWhiteSpace(StudentFilter) || - string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase); + string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter); private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) => - string.IsNullOrWhiteSpace(StudentFilter) || - string.Equals(entry.StudentName, StudentFilter, StringComparison.OrdinalIgnoreCase); + string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter); private static bool TryDate(int value, out DateOnly date) => DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index cc69795..584f98a 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -17,8 +17,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// Führt die WebUntis-Schülerliste der Klasse mit den heutigen Fehlzeiten und den /// jüngsten Klassenbucheinträgen zusammen. Zuordnung primär über ExternKey (beide - /// stammen aus WebUntis, gleiche Kennung), Fallback auf Namensgleichheit für den - /// Klassenbuch-Bericht, der keinen ExternKey liefert (siehe UntisForeignClassRegisterEventDto). + /// stammen aus WebUntis, gleiche Kennung), Fallback auf reihenfolge-unabhängigen Namensabgleich + /// () für den Klassenbuch-Bericht, der keinen ExternKey liefert + /// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer + /// Reihenfolge als im Schülerreport. public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, @@ -29,20 +31,21 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b .GroupBy(a => a.ExternKey!.Value) .ToDictionary(g => g.Key, g => g.First()); var absenceByName = todayAbsences - .GroupBy(a => a.StudentName, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + .GroupBy(a => UntisNameMatching.NameKey(a.StudentName)) + .ToDictionary(g => g.Key, g => g.First()); var recentNames = recentClassRegisterEntries - .Select(e => e.StudentName) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + .Select(e => UntisNameMatching.NameKey(e.StudentName)) + .ToHashSet(); return students .Select(s => { + var nameKey = UntisNameMatching.NameKey(s.DisplayName); var absence = (s.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null) - ?? absenceByName.GetValueOrDefault(s.DisplayName); + ?? absenceByName.GetValueOrDefault(nameKey); return new ClassTeacherRosterRow(s.DisplayName, s.ExternKey, absence is not null, absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", - recentNames.Contains(s.DisplayName)); + recentNames.Contains(nameKey)); }) .OrderBy(r => r.StudentName) .ToList(); diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs new file mode 100644 index 0000000..8da87d6 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/UntisNameMatching.cs @@ -0,0 +1,22 @@ +namespace LehrerApp.Desktop.ViewModels.ClassTeacher; + +/// +/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der +/// Schülerreport baut "Vorname Nachname" (), +/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter +/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte +/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der +/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als +/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der +/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto). +/// +public static class UntisNameMatching +{ + public static bool NamesMatch(string? a, string? b) => + !string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b); + + public static string NameKey(string value) => string.Join(' ', + value.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(w => w.Trim().ToLowerInvariant()) + .OrderBy(w => w, StringComparer.Ordinal)); +} diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml index 8a6ad0e..dfd29d6 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherDetailsView.axaml @@ -69,20 +69,23 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index d75743e..c466d18 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -17,7 +17,7 @@ + SelectedIndex="{Binding ActiveTabIndex, Mode=TwoWay}"> diff --git a/TODO.md b/TODO.md index 9f7c1bd..cca2817 100644 --- a/TODO.md +++ b/TODO.md @@ -1362,6 +1362,29 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk (`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat. **Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt. +- [x] **Zwei echte Fehler gefunden und behoben (August 2026):** + - Gefilterte Listen im Klassenlehrer-Bereich blieben nach Klick auf eine Roster-Zeile leer, + obwohl Einträge vorhanden sein sollten. Ursache: WebUntis liefert Schülernamen je nach Bericht + in unterschiedlicher Reihenfolge — der Schülerreport baut "Vorname Nachname" + (`UntisStudentDto.DisplayName`), Fehlzeiten-/Klassenbuchberichte liefern typischerweise + "Nachname Vorname". Der exakte String-Vergleich beim Filtern (`ClassTeacherDetailsViewModel`) + schlug deshalb praktisch immer fehl — derselbe Fehler steckte unbemerkt auch schon im + Roster-Namensabgleich der Übersicht (`ClassTeacherRosterRow.Build`, Fallback wenn kein + `ExternKey` vorliegt), dort blieben Ampel-Symbol und Klassenbuch-Badge also vermutlich fast + immer falsch. Neuer reihenfolge-unabhängiger Namensvergleich + (`ClassTeacher/UntisNameMatching.NamesMatch`: Namen in Wörter zerlegt, als sortierte Menge + verglichen), an beiden Stellen eingesetzt. + - Nach dem ersten Klick auf eine Roster-Zeile (Wechsel in die Details) blieb ein zweiter Klick + wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte — man blieb + auf der Übersicht hängen. Ursache: `TabbedPage.SelectedIndex="{Binding ActiveTabIndex}"` ohne + `Mode=TwoWay` ist effektiv nur VM→View gebunden; ein manueller Tab-Klick des Nutzers schreibt + `ActiveTabIndex` im ViewModel nicht zurück auf 0, wodurch ein erneutes `ActiveTabIndex = 1` aus + `ShowDetailsForStudent` denselben Wert setzt und mangels Änderung kein `PropertyChanged` + auslöst. Fix: `Mode=TwoWay` explizit ergänzt (`ClassTeacherOverviewView.axaml`). Dasselbe + Bindungsmuster ohne `Mode=TwoWay` existiert auch in `GroupDetailView.axaml`/`WorkloadView.axaml` + (dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem + reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung + ausgelagert, nicht Teil dieser Änderung. ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3