Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
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:
2026-08-31 11:56:35 +02:00
co-authored by Claude Sonnet 5
parent 527c186090
commit cdbed69d8b
6 changed files with 304 additions and 21 deletions
@@ -264,6 +264,32 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(row.StatusText, row.StatusTextWithGlyph); Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
} }
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ───────────────────────────── // ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact] [Fact]
@@ -290,6 +316,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(rows[1].IsOverdue); Assert.False(rows[1].IsOverdue);
} }
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ────────────────── // ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact] [Fact]
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message); public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate); public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName); public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title, public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds) bool Active, IReadOnlyList<int> DepartmentUntisIds)
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token)) async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token); .Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync( public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token)) async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token); .Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
public int? TeacherUntisId { get; set; } public int? TeacherUntisId { get; set; }
public int? HomeroomClassUntisId { get; set; } public int? HomeroomClassUntisId { get; set; }
public string? HomeroomClassName { get; set; } public string? HomeroomClassName { get; set; }
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
public DateTime? HolidaysFetchedAt { get; set; }
} }
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password); public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
/// <summary> /// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie /// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die /// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
Save(); Save();
} }
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
{
_config.CachedHolidays = holidays.ToList();
_config.HolidaysFetchedAt = at;
Save();
}
private byte[] GenerateAndSaveKey() private byte[] GenerateAndSaveKey()
{ {
var key = SyncCrypto.GenerateKey(); var key = SyncCrypto.GenerateKey();
@@ -157,7 +157,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt"; $"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
} }
public sealed record ClassTeacherPatternNotice(string StudentName, string Message, public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind) ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
{ {
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger; public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning; public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
@@ -182,7 +182,10 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build( public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) => 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, .Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
today.DayNumber - a.Date.DayNumber)) today.DayNumber - a.Date.DayNumber))
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName) .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 public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis;
private readonly UntisReportCacheService _cache; private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear; private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks; private readonly IWorkTaskRepository _workTasks;
@@ -268,12 +272,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; } public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks, UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation, IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab) IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
{ {
_settings = settings; _settings = settings;
_untis = untis;
_cache = cache; _cache = cache;
_schoolYear = schoolYear; _schoolYear = schoolYear;
_workTasks = workTasks; _workTasks = workTasks;
@@ -325,12 +330,12 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var studentsTask = _cache.GetStudentRosterAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today); var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today); 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 absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList(); var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList(); var schoolDaysElapsed = CountSchoolWeekdays(yearStart, today, holidaysTask.Result);
var schoolDaysElapsed = CountWeekdays(yearStart, today);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences, foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row); 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); UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count; RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDaysYear, trendDays); BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(weekAbsenceDays); BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear); BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices(); BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today); BuildOpenExcuses(absenceDaysYear, today);
@@ -462,23 +467,106 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
return count; 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 &amp; 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 rows = group.ToList();
var displayName = Roster.FirstOrDefault(row => var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName; var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
var lateDays = rows.Count(row => row.IsLate); var lateDaysWeek = weekRows.Count(row => row.IsLate);
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate); var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2) var lateDaysYear = rows.Count(row => row.IsLate);
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger)); if (unexcusedDaysWeek >= 2)
else if (lateDays >= 2) notices.Add(new ClassTeacherPatternNotice(displayName,
PatternNotices.Add(new ClassTeacherPatternNotice(displayName, $"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning)); 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 /// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
@@ -635,6 +723,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
urgent: row.IsOverdue); 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) private void SaveReminder(string studentName, string notes, bool urgent)
{ {
var task = new WorkTask var task = new WorkTask
@@ -439,7 +439,7 @@
<ItemsControl ItemsSource="{Binding PatternNotices}"> <ItemsControl ItemsSource="{Binding PatternNotices}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ClassTeacherPatternNotice"> <DataTemplate x:DataType="vm:ClassTeacherPatternNotice">
<Grid ColumnDefinitions="4,*" Margin="0,3"> <Grid ColumnDefinitions="4,*,Auto" Margin="0,3">
<Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill" <Border CornerRadius="2" Margin="0,1,8,1" Classes="statusFill"
Classes.info="{Binding IsInfoStatus}" Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}" Classes.warning="{Binding IsWarningStatus}"
@@ -448,6 +448,11 @@
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/> <TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/> <TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<Button Grid.Column="2" Classes="rosterRowAction" Content="+"
IsVisible="{Binding CanCreateReminder}"
ToolTip.Tip="Wiedervorlage „Eltern kontaktieren&#8220; anlegen"
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForPatternNoticeCommand}"
CommandParameter="{Binding}"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
+54
View File
@@ -1679,6 +1679,60 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
`IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon `IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon
als Singleton registriert, nur Konstruktor-Injection ergänzt). als Singleton registriert, nur Konstruktor-Injection ergänzt).
**Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug. **Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug.
- [x] **"Klassenlehrer"-Feature — Verspätungen nicht mehr als fehlende Entschuldigung, sondern als
eigenes Muster (August 2026):** Nutzer-Feedback: "Offene Entschuldigungen" erinnerte auch bei
Verspätungen, die aber i.d.R. gar nicht entschuldigungsfähig sind — die Erinnerung "Entschuldigung
fehlt" war hier gegenstandslos.
- `ClassTeacherOpenExcuseRow.Build` filtert jetzt zusätzlich `!a.IsLate` heraus — Verspätungen
tauchen nicht mehr in der Karte "Offene Entschuldigungen" auf und lösen darüber auch keine
Wiedervorlage mehr aus.
- Stattdessen zweite Eskalationsstufe in der Mustererkennung: die bestehende Kurzfrist-Regel
(≥2 Verspätungen in 7 Tagen → Warning-Hinweis ohne Aktion) bleibt, kommt aber erst zum Zug,
wenn eine neue Jahresschwelle (`LateYearEscalationThreshold = 5`, Verspätungen seit
Schuljahresbeginn) nicht bereits gerissen ist — dann Danger-Hinweis "Elterngespräch oder Brief
erwägen" mit eigenem "+"-Button (`CreateReminderForPatternNoticeCommand`, legt wie bei "Offene
Entschuldigungen" eine `WorkTask` an). Kernlogik aus dem bisher privaten `BuildPatternNotices`
in ein neues öffentliches, statisches `DetectLatePatterns` gezogen (gleiches Muster wie
`DetectWeekdayPatterns`), damit die Schwellenwerte ohne ViewModel-Instanz testbar sind.
`ClassTeacherPatternNotice` hat dafür ein neues `CanCreateReminder`-Flag (Default `false`,
steuert Sichtbarkeit des "+"-Buttons in der XAML).
- [x] **"Klassenlehrer"-Feature — Fehlquote seit Schuljahresbeginn zu niedrig kurz nach Schuljahres-
start (August 2026):** Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an
jedem Tag fehlten, zeigten trotzdem nur ~57 % statt der erwarteten ~100 % in
`ClassTeacherRosterRow.YearSummaryLabel`. Ursache: der Nenner (`SchoolDaysElapsed`) zählte
Werktage ab dem fest verdrahteten 1. August (`SchoolYearService.SchoolYearStart`) — die
tatsächlichen Sommerferien enden je nach Bundesland/Jahr aber erst Wochen später, und kurz nach
Schuljahresbeginn macht diese Restferienzeit einen großen Teil des bis dahin "verstrichenen"
Zeitraums aus (rechnerisch: absolut korrekte Fehlzeiten-Zähler, aber ein um mehrere Wochen zu
großer Nenner). Erster Fix-Versuch (`EstimateTermStart`, frühester Fehlzeiten-Eintrag der Klasse
als Näherung für den ersten Unterrichtstag) kam ohne zusätzlichen WebUntis-Abruf aus, war aber
nur eine Näherung. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits
(`getHolidays`-Bericht, in `LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon
implementiert, aber bis dahin nirgends im Desktop verdrahtet) — `EstimateTermStart` durch den
echten Kalender ersetzt:
- `WebUntisIntegrationService.GetHolidaysAsync` (neuer `UntisHolidayDto`) als dünner Wrapper,
gleiches Muster wie `GetSchoolYearsAsync`.
- Cache dafür bewusst nicht über die LiteDB-Tabellen von `UntisReportCacheService` (deren
heißes/kaltes Fenster ist auf sich laufend ändernde Fehlzeiten/Klassenbucheinträge
zugeschnitten) — Ferien ändern sich innerhalb eines Schuljahrs praktisch nie. Stattdessen
einfacher tagesgenauer Cache direkt in `WebUntisSettingsService`
(`CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt, kein Geheimnis anders als
iCal-URL/API-Zugangsdaten in derselben Datei). Schlägt der Live-Abruf fehl, wird der zuletzt
bekannte Stand weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren.
- `ClassTeacherOverviewViewModel.CountSchoolWeekdays` (ersetzt `CountWeekdays`) zählt Werktage
zwischen Schuljahresbeginn und heute abzüglich aller Ferienzeiträume — behebt damit nicht nur
die große Verzerrung kurz nach Schuljahresbeginn, sondern nebenbei auch die bislang akzeptierte
kleinere Ungenauigkeit durch Herbst-/Weihnachts-/Osterferien im weiteren Jahresverlauf (oben
unter "Trend & Fehlquote" noch als bewusste Vereinfachung dokumentiert — damit erledigt).
Reine, ohne ViewModel-Zustand testbare Kernlogik, gleiches Muster wie
`DetectWeekdayPatterns`/`DetectLatePatterns`.
Neue Abhängigkeit `WebUntisIntegrationService` in `ClassTeacherOverviewViewModel` (bereits als
Singleton registriert, nur Konstruktor-Injection ergänzt).
**Nicht behoben, weil mit den vorhandenen Daten nicht erkennbar:** Kolleg*innen, die die
Anwesenheitsliste nur sporadisch führen, drücken die Quote auf dieselbe Weise (fehlende
Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus
dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob
für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
### 4.4 Wochen-/Tagesansicht ### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3