Files
2026-09-15 10:02:33 +02:00

1269 lines
73 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
/// bisherige Klassen-Gesamtansicht.</summary>
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
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" : "");
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. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
public double PatternScore { get; init; }
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
public double RegressionScore { get; init; }
public string? RegressionVorgangTitle { get; init; }
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, PatternScoreWeights weights)
{
var weight = 0.0;
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
return weight;
}
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
private static readonly string[] LatenessDomainKeywords = ["versp"];
private static readonly string[] ClassbookDomainKeywords =
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
{
var domains = new HashSet<VorgangScoreDomain>();
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Attendance);
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Lateness);
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Classbook);
if (domains.Count == 0)
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
return domains;
}
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
IReadOnlyList<Vorgang> closedVorgaenge)
{
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
foreach (var v in closedVorgaenge)
{
if (v.ClosedAt is not { } closedAt) continue;
var closedDate = DateOnly.FromDateTime(closedAt);
foreach (var domain in MatchDomains(v.Tags))
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
cutoffs[domain] = closedDate;
}
return cutoffs;
}
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
{
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
var classRegister = yearRegisterEntries
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
.Sum(e => ClassRegisterEntryWeight(e, weights));
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
late * weights.LateDayWeight + classRegister;
}
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,
PatternScoreWeights? weights = null,
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
{
var effectiveWeights = weights ?? new PatternScoreWeights();
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) ?? [];
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
var regressionScore = 0.0;
if (mostRecentClosed is not null)
{
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
}
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(e => ClassRegisterEntryWeight(e, effectiveWeights)),
PatternScore = patternScore,
RegressionScore = regressionScore,
RegressionVorgangTitle = mostRecentClosed?.Title,
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();
}
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
string SignalColorHex, string Tooltip, bool IsToday)
{
public string DayNumber => Date.Day.ToString();
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
}
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 IVorgangRepository _vorgaenge;
private readonly PatternScoreSettingsService _patternScoreSettings;
private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ClassTeacherCasesViewModel CasesTab { 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; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { 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;
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
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 string CompactMonthLabel
{
get
{
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
return SelectedCalendarScope?.StudentName is { Length: > 0 }
? $"{month} · {SelectedCalendarScope.DisplayName}"
: $"{month} · gesamte Klasse";
}
}
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, IVorgangRepository vorgaenge,
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{
_settings = settings;
_untis = untis;
_cache = cache;
_schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_documentation = documentation;
_vorgaenge = vorgaenge;
_patternScoreSettings = patternScoreSettings;
_participation = participation;
_participationSessions = participationSessions;
_logger = logger;
DetailsTab = detailsTab;
CasesTab = casesTab;
}
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 OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
{
OnPropertyChanged(nameof(CompactMonthLabel));
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
}
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value)
{
OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected));
OnPropertyChanged(nameof(PatternScoreFilterSelected));
ApplyRosterFilter();
}
private void ResetRosterState()
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
public void PrepareForDisplay()
{
if (Busy) return;
HomeroomClassName = _settings.HomeroomClassName;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
}
else
{
DetailsTab.Initialize(HomeroomClassName!);
CasesTab.Initialize(HomeroomClassName!);
if (StudentCount == 0)
Status = "Noch nicht geladen auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
}
NotifyRosterState();
}
[RelayCommand]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
NotifyRosterState();
return;
}
var className = HomeroomClassName!;
DetailsTab.Initialize(className);
CasesTab.Initialize(className);
Busy = true;
NotifyRosterState();
try
{
// 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 (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
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);
_lastStudents = studentsTask.Result;
_lastAbsences = absencesTask.Result;
_lastClassRegisterEvents = classRegisterTask.Result;
_lastHolidays = holidaysTask.Result;
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); }
}
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
// wenn die Nutzerin sich dafür entscheidet.
public void RefreshFromLocalDataOnly()
{
if (Busy || _lastStudents is null || _lastAbsences is null ||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
ResetRosterState();
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
today, yearStart, sevenDayStart, trendDays);
NotifyRosterState();
}
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
}
private void BuildRosterFromCachedData(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
IReadOnlyList<CachedUntisHoliday> holidays,
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
{
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
var recentRegisterEntries = classRegisterEvents
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
var localStudents = _students.GetAll();
var closedVorgaengeByNameKey = students
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.ToDictionary(
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
foreach (var student in students.OrderBy(s => s.DisplayName))
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
SelectedCalendarScope = CalendarScopes[0];
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);
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today);
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
[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 OpenMonthlyCalendar()
{
var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null);
}
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
CompactMonthDays.Add(day);
}
private void RebuildCompactMonthFromCachedData()
{
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
var today = DateOnly.FromDateTime(DateTime.Today);
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
_lastClassRegisterEvents, today);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
string? studentName = null)
{
var first = new DateOnly(month.Year, month.Month, 1);
if (!string.IsNullOrWhiteSpace(studentName))
{
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
}
var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
.ToList();
var result = new List<ClassTeacherCompactCalendarDay>();
var days = DateTime.DaysInMonth(first.Year, first.Month);
for (var number = 1; number <= days; number++)
{
var date = new DateOnly(first.Year, first.Month, number);
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
includeHomework: false);
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
var tooltip = events.Count == 0
? $"{date:dd.MM.yyyy}: unauffällig"
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
}
return result;
}
internal static string DisplayStudentName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{
ClassTeacherCalendarEventKind.Unexcused => 0,
ClassTeacherCalendarEventKind.ClassRegister => 1,
ClassTeacherCalendarEventKind.Absent => 2,
ClassTeacherCalendarEventKind.Late => 3,
ClassTeacherCalendarEventKind.Excused => 4,
_ => 5,
};
[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));
}
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
/// Default 3,0).
private const double RegressionThreshold = 3.0;
private void BuildVorgangRegressionNotices()
{
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
r.RegressionVorgangTitle is not null))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
}
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));
}
}