diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 6503e91..f6c3f95 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -9,6 +9,38 @@ namespace LehrerApp.Desktop.Tests; public sealed class ClassTeacherViewModelsTests { + [Theory] + [InlineData("Früher gegangen", 2, 90)] + [InlineData("Vorzeitig verlassen", 0, 20)] + public void FruehesGehen_IstTeilfehlzeitUndImKalenderGesondertErkennbar(string reason, int periods, int minutes) + { + var date = new DateOnly(2026, 9, 3); + var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, periods, minutes, + [], [], ["nicht entsch."], [reason], null, null, true); + + Assert.True(row.IsEarlyDeparture); + Assert.False(row.CountsAsFullDay); + Assert.False(row.IsLate); + Assert.True(row.IsPartialAbsence); + var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false)); + Assert.Equal("G", marker.Code); + Assert.Equal("#C62828", marker.ColorHex); + Assert.Contains("Früher gegangen", marker.Tooltip); + Assert.Contains("Unentschuldigt", marker.Tooltip); + Assert.DoesNotContain("Ganzer Fehltag", marker.Tooltip); + } + + [Fact] + public void Kalender_GanzeFehltageBehaltenEigenesKuerzel() + { + var date = new DateOnly(2026, 9, 3); + var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, 6, 270, + [], [], ["nicht entsch."], ["Absent"], null, null, true); + var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false)); + Assert.Equal("U", marker.Code); + Assert.Contains("Ganzer Fehltag", marker.Tooltip); + } + [Fact] public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus() { @@ -32,7 +64,7 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(0, days.Count % 5); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V"); - Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U"); + Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "T"); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!"); Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H"); @@ -65,10 +97,10 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(30, days.Count); Assert.False(days[0].HasSignal); Assert.Equal("1", days[0].DayNumber); - Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag + Assert.Equal("T", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag Assert.True(days[2].IsToday); Assert.Contains("Ada Müller", days[2].Tooltip); - Assert.Equal("E", days[3].SignalCode); + Assert.Equal("T", days[3].SignalCode); Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet } @@ -87,7 +119,7 @@ public sealed class ClassTeacherViewModelsTests var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month, "Ada Müller"); - Assert.Equal("U", days[2].SignalCode); + Assert.Equal("T", days[2].SignalCode); Assert.False(days[3].HasSignal); } @@ -107,7 +139,7 @@ public sealed class ClassTeacherViewModelsTests Assert.True(drawing.ContentHeight > 0); Assert.Contains(drawing.Commands.OfType(), c => c.Text == "Ada Müller"); - Assert.Contains(drawing.Commands.OfType(), c => c.Text == "U"); + Assert.Contains(drawing.Commands.OfType(), c => c.Text == "T"); Assert.DoesNotContain(drawing.Commands.OfType(), c => c.Text == "E"); } diff --git a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs index 9c8915b..d7feed3 100644 --- a/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs +++ b/LehrerApp.Desktop/Services/StudentAttendanceCalendarDrawingBuilder.cs @@ -151,7 +151,7 @@ public static class StudentAttendanceCalendarDrawingBuilder y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + designTrailingGapY * k; commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign, - "U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch", + "U/E/A ganztägig · T teilweise · G gegangen · V verspätet · ! Klassenbuch", DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280")); return new DrawingValue(commands, y + 9 * k); } diff --git a/LehrerApp.Desktop/Services/UntisReportCacheService.cs b/LehrerApp.Desktop/Services/UntisReportCacheService.cs index 274c6a7..0aeccac 100644 --- a/LehrerApp.Desktop/Services/UntisReportCacheService.cs +++ b/LehrerApp.Desktop/Services/UntisReportCacheService.cs @@ -34,7 +34,7 @@ public sealed class UntisReportCacheService( { private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); private const int HotWindowDays = 14; - private const int AbsenceReportVersion = 1; + private const int AbsenceReportVersion = 2; /// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer /// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch". diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index f36c5b3..680e3a0 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -66,6 +66,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName IReadOnlyList PeriodNumbers, IReadOnlyList Statuses, IReadOnlyList AbsenceReasons, string? Note, string? ExcuseNote, bool CountsAsFullDay) { + public bool CountsAsFullDay { get; init; } = CountsAsFullDay && !HasEarlyDepartureReason(AbsenceReasons); + public bool IsEarlyDeparture => HasEarlyDepartureReason(AbsenceReasons); + + private static bool HasEarlyDepartureReason(IEnumerable reasons) => reasons.Any(reason => + reason.Contains("vorzeitig", StringComparison.OrdinalIgnoreCase) || + ((reason.Contains("früh", StringComparison.OrdinalIgnoreCase) || + reason.Contains("frueh", StringComparison.OrdinalIgnoreCase)) && + (reason.Contains("geh", StringComparison.OrdinalIgnoreCase) || + reason.Contains("gegang", StringComparison.OrdinalIgnoreCase) || + reason.Contains("verlass", StringComparison.OrdinalIgnoreCase)))); + /// Nur für die Gewichtung von stundenweisen Fehlzeiten im Auffälligkeitsscore. Zeugnis- und /// Anzeigezahlen verwenden weiterhin die echten WebUntis-Felder CountsAsFullDay bzw. /// TotalAbsentPeriods und rechnen keine Stunden künstlich in Fehltage um. @@ -84,8 +95,8 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order()); public string StatusLabel => string.Join(", ", Statuses); public string ReasonLabel => string.Join("; ", AbsenceReasons); - public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r => - r.Contains("verspät", StringComparison.OrdinalIgnoreCase)); + public bool IsLate => !IsEarlyDeparture && (TotalAbsentPeriods == 0 || AbsenceReasons.Any(r => + r.Contains("verspät", StringComparison.OrdinalIgnoreCase))); public bool IsUnexcused => Statuses.Any(s => s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)); public bool IsPartialAbsence => !CountsAsFullDay && (!IsLate || IsUnexcused); @@ -93,7 +104,9 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName Math.Min(1d, (TotalAbsentPeriods > 0 ? TotalAbsentPeriods : TotalAbsentMinutes / 45d) / TypicalPeriodsPerSchoolDayForScoring); - public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" : + public string KindLabel => IsEarlyDeparture + ? $"Früher gegangen · {(TotalAbsentPeriods > 0 ? $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}" : $"{TotalAbsentMinutes} Fehlminuten")}" : + IsLate ? $"{TotalAbsentMinutes} Min. verspätet" : CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}"; public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" : Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel; @@ -410,6 +423,13 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject : absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) ? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused) : ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent); + // Umfang und Entschuldigungsstatus sind unabhängig: Rot kann auch zwei + // unentschuldigte Fehlstunden bedeuten, nicht zwingend einen ganzen Fehltag. + if (absence.IsPartialAbsence && !absence.IsLate) + code = absence.IsEarlyDeparture ? "G" : "T"; + else if (absence.IsLate) + code = "V"; + label = $"{absence.KindLabel} · {absence.FriendlyStatusLabel}"; events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind)); } diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs index cb164b2..087fa04 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherOverviewViewModel.cs @@ -50,11 +50,7 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b { if (TodayAbsence is null) return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit"; - var core = IsLate - ? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet" - : TodayAbsence.CountsAsFullDay - ? "Ganzer Fehltag" - : $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}"; + var core = TodayAbsence.KindLabel; return IsUnexcused ? $"{core} · Unentschuldigt" : core; } } diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml index 38801a4..7ae89bb 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherOverviewView.axaml @@ -432,7 +432,7 @@ HorizontalAlignment="Stretch" AutomationProperties.Name="Schüler für Monatskalender auswählen"/> - diff --git a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs index b72c587..2411a87 100644 --- a/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs +++ b/LehrerApp.WebUntis.Tests/WebUntisClientTests.cs @@ -293,7 +293,6 @@ public sealed class WebUntisClientTests Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri); Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri); Assert.Contains("studentId=-1", handler.Requests[1].Uri); - Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri); Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri); Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri); Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri); @@ -341,6 +340,7 @@ public sealed class WebUntisClientTests Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri); Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri); Assert.Contains("studentId=-1", handler.Requests[1].Uri); + Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri); Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri); Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri); Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri); diff --git a/LehrerApp.WebUntis/WebUntisClient.cs b/LehrerApp.WebUntis/WebUntisClient.cs index f5f911c..fbc7183 100644 --- a/LehrerApp.WebUntis/WebUntisClient.cs +++ b/LehrerApp.WebUntis/WebUntisClient.cs @@ -179,7 +179,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" + "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" + "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" + - "&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" + + "&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" + "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" + "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" + "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" + @@ -208,7 +208,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" + "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" + "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" + - "&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" + + "&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" + "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" + "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" + "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" + diff --git a/TODO.md b/TODO.md index 6eed030..4d8311a 100644 --- a/TODO.md +++ b/TODO.md @@ -1622,6 +1622,11 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler. Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut. - [x] **Klassenlehrer — einzelne Fehlstunden als ganze Fehltage (Oktober 2026):** + - Nachkorrektur: Der Trennparameter und seine Regression gehören zum Fehlzeitenbericht, + nicht zum gleich aufgebauten Klassenbuchbericht. Cacheversion 2 erneuert die alten Abrufe. + Explizites früheres Gehen im Abwesenheitsgrund zählt als Teilfehlzeit. Kalender zeigen + `G` für früheres Gehen und `T` für sonstige Teilfehlzeiten, mit Umfang/Status im Tooltip; + Rot kennzeichnet weiterhin unentschuldigte Fehlzeiten. Ganze Fehltage behalten U/E/A. Beim Berichtabruf ist `separateAbsentDays=true` jetzt tatsächlich aktiviert; der bisherige Parameter `_separateAbsentDays=on` allein aktivierte die Checkbox nicht. Alte Fehlzeiten-Caches werden über eine Berichtversion neu geladen und auch im historischen Bereich ersetzt.