Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 21:36:34 +02:00
co-authored by Claude Sonnet 5
parent fb7a015b42
commit 987b49e761
4 changed files with 393 additions and 14 deletions
@@ -228,6 +228,198 @@ public sealed class ClassTeacherViewModelsTests
Assert.Null(row.YearSummaryTooltip); 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<UntisForeignClassRegisterEventDto>
{
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] [Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu() public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{ {
@@ -73,8 +73,35 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein /// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage /// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>). /// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
/// 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 <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
/// Verspätet vor Entschuldigt, überschneidungsfrei).
public int YearAbsenceDayCount { get; init; } public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; } public int YearUnexcusedDayCount { get; init; }
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
public int YearLateDayCount { get; init; }
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
public int YearExcusedDayCount { get; init; }
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
/// <see cref="HasRecentClassRegisterEntry"/> (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 <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
public double ClassRegisterScoreComponent { get; init; }
public int SchoolDaysElapsed { get; init; } public int SchoolDaysElapsed { get; init; }
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die /// 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 /// 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), /// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin. /// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
public DateOnly TermStart { get; init; } 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 => public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed); SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary public string YearSummaryLabel => !HasYearSummary ? "" :
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : ""; 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 : public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" + (YearAbsenceDayCount > 0
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") + ? $"{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" : ""); (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 (<see cref="ClassRegisterScoreComponent"/>) — 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) => private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase); 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<string> 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<ClassTeacherRosterRow> Build( public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentRosterCacheEntry> students, IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences, IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
@@ -106,7 +192,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null, IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0, int schoolDaysElapsed = 0,
int holidayWeekdaysExcluded = 0, int holidayWeekdaysExcluded = 0,
DateOnly termStart = default) DateOnly termStart = default,
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null)
{ {
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today); var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null) 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()); .ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)) var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList()); .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 yearRows = yearAbsences ?? [];
var yearByKey = yearRows.Where(a => a.ExternKey is not null) var yearByKey = yearRows.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList()); .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 registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null) var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? []; ?? yearByName.GetValueOrDefault(nameKey) ?? [];
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null, return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}", absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0) registerEntries.Count > 0)
{ {
TodayAbsence = absence, TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate), 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), 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, SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded, HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart, TermStart = termStart,
@@ -232,7 +334,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private string _status = ""; [ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy; [ObservableProperty] private bool _busy;
[ObservableProperty] private string _searchText = ""; [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 _primarySectionTitle = "Heute auffällig";
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen"; [ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount; [ObservableProperty] private int _studentCount;
@@ -263,6 +369,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public bool AlertsFilterSelected => SelectedRosterFilter == 0; public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1; public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2; public bool AllFilterSelected => SelectedRosterFilter == 2;
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount); public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount); public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount); public int PresentPercent => Percent(PresentCount);
@@ -324,6 +431,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(AlertsFilterSelected)); OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected)); OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected)); OnPropertyChanged(nameof(AllFilterSelected));
OnPropertyChanged(nameof(PatternScoreFilterSelected));
ApplyRosterFilter(); ApplyRosterFilter();
} }
@@ -357,7 +465,14 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today)); var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); 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(); var holidaysTask = GetHolidaysAsync();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
@@ -367,9 +482,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []); var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result); var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed; 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, foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed, recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart)) Roster.Add(row); holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row);
StudentCount = Roster.Count; StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday); TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
@@ -378,7 +495,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
PresentCount = Roster.Count(r => !r.HasAbsenceToday); PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused); ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = 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); BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(absenceDaysYear, sevenDayStart); BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear); BuildWeekdayPatternNotices(absenceDaysYear);
@@ -397,6 +514,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0; [RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1; [RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2; [RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
[RelayCommand] [RelayCommand]
private void OpenClassRegister() private void OpenClassRegister()
@@ -444,6 +562,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage"; PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row); 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 else
{ {
PrimarySectionTitle = "Heute auffällig"; 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)); (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 <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne /// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine /// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden. /// Schulferien, nur Wochenenden.
@@ -227,14 +227,23 @@
<Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}" <Border Grid.Row="0" Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1" BorderBrush="{DynamicResource AppCardBorderBrush}" BorderThickness="1"
CornerRadius="8" Padding="8" Margin="0,0,0,4"> CornerRadius="8" Padding="8" Margin="0,0,0,4">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,250" ColumnSpacing="8"> <Grid ColumnDefinitions="Auto,Auto,Auto,Auto,*,250" ColumnSpacing="8">
<Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}" <Button Grid.Column="0" Classes="filter" Classes.active="{Binding AlertsFilterSelected}"
Content="Auffällig" Command="{Binding ShowAlertsCommand}"/> Content="Auffällig" Command="{Binding ShowAlertsCommand}"/>
<Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}" <Button Grid.Column="1" Classes="filter" Classes.active="{Binding ClassRegisterFilterSelected}"
Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/> Content="Klassenbuch" Command="{Binding ShowClassRegisterCommand}"/>
<Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}" <Button Grid.Column="2" Classes="filter" Classes.active="{Binding AllFilterSelected}"
Content="Alle" Command="{Binding ShowAllCommand}"/> Content="Alle" Command="{Binding ShowAllCommand}"/>
<TextBox Grid.Column="4" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…" <!-- Nutzer-Feedback: "Auffällig" zeigt nur den Heute-Snapshot — wer heute zufällig
da ist, aber zuvor mehrfach unentschuldigt fehlte, taucht dort gar nicht auf,
während ein einmaliger Erstfehltag ganz oben landet. Neuer Reiter sortiert
stattdessen nach dem kumulierten Score seit Schuljahresbeginn
(ClassTeacherRosterRow.PatternScore: unentschuldigt > entschuldigt >
Verspätung) und ist deshalb der beim Öffnen aktive Standard-Reiter. -->
<Button Grid.Column="3" Classes="filter" Classes.active="{Binding PatternScoreFilterSelected}"
Content="Gesamtbild" Command="{Binding ShowPatternScoreCommand}"
ToolTip.Tip="Sortiert nach Auffälligkeits-Score seit Schuljahresbeginn (unentschuldigt zählt am stärksten, Verspätung am wenigsten) statt nur nach dem heutigen Status."/>
<TextBox Grid.Column="5" Text="{Binding SearchText, Mode=TwoWay}" PlaceholderText="Schüler*in suchen…"
FontSize="12" MinHeight="32"/> FontSize="12" MinHeight="32"/>
</Grid> </Grid>
</Border> </Border>
+45
View File
@@ -1807,6 +1807,51 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
**Bewusst zurückgestellt:** Kategorien-Chipreihe (bisher nur WebUntis-`CategoryGroup`) auf eigene **Bewusst zurückgestellt:** Kategorien-Chipreihe (bisher nur WebUntis-`CategoryGroup`) auf eigene
Tags erweitern; "Gespräch begleiten" (Elternanruf-Durchführung) aus dem Gruppen-Tab wurde hier Tags erweitern; "Gespräch begleiten" (Elternanruf-Durchführung) aus dem Gruppen-Tab wurde hier
nicht übernommen, da dieser Hub bewusst schlank gehalten wurde. nicht übernommen, da dieser Hub bewusst schlank gehalten wurde.
- [x] **"Klassenlehrer"-Feature — Fehlquote behandelte Verspätung wie Schwänzen, Roster-Sortierung
ignorierte Muster über "heute" hinaus (September 2026):** Zwei verwandte Nutzer-Feedback-Punkte
zur selben Ursache: kein Unterschied zwischen "10× 5 Min. verspätet" und "10× ganztägig
unentschuldigt gefehlt".
- **Fehlquote:** `ClassTeacherRosterRow.YearAbsenceDayCount` (Zähler von
`YearAbsenceRatePercent`, angezeigt als "X % Fehlzeit seit Schuljahresbeginn") zählte bisher
jeden Tag mit irgendeinem Fehlzeiten-Eintrag gleich, egal ob ganztägig unentschuldigt oder nur
5 Minuten verspätet. Jetzt dieselbe überschneidungsfreie Kategorisierung wie im Wochentrend
(`BuildTrend`: Unentschuldigt vor Verspätet vor Entschuldigt) — reine Verspätung (nicht auch
unentschuldigt) zählt nicht mehr in die Quote, sondern separat in neuem
`YearLateDayCount`/`YearExcusedDayCount`. Ist jemand unentschuldigt UND verspätet, bleibt das
der schwerwiegendere Fall und zählt weiter voll in die Quote (gleiche Rangfolge wie
`AttentionRank`/`StatusKind`). `YearSummaryLabel`/`-Tooltip` zeigen reine Verspätungstage jetzt
als eigene Zeile statt sie in die Prozentzahl einzurechnen.
- **Roster-Sortierung:** die bisherige "Auffällig"-Ansicht filtert rein auf den Heute-Snapshot
(`HasAbsenceToday`) — ein/e Schüler*in mit 5 Tagen unentschuldigter Vorgeschichte, aber heute
zufällig anwesend, verschwand komplett aus der Liste, während ein einmaliger Erstfehltag ganz
oben landete, sobald er der/die einzige "heute Auffällige" war. Neuer vierter Reiter
"Gesamtbild" (`SelectedRosterFilter == 3`, jetzt der beim Öffnen aktive Standard-Reiter statt
"Auffällig") sortiert stattdessen nach neuem `ClassTeacherRosterRow.PatternScore` — kumulierte,
gewichtete Summe über das ganze bisherige Schuljahr (unentschuldigt × 3,0, entschuldigt
abwesend × 1,0, nur verspätet × 0,5; feste statt einstellbare Gewichte, Nutzer-Entscheidung),
bewusst ohne zeitliche Abklingkurve (für die genannten Fälle reicht die reine Summe). Die drei
bestehenden Reiter (Auffällig/Klassenbuch/Alle) blieben unverändert.
- **Nachtrag (September 2026):** `PatternScore` bezog zunächst nur Fehlzeiten ein — Nutzer-Feedback:
Klassenbucheinträge anderer Lehrkräfte (fehlende Hausaufgaben, schlechte Mitarbeit, im
Extremfall eine Suspendierung) sollen ebenfalls einfließen, "wenn die negativen Einträge durch
die Decke gehen". `ClassTeacherRosterRow` bekam dafür `YearNegativeClassRegisterCount`/
`HasSuspensionEntry`/`ClassRegisterScoreComponent`: jeder Klassenbucheintrag mit
`CategoryGroup` "Negativ" zählt mit Gewicht 1,0 (ein einzelner Eintrag fällt kaum ins Gewicht,
erst die Häufung), `CategoryName`/`Text`-Schlüsselwörter "hausaufgabe"/"mitarbeit" zählen mit
zusätzlichem Bonus 1,5, das Schlüsselwort "suspendier" mit Ausreißer-Gewicht 15,0 (schießt den
Score sofort deutlich über jedes normale Muster, wie gewünscht) — alles einfache
case-insensitive `Contains`-Prüfungen, gleiches Muster wie die bestehende "Negativ"/"verspät"-
Erkennung. Dafür musste `ClassTeacherOverviewViewModel.Load` den Klassenbuch-Abruf von "letzte
7 Tage" auf "seit Schuljahresbeginn" ausweiten (`_cache.GetClassRegisterEventsAsync(...,
yearStart, today)`, dieselbe dauerhaft gecachte "kalte Historie" wie bei den Fehlzeiten) — die
bestehenden 7-Tage-Badges (`RecentClassRegisterCount`, `HasRecentClassRegisterEntry`,
"Klassenbuch"-Reiter) filtern seitdem clientseitig aus genau diesem einen Abruf heraus, statt
weiterhin separat und enger abzurufen, damit sich an ihrem Verhalten nichts ändert.
`ClassTeacherRosterRow.Build` bekam dafür einen neuen optionalen Parameter
`yearClassRegisterEntries`, der ohne Angabe auf den bisherigen `recentClassRegisterEntries`-
Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests).
**Bewusst zurückgestellt:** einstellbare Gewichte (Schieberegler o.ä.) für `PatternScore`
Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3