From 987b49e761c8cd266cceb5b7c1fe4a5e76c04c4c Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Tue, 1 Sep 2026 21:36:34 +0200 Subject: [PATCH] Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag, sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter (Standardansicht) sortiert die Klasse nach einem kumulierten Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status, und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein (Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort deutlich hoch). Co-Authored-By: Claude Sonnet 5 --- .../ClassTeacherViewModelsTests.cs | 192 ++++++++++++++++++ .../ClassTeacherOverviewViewModel.cs | 157 ++++++++++++-- .../ClassTeacherOverviewView.axaml | 13 +- TODO.md | 45 ++++ 4 files changed, 393 insertions(+), 14 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index aad0e64..04a5c5d 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -228,6 +228,198 @@ public sealed class ClassTeacherViewModelsTests Assert.Null(row.YearSummaryTooltip); } + // ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ──── + + [Fact] + public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote() + { + // Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt + // gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr + // treiben, taucht aber weiterhin als eigene Zahl auf. + var students = new[] { Student(1001, "Ada Müller") }; + var yearAbsences = Enumerable.Range(0, 3) + .Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5, + ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false)) + .ToArray(); + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1))); + + Assert.Equal(0, row.YearAbsenceDayCount); + Assert.Equal(3, row.YearLateDayCount); + Assert.Equal(0, row.YearAbsenceRatePercent); + Assert.True(row.HasYearSummary); + Assert.Contains("verspätet", row.YearSummaryLabel); + Assert.DoesNotContain("%", row.YearSummaryLabel); + Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip); + Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip); + } + + [Fact] + public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung() + { + // Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe + // Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt + // deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden. + var students = new[] { Student(1001, "Ada Müller") }; + var yearAbsences = new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60, + ["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false), + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1))); + + Assert.Equal(1, row.YearAbsenceDayCount); + Assert.Equal(1, row.YearUnexcusedDayCount); + Assert.Equal(0, row.YearLateDayCount); + } + + [Fact] + public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung() + { + // Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score + // ergeben wie 10× ganztägig unentschuldigt gefehlt. + var lateStudent = Student(1001, "Verpennt Timo"); + var truantStudent = Student(1002, "Geschwaenzt Cem"); + var yearAbsences = Enumerable.Range(0, 10) + .SelectMany(i => new[] + { + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5, + ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), + new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), + }) + .ToArray(); + + var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)); + + var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo"); + var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem"); + Assert.Equal(5.0, timo.PatternScore); + Assert.Equal(30.0, cem.PatternScore); + Assert.True(cem.PatternScore > timo.PatternScore * 5); + } + + [Fact] + public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend() + { + // Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt + // fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der + // reinen "heute auffällig"-Sortierung (HasAbsenceToday). + var student = Student(1001, "Ada Müller"); + var yearAbsences = Enumerable.Range(0, 5) + .Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270, + ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true)) + .ToArray(); + + // Kein todayAbsences-Eintrag: heute anwesend. + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1))); + + Assert.False(row.HasAbsenceToday); + Assert.True(row.PatternScore > 0); + } + + [Fact] + public void PatternScore_IstNullOhneJahresdaten() + { + var students = new[] { Student(1001, "Ada Müller") }; + + var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26))); + + Assert.Equal(0.0, row.PatternScore); + } + + // ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ──────────────── + + [Fact] + public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten() + { + var student = Student(1001, "Ada Müller"); + var yearRegister = Enumerable.Range(0, 4) + .Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test", + "Unterrichtsstörung", "Negativ", "Gestört")) + .ToArray(); + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], + new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + yearClassRegisterEntries: yearRegister)); + + Assert.Equal(4, row.YearNegativeClassRegisterCount); + Assert.Equal(4.0, row.PatternScore); + Assert.True(row.HasYearSummary); + Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel); + } + + [Fact] + public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ() + { + var withKeyword = Student(1001, "Keyword Kim"); + var plain = Student(1002, "Plain Priya"); + var yearRegister = new[] + { + new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test", + "Hausaufgaben fehlen", "Negativ", "HA nicht dabei"), + new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test", + "Sonstiges", "Negativ", "Gestört"), + }; + + var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [], + new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + yearClassRegisterEntries: yearRegister); + + var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim"); + var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya"); + Assert.True(kim.PatternScore > priya.PatternScore); + } + + [Fact] + public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus() + { + var suspended = Student(1001, "Suspendiert Sam"); + var chronic = Student(1002, "Viele Vera"); + var yearRegister = new List + { + new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ", + "Vom Unterricht suspendiert"), + }; + yearRegister.AddRange(Enumerable.Range(0, 6) + .Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test", + "Sonstiges", "Negativ", "Gestört"))); + + var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [], + new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), + yearClassRegisterEntries: yearRegister); + + var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam"); + var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera"); + Assert.True(sam.HasSuspensionEntry); + Assert.True(sam.PatternScore > vera.PatternScore); + Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip); + } + + [Fact] + public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck() + { + // Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen + // recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen + // Einträgen bekommen statt stillschweigend leer auszugehen. + var student = Student(1001, "Ada Müller"); + var entries = new[] + { + new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test", + "Sonstiges", "Negativ", "Gestört"), + }; + + var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries, + new DateOnly(2026, 8, 26))); + + Assert.True(row.PatternScore > 0); + } + [Fact] public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu() { diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index 933ec38..5d461ec 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -73,8 +73,35 @@ 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 Werktage /// abzüglich der über WebUntis geladenen Ferien (). + /// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen + /// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher + /// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion + /// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen + /// separat in (gleiche Kategorisierung wie schon länger im + /// Wochentrend, siehe : Unentschuldigt vor + /// Verspätet vor Entschuldigt, überschneidungsfrei). public int YearAbsenceDayCount { get; init; } public int YearUnexcusedDayCount { get; init; } + /// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht + /// in /, tragen aber mit + /// reduziertem Gewicht zu bei. + public int YearLateDayCount { get; init; } + /// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von + /// innerhalb von . + public int YearExcusedDayCount { get; init; } + /// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit + /// CategoryGroup "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild + /// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als + /// (nur letzte 7 Tage, für die "Klassenbuch"-Badges) + /// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann. + public int YearNegativeClassRegisterCount { get; init; } + /// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll + /// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback: + /// "Suspendiert sollte direkt hochschießen"). + public bool HasSuspensionEntry { get; init; } + /// Gewichtete Summe aus über alle Klassenbucheinträge + /// des Schuljahres — fließt in ein. + public double ClassRegisterScoreComponent { 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 @@ -85,19 +112,78 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b /// 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 bool HasYearSummary => SchoolDaysElapsed > 0 && + (YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0); public int YearAbsenceRatePercent => SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); - public string YearSummaryLabel => HasYearSummary - ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : ""; + public string YearSummaryLabel => !HasYearSummary ? "" : + YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" + : YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn" + : $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn"; public string? YearSummaryTooltip => !HasYearSummary ? null : - $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" + - (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") + + (YearAbsenceDayCount > 0 + ? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" + + (YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") + : $"Keine Fehltage seit {TermStart:dd.MM.}") + + (YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") + + (YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") + + (HasSuspensionEntry ? " · Suspendierung vermerkt" : "") + (HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : ""); + // Feste Gewichte statt einstellbarer Werte (Nutzer-Entscheidung): unentschuldigt fällt am + // stärksten ins Gewicht ("geschwänzt"), entschuldigte Abwesenheit mittel, reine Verspätung + // ("verpennt") am wenigsten — genau die vom Nutzer gewünschte Trennung, nur als Zahl statt als + // Statustext. Bei Bedarf später anpassbar, siehe TODO.md. + private const double UnexcusedDayWeight = 3.0; + private const double ExcusedDayWeight = 1.0; + private const double LateDayWeight = 0.5; + // Klassenbuch-Einträge anderer Lehrkräfte (Nutzer-Feedback): ein einzelner "Negativ"-Eintrag + // wiegt ungefähr wie eine entschuldigte Fehlzeit — erst die Häufung ("durch die Decke gehen") + // treibt den Score merklich. Die beiden genannten Muster (fehlende Hausaufgaben/schlechte + // Mitarbeit) zählen zusätzlich etwas stärker, weil sie explizit als wiederkehrend relevant + // genannt wurden. "Suspendiert" ist bewusst ein Ausreißer-Gewicht, das den Score sofort nach + // oben reißt, unabhängig vom sonstigen Verlauf. + private const double NegativeClassRegisterEntryWeight = 1.0; + private const double ConcerningKeywordBonus = 1.5; + private const double SuspensionKeywordWeight = 15.0; + private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"]; + private static readonly string[] SuspensionKeywords = ["suspendier"]; + + /// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback: + /// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der + /// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald + /// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die + /// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die + /// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge + /// anderer Lehrkräfte mit ein () — beides sind + /// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren + /// statt sich gegenseitig zu verdrängen. + public double PatternScore => + YearUnexcusedDayCount * UnexcusedDayWeight + + YearExcusedDayCount * ExcusedDayWeight + + YearLateDayCount * LateDayWeight + + ClassRegisterScoreComponent; + private static bool IsLateReason(string reason) => reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); + private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) => + e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true; + + private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList keywords) => + keywords.Any(k => + e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true || + e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true); + + private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e) + { + var weight = 0.0; + if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight; + if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus; + if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight; + return weight; + } + public static IReadOnlyList Build( IReadOnlyList students, IReadOnlyList todayAbsences, @@ -106,7 +192,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b IReadOnlyList? yearAbsences = null, int schoolDaysElapsed = 0, int holidayWeekdaysExcluded = 0, - DateOnly termStart = default) + DateOnly termStart = default, + IReadOnlyList? yearClassRegisterEntries = null) { var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) @@ -115,6 +202,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b .ToDictionary(g => g.Key, g => g.First()); var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) .ToDictionary(g => g.Key, g => g.ToList()); + // Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als + // recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur + // die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der + // Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests). + var yearRegisterByName = (yearClassRegisterEntries ?? 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()); @@ -129,14 +222,23 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? []; var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null) ?? yearByName.GetValueOrDefault(nameKey) ?? []; + var yearRegisterEntries = yearRegisterByName.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, + // Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe + // Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch + // unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort. + YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate), YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused), + YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate), + YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate), + YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry), + HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)), + ClassRegisterScoreComponent = yearRegisterEntries.Sum(ClassRegisterEntryWeight), SchoolDaysElapsed = schoolDaysElapsed, HolidayWeekdaysExcluded = holidayWeekdaysExcluded, TermStart = termStart, @@ -232,7 +334,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject [ObservableProperty] private string _status = ""; [ObservableProperty] private bool _busy; [ObservableProperty] private string _searchText = ""; - [ObservableProperty] private int _selectedRosterFilter; + /// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine + /// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte + /// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben + /// steht, sobald er der/die einzige "heute Auffällige" ist. + [ObservableProperty] private int _selectedRosterFilter = 3; [ObservableProperty] private string _primarySectionTitle = "Heute auffällig"; [ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen"; [ObservableProperty] private int _studentCount; @@ -263,6 +369,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool AllFilterSelected => SelectedRosterFilter == 2; + public bool PatternScoreFilterSelected => SelectedRosterFilter == 3; public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount); public int LatePercent => Percent(LateCount); public int PresentPercent => Percent(PresentCount); @@ -324,6 +431,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject OnPropertyChanged(nameof(AlertsFilterSelected)); OnPropertyChanged(nameof(ClassRegisterFilterSelected)); OnPropertyChanged(nameof(AllFilterSelected)); + OnPropertyChanged(nameof(PatternScoreFilterSelected)); ApplyRosterFilter(); } @@ -357,7 +465,14 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject 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); + // Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge + // anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine + // Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht + // nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den + // Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges + // (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus + // genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen. + var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today); var holidaysTask = GetHolidaysAsync(); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); @@ -367,9 +482,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; + var recentRegisterEntries = classRegisterTask.Result + .Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList(); foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, - classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed, - holidayWeekdaysExcluded, termStart)) Roster.Add(row); + recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed, + holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row); StudentCount = Roster.Count; TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); @@ -378,7 +495,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject 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; + RecentClassRegisterCount = recentRegisterEntries.Count; BuildTrend(absenceDaysYear, trendDays); BuildPatternNotices(absenceDaysYear, sevenDayStart); BuildWeekdayPatternNotices(absenceDaysYear); @@ -397,6 +514,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; + [RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3; [RelayCommand] private void OpenClassRegister() @@ -444,6 +562,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage"; foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row); } + else if (SelectedRosterFilter == 3) + { + // Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte + // verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen + // einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige" + // ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach + // dem heutigen Status — bewusst unabhängig von HasAbsenceToday. + PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn"; + foreach (var row in query.Where(r => r.PatternScore > 0) + .OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName)) + PrimaryRoster.Add(row); + } else { PrimarySectionTitle = "Heute auffällig"; @@ -476,6 +606,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject (double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max)); } + private static bool TryDate(int value, out DateOnly date) => + DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date); + /// Letzte Werktage bis einschließlich , ohne /// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine /// Schulferien, nur Wochenenden. diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index ef44a6d..f2b1eb2 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -227,14 +227,23 @@ - +