Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s
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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user