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);
}
// ── 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) ─────────────────────────────
[Fact]
@@ -290,6 +316,87 @@ public sealed class ClassTeacherViewModelsTests
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) ──────────────────
[Fact]
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message);
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 UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds)
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(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(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, 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? HomeroomClassUntisId { 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);
/// <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>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
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()
{
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";
}
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 &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 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
@@ -439,7 +439,7 @@
<ItemsControl ItemsSource="{Binding PatternNotices}">
<ItemsControl.ItemTemplate>
<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"
Classes.info="{Binding IsInfoStatus}"
Classes.warning="{Binding IsWarningStatus}"
@@ -448,6 +448,11 @@
<TextBlock Text="{Binding StudentName}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" FontSize="11" Opacity="0.62" TextWrapping="Wrap"/>
</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>
</DataTemplate>
</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
als Singleton registriert, nur Konstruktor-Injection ergänzt).
**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
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3