Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option. Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -157,7 +157,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
|
||||
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
|
||||
}
|
||||
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
|
||||
ClassTeacherStatusKind Kind)
|
||||
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
|
||||
{
|
||||
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
|
||||
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
|
||||
@@ -182,7 +182,10 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
|
||||
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
|
||||
absenceDays.Where(a => a.IsUnexcused)
|
||||
// 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)
|
||||
@@ -192,6 +195,7 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
|
||||
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;
|
||||
@@ -268,12 +272,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
public Func<Task>? OnNavigateToSettings { get; set; }
|
||||
public Func<Task>? OnNavigateToWorkload { get; set; }
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
|
||||
{
|
||||
_settings = settings;
|
||||
_untis = untis;
|
||||
_cache = cache;
|
||||
_schoolYear = schoolYear;
|
||||
_workTasks = workTasks;
|
||||
@@ -325,12 +330,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
var studentsTask = _cache.GetStudentRosterAsync(className);
|
||||
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
|
||||
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
|
||||
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
|
||||
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 weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
|
||||
var schoolDaysElapsed = CountWeekdays(yearStart, today);
|
||||
var schoolDaysElapsed = CountSchoolWeekdays(yearStart, today, holidaysTask.Result);
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
|
||||
|
||||
@@ -343,7 +348,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
|
||||
RecentClassRegisterCount = classRegisterTask.Result.Count;
|
||||
BuildTrend(absenceDaysYear, trendDays);
|
||||
BuildPatternNotices(weekAbsenceDays);
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
@@ -462,23 +467,106 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
return count;
|
||||
}
|
||||
|
||||
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
|
||||
/// 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 den echten Ferienkalender bereits (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||
/// Werktagen ausgeschlossen statt wie bisher grob geschätzt) — behebt zusätzlich zum
|
||||
/// Schuljahresbeginn auch die kleineren Verzerrungen durch Herbst-/Weihnachts-/Osterferien
|
||||
/// im weiteren Jahresverlauf (bislang bewusst hingenommene Vereinfachung, siehe TODO.md
|
||||
/// "Klassenlehrer"-Feature — Trend & Fehlquote).
|
||||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||
{
|
||||
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
return fresh;
|
||||
}
|
||||
catch (WebUntisIntegrationException)
|
||||
{
|
||||
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 = Roster.FirstOrDefault(row =>
|
||||
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
|
||||
var lateDays = rows.Count(row => row.IsLate);
|
||||
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
|
||||
if (unexcusedDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
|
||||
else if (lateDays >= 2)
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
|
||||
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
|
||||
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));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
return notices;
|
||||
}
|
||||
|
||||
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
|
||||
@@ -635,6 +723,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user