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>
957 lines
57 KiB
C#
957 lines
57 KiB
C#
using System.Collections.ObjectModel;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using LehrerApp.Core.Services;
|
||
using LehrerApp.Desktop.Services;
|
||
|
||
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||
|
||
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
|
||
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
|
||
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
|
||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||
|
||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||
{
|
||
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
|
||
public bool HasClassRegisterToday { get; init; }
|
||
public bool IsLate => TodayAbsence is { } a &&
|
||
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
|
||
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
|
||
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
|
||
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
|
||
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
|
||
HasRecentClassRegisterEntry ? 3 : 4;
|
||
|
||
public string Initials
|
||
{
|
||
get
|
||
{
|
||
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
|
||
}
|
||
}
|
||
|
||
public string StatusText
|
||
{
|
||
get
|
||
{
|
||
if (TodayAbsence is null)
|
||
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
|
||
var core = IsLate
|
||
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
|
||
: TodayAbsence.CountsAsFullDay
|
||
? "Ganzer Fehltag"
|
||
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
|
||
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
|
||
}
|
||
}
|
||
|
||
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
|
||
HasAbsenceToday ? ClassTeacherStatusKind.Warning :
|
||
HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
|
||
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
|
||
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
|
||
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
|
||
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
|
||
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
|
||
|
||
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
|
||
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
|
||
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
|
||
public string StatusTextWithGlyph => StatusKind switch
|
||
{
|
||
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
|
||
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
|
||
_ => StatusText,
|
||
};
|
||
|
||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||
/// 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 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; }
|
||
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||
public int HolidayWeekdaysExcluded { get; init; }
|
||
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||
public DateOnly TermStart { get; init; }
|
||
public bool HasYearSummary => SchoolDaysElapsed > 0 &&
|
||
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
|
||
public int YearAbsenceRatePercent =>
|
||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||
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 > 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 (<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) =>
|
||
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(
|
||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||
DateOnly? today = null,
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||
int schoolDaysElapsed = 0,
|
||
int holidayWeekdaysExcluded = 0,
|
||
DateOnly termStart = default,
|
||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null)
|
||
{
|
||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||
.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());
|
||
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
|
||
.ToDictionary(g => g.Key, g => g.ToList());
|
||
|
||
return students.Select(student =>
|
||
{
|
||
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
|
||
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
|
||
?? absenceByName.GetValueOrDefault(nameKey);
|
||
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),
|
||
// 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,
|
||
};
|
||
})
|
||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||
}
|
||
|
||
private static bool TryDate(int value, out DateOnly date) =>
|
||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||
}
|
||
|
||
/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
|
||
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
|
||
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
|
||
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
|
||
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
|
||
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
|
||
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
|
||
double ExcusedFraction)
|
||
{
|
||
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
|
||
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
|
||
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
|
||
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
|
||
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
|
||
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
|
||
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
|
||
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
|
||
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
|
||
public string DetailTooltip =>
|
||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||
}
|
||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
|
||
{
|
||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
|
||
}
|
||
|
||
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
|
||
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
|
||
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
|
||
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
|
||
/// kein zusätzlicher Abruf.</summary>
|
||
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
|
||
{
|
||
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
|
||
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
|
||
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
|
||
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
|
||
public const int DeadlineDays = 3;
|
||
public string DateLabel => Date.ToString("dd.MM.");
|
||
public bool IsOverdue => DaysOpen > DeadlineDays;
|
||
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
|
||
|
||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
|
||
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
|
||
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
|
||
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
|
||
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
|
||
today.DayNumber - a.Date.DayNumber))
|
||
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
|
||
.ToList();
|
||
}
|
||
|
||
public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||
{
|
||
private readonly WebUntisSettingsService _settings;
|
||
private readonly WebUntisIntegrationService _untis;
|
||
private readonly UntisReportCacheService _cache;
|
||
private readonly SchoolYearService _schoolYear;
|
||
private readonly IWorkTaskRepository _workTasks;
|
||
private readonly IStudentRepository _students;
|
||
private readonly IDocumentationRepository _documentation;
|
||
private readonly IParticipationRepository _participation;
|
||
private readonly IParticipationSessionRepository _participationSessions;
|
||
private readonly AppLogger? _logger;
|
||
|
||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
|
||
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
|
||
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
|
||
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
|
||
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
|
||
|
||
[ObservableProperty] private string? _homeroomClassName;
|
||
[ObservableProperty] private int _activeTabIndex;
|
||
[ObservableProperty] private string _status = "";
|
||
[ObservableProperty] private bool _busy;
|
||
[ObservableProperty] private string _searchText = "";
|
||
/// 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;
|
||
[ObservableProperty] private int _todayAlertCount;
|
||
[ObservableProperty] private int _todayUnexcusedCount;
|
||
[ObservableProperty] private int _recentClassRegisterCount;
|
||
[ObservableProperty] private int _presentCount;
|
||
[ObservableProperty] private int _lateCount;
|
||
[ObservableProperty] private int _excusedAbsenceCount;
|
||
[ObservableProperty] private int _unexcusedAbsenceCount;
|
||
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
|
||
[ObservableProperty] private int _openExcuseOverflowCount;
|
||
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
|
||
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
|
||
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
|
||
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
|
||
[ObservableProperty] private int _ownDocumentationFollowUpCount;
|
||
[ObservableProperty] private int _ownDocumentationCriticalCount;
|
||
|
||
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
|
||
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
|
||
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
|
||
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
|
||
public bool HasPatternNotices => PatternNotices.Count > 0;
|
||
public bool HasOpenExcuses => OpenExcuses.Count > 0;
|
||
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
|
||
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
|
||
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);
|
||
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
|
||
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
|
||
|
||
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
|
||
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
|
||
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
|
||
|
||
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
|
||
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
|
||
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
|
||
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
|
||
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
|
||
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
|
||
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
|
||
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
|
||
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
|
||
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
|
||
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
|
||
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
|
||
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
|
||
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
|
||
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
|
||
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
|
||
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
|
||
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
|
||
|
||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||
|
||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||
IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation,
|
||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||
AppLogger? logger = null)
|
||
{
|
||
_settings = settings;
|
||
_untis = untis;
|
||
_cache = cache;
|
||
_schoolYear = schoolYear;
|
||
_workTasks = workTasks;
|
||
_students = students;
|
||
_documentation = documentation;
|
||
_participation = participation;
|
||
_participationSessions = participationSessions;
|
||
_logger = logger;
|
||
DetailsTab = detailsTab;
|
||
}
|
||
|
||
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
|
||
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
|
||
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
|
||
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
|
||
partial void OnSelectedRosterFilterChanged(int value)
|
||
{
|
||
OnPropertyChanged(nameof(AlertsFilterSelected));
|
||
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
|
||
OnPropertyChanged(nameof(AllFilterSelected));
|
||
OnPropertyChanged(nameof(PatternScoreFilterSelected));
|
||
ApplyRosterFilter();
|
||
}
|
||
|
||
[RelayCommand]
|
||
private async Task Load()
|
||
{
|
||
HomeroomClassName = _settings.HomeroomClassName;
|
||
ActiveTabIndex = 0;
|
||
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
|
||
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
|
||
if (!HomeroomClassConfigured)
|
||
{
|
||
Status = "Noch keine Klasse ausgewählt.";
|
||
NotifyRosterState();
|
||
return;
|
||
}
|
||
|
||
var className = HomeroomClassName!;
|
||
DetailsTab.Initialize(className);
|
||
Busy = true;
|
||
NotifyRosterState();
|
||
try
|
||
{
|
||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||
var sevenDayStart = today.AddDays(-6);
|
||
var trendDays = LastSchoolDays(today, 7);
|
||
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
|
||
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
|
||
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
|
||
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
|
||
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
|
||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, 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);
|
||
|
||
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
|
||
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
|
||
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||
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,
|
||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||
holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row);
|
||
|
||
StudentCount = Roster.Count;
|
||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
|
||
LateCount = Roster.Count(r => r.IsLate);
|
||
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 = recentRegisterEntries.Count;
|
||
BuildTrend(absenceDaysYear, trendDays);
|
||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||
BuildAttendanceParticipationNotices();
|
||
BuildOwnDocumentationCounts();
|
||
BuildOpenExcuses(absenceDaysYear, today);
|
||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
|
||
ApplyRosterFilter();
|
||
NotifySummary();
|
||
}
|
||
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
|
||
finally { Busy = false; NotifyRosterState(); }
|
||
}
|
||
|
||
[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()
|
||
{
|
||
DetailsTab.StudentFilter = "";
|
||
ActiveTabIndex = 1;
|
||
DetailsTab.LoadCommand.Execute(null);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void OpenAbsences()
|
||
{
|
||
DetailsTab.StudentFilter = "";
|
||
ActiveTabIndex = 2;
|
||
DetailsTab.LoadCommand.Execute(null);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
|
||
{
|
||
if (row is null) return;
|
||
DetailsTab.StudentFilter = row.StudentName;
|
||
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
|
||
DetailsTab.LoadCommand.Execute(null);
|
||
}
|
||
|
||
[RelayCommand] private async Task GoToSettings()
|
||
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
|
||
|
||
[RelayCommand] private async Task GoToWorkload()
|
||
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
|
||
|
||
private void ApplyRosterFilter()
|
||
{
|
||
PrimaryRoster.Clear(); SecondaryRoster.Clear();
|
||
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
|
||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
|
||
if (SelectedRosterFilter == 0)
|
||
{
|
||
PrimarySectionTitle = "Heute auffällig";
|
||
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
|
||
}
|
||
else if (SelectedRosterFilter == 1)
|
||
{
|
||
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";
|
||
SecondarySectionTitle = "Weitere Schüler*innen";
|
||
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
|
||
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
|
||
}
|
||
NotifyRosterState();
|
||
}
|
||
|
||
/// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
|
||
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
|
||
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
|
||
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
|
||
{
|
||
var today = days[^1];
|
||
var counts = days.Select(date =>
|
||
{
|
||
var rows = absenceDays.Where(r => r.Date == date).ToList();
|
||
var unexcused = rows.Count(r => r.IsUnexcused);
|
||
var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
|
||
var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
|
||
return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
|
||
Total: unexcused + lateExcused + excused);
|
||
}).ToList();
|
||
var max = Math.Max(1, counts.Max(c => c.Total));
|
||
foreach (var day in counts)
|
||
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
|
||
day.Total, day.Unexcused, day.LateExcused, day.Excused,
|
||
(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
|
||
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
|
||
/// Schulferien, nur Wochenenden.
|
||
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
|
||
{
|
||
var days = new List<DateOnly>();
|
||
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
|
||
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
|
||
days.Add(cursor);
|
||
days.Reverse();
|
||
return days;
|
||
}
|
||
|
||
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
||
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
|
||
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||
/// innerhalb des Zeitraums abzuziehen.
|
||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||
{
|
||
if (end < start) return 0;
|
||
var count = 0;
|
||
for (var d = start; d <= end; d = d.AddDays(1))
|
||
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
|
||
!holidays.Any(h => d >= h.Start && d <= h.End))
|
||
count++;
|
||
return count;
|
||
}
|
||
|
||
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||
/// ohnehin ausgeblendet).
|
||
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||
|
||
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
|
||
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
|
||
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
|
||
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
|
||
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
|
||
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
|
||
|
||
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
|
||
{
|
||
var cached = _settings.GetCachedHolidays();
|
||
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
|
||
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
|
||
return cached;
|
||
try
|
||
{
|
||
var fresh = (await _untis.GetHolidaysAsync())
|
||
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||
.ToList();
|
||
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||
return fresh;
|
||
}
|
||
catch (WebUntisIntegrationException ex)
|
||
{
|
||
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||
return cached ?? [];
|
||
}
|
||
}
|
||
|
||
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
|
||
|
||
private const int LateYearEscalationThreshold = 5;
|
||
|
||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
|
||
{
|
||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
|
||
PatternNotices.Add(notice);
|
||
OnPropertyChanged(nameof(HasPatternNotices));
|
||
}
|
||
|
||
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
|
||
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
|
||
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
|
||
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
|
||
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
|
||
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
|
||
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
|
||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
|
||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||
DateOnly sevenDayStart,
|
||
int lateYearEscalationThreshold = LateYearEscalationThreshold)
|
||
{
|
||
var notices = new List<ClassTeacherPatternNotice>();
|
||
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||
{
|
||
var rows = group.ToList();
|
||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
|
||
var lateDaysWeek = weekRows.Count(row => row.IsLate);
|
||
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
|
||
var lateDaysYear = rows.Count(row => row.IsLate);
|
||
|
||
if (unexcusedDaysWeek >= 2)
|
||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||
else if (lateDaysYear >= lateYearEscalationThreshold)
|
||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn – Elterngespräch oder Brief erwägen",
|
||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||
else if (lateDaysWeek >= 2)
|
||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||
}
|
||
return notices;
|
||
}
|
||
|
||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
|
||
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
|
||
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
|
||
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
|
||
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
|
||
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||
{
|
||
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
|
||
.ToDictionary(g => g.Key, g => g.First().StudentName);
|
||
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
|
||
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
|
||
PatternNotices.Add(notice);
|
||
OnPropertyChanged(nameof(HasPatternNotices));
|
||
}
|
||
|
||
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
|
||
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
|
||
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
|
||
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
|
||
/// Mehrheit.
|
||
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
|
||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
|
||
IReadOnlyDictionary<string, string> displayNamesByKey,
|
||
ISet<string> excludeDisplayNames,
|
||
int minSampleSize = 3, double clusterThreshold = 0.6)
|
||
{
|
||
var notices = new List<ClassTeacherPatternNotice>();
|
||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||
{
|
||
var rows = group.ToList();
|
||
if (rows.Count < minSampleSize) continue;
|
||
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
|
||
if (excludeDisplayNames.Contains(displayName)) continue;
|
||
|
||
var top = rows.GroupBy(r => r.Date.DayOfWeek)
|
||
.Select(g => (Day: g.Key, Count: g.Count()))
|
||
.OrderByDescending(g => g.Count).First();
|
||
if ((double)top.Count / rows.Count < clusterThreshold) continue;
|
||
|
||
notices.Add(new ClassTeacherPatternNotice(displayName,
|
||
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
|
||
ClassTeacherStatusKind.Info));
|
||
}
|
||
return notices;
|
||
}
|
||
|
||
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
|
||
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
|
||
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
|
||
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
|
||
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
|
||
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
|
||
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
|
||
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
|
||
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
|
||
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
|
||
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
|
||
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
|
||
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
|
||
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
|
||
private const int CorrelationAbsenceRateThreshold = 15;
|
||
private void BuildAttendanceParticipationNotices()
|
||
{
|
||
var students = _students.GetAll();
|
||
foreach (var row in Roster.Where(r => r.HasYearSummary &&
|
||
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
|
||
{
|
||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||
var student = MatchStudent(row.StudentName, students);
|
||
if (student is null) continue;
|
||
|
||
var points = _participation.GetByStudent(student.Id)
|
||
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
|
||
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
|
||
.OrderBy(x => x.Session!.Date)
|
||
.TakeLast(8)
|
||
.Select(x => x.Entry.Ratings.Average(r => r.Value))
|
||
.ToList();
|
||
if (points.Count < 4) continue;
|
||
|
||
var mid = points.Count / 2;
|
||
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
|
||
if (diff <= -0.4)
|
||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
|
||
ClassTeacherStatusKind.Info));
|
||
}
|
||
OnPropertyChanged(nameof(HasPatternNotices));
|
||
}
|
||
|
||
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
||
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
||
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
||
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
|
||
/// <see cref="BuildAttendanceParticipationNotices"/>.
|
||
private void BuildOwnDocumentationCounts()
|
||
{
|
||
var students = _students.GetAll();
|
||
var matchedIds = Roster
|
||
.Select(r => MatchStudent(r.StudentName, students))
|
||
.Where(s => s is not null)
|
||
.Select(s => s!.Id)
|
||
.ToHashSet();
|
||
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
|
||
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
|
||
OwnDocumentationCriticalCount = docs.Count(d =>
|
||
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
|
||
}
|
||
|
||
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
|
||
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
|
||
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
|
||
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
|
||
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
|
||
{
|
||
var key = UntisNameMatching.NameKey(webUntisDisplayName);
|
||
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
|
||
}
|
||
|
||
private static string WeekdayLabel(DayOfWeek day) => day switch
|
||
{
|
||
DayOfWeek.Monday => "Montag",
|
||
DayOfWeek.Tuesday => "Dienstag",
|
||
DayOfWeek.Wednesday => "Mittwoch",
|
||
DayOfWeek.Thursday => "Donnerstag",
|
||
DayOfWeek.Friday => "Freitag",
|
||
DayOfWeek.Saturday => "Samstag",
|
||
_ => "Sonntag",
|
||
};
|
||
|
||
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
|
||
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
|
||
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
|
||
private const int OpenExcuseDisplayLimit = 6;
|
||
|
||
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
|
||
{
|
||
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
|
||
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
|
||
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||
{
|
||
if (row is null) return;
|
||
DetailsTab.StudentFilter = row.StudentName;
|
||
ActiveTabIndex = 2;
|
||
DetailsTab.LoadCommand.Execute(null);
|
||
}
|
||
|
||
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
|
||
/// Erstellen ("Eltern anrufen – Ada Müller") musste man dort noch einmal von Hand eintippen.
|
||
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
|
||
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
|
||
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
|
||
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
|
||
[RelayCommand]
|
||
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
|
||
{
|
||
if (row is null) return;
|
||
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
|
||
{
|
||
if (row is null) return;
|
||
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
|
||
urgent: row.IsOverdue);
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
|
||
{
|
||
if (notice is null || !notice.CanCreateReminder) return;
|
||
SaveReminder(notice.StudentName, notice.Message, urgent: true);
|
||
}
|
||
|
||
private void SaveReminder(string studentName, string notes, bool urgent)
|
||
{
|
||
var task = new WorkTask
|
||
{
|
||
Title = $"Eltern kontaktieren – {studentName}",
|
||
Notes = notes,
|
||
Kind = TaskKind.Reminder,
|
||
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
|
||
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
|
||
};
|
||
_workTasks.Save(task);
|
||
Status = $"Wiedervorlage „{task.Title}\" angelegt — zu finden unter Aufgaben & Wiedervorlagen.";
|
||
}
|
||
|
||
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
|
||
private void NotifySummary()
|
||
{
|
||
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
|
||
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
|
||
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
|
||
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
|
||
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
|
||
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
|
||
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
|
||
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
|
||
OnPropertyChanged(nameof(DayOverviewTooltip));
|
||
}
|
||
private void NotifyRosterState()
|
||
{
|
||
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
|
||
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
|
||
}
|
||
}
|