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:
@@ -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 & 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“ anlegen"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:ClassTeacherOverviewViewModel)DataContext).CreateReminderForPatternNoticeCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user