Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten Ferienkalender für den Rest des Jahres. Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) - sonst wäre der falsche Wert wieder unbemerkt geblieben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -184,7 +184,8 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
Assert.True(row.HasYearSummary);
|
Assert.True(row.HasYearSummary);
|
||||||
Assert.Equal(2, row.YearAbsenceDayCount);
|
Assert.Equal(2, row.YearAbsenceDayCount);
|
||||||
@@ -192,9 +193,29 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Equal(10, row.YearAbsenceRatePercent);
|
Assert.Equal(10, row.YearAbsenceRatePercent);
|
||||||
Assert.Contains("10 %", row.YearSummaryLabel);
|
Assert.Contains("10 %", row.YearSummaryLabel);
|
||||||
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
Assert.Contains("2 von 20", row.YearSummaryTooltip);
|
||||||
|
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
|
||||||
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
|
||||||
|
{
|
||||||
|
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
|
||||||
|
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
|
||||||
|
var students = new[] { Student(1001, "Ada Müller") };
|
||||||
|
var yearAbsences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
|
||||||
|
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
|
||||||
|
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
|
||||||
|
termStart: new DateOnly(2026, 8, 1)));
|
||||||
|
|
||||||
|
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
|
||||||
{
|
{
|
||||||
@@ -290,6 +311,53 @@ public sealed class ClassTeacherViewModelsTests
|
|||||||
Assert.Equal(21, schoolDays);
|
Assert.Equal(21, schoolDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
|
||||||
|
{
|
||||||
|
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
|
||||||
|
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
|
||||||
|
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||||
|
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
|
||||||
|
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
|
||||||
|
// nötig statt CountSchoolWeekdays allein.
|
||||||
|
var absences = new[]
|
||||||
|
{
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
|
||||||
|
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
|
||||||
|
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
|
||||||
|
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
|
||||||
|
{
|
||||||
|
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
|
||||||
|
{
|
||||||
|
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
|
||||||
|
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
|
||||||
|
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
|
||||||
|
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
|
||||||
|
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
|
||||||
|
var termStart = new DateOnly(2026, 8, 13);
|
||||||
|
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
|
||||||
|
|
||||||
|
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
|
||||||
|
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
|
||||||
|
|
||||||
|
Assert.Equal(13, schoolDays);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -71,19 +71,29 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
|
||||||
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
|
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
|
||||||
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
|
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
|
||||||
public int YearAbsenceDayCount { get; init; }
|
public int YearAbsenceDayCount { get; init; }
|
||||||
public int YearUnexcusedDayCount { get; init; }
|
public int YearUnexcusedDayCount { get; init; }
|
||||||
public int SchoolDaysElapsed { get; init; }
|
public int SchoolDaysElapsed { get; init; }
|
||||||
|
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
|
||||||
|
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
|
||||||
|
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
|
||||||
|
public int HolidayWeekdaysExcluded { get; init; }
|
||||||
|
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
|
||||||
|
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
|
||||||
|
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
|
||||||
|
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
|
||||||
|
public DateOnly TermStart { get; init; }
|
||||||
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
|
||||||
public int YearAbsenceRatePercent =>
|
public int YearAbsenceRatePercent =>
|
||||||
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
|
||||||
public string YearSummaryLabel => HasYearSummary
|
public string YearSummaryLabel => HasYearSummary
|
||||||
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
|
||||||
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
public string? YearSummaryTooltip => !HasYearSummary ? null :
|
||||||
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
|
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
|
||||||
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
|
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
|
||||||
|
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||||
|
|
||||||
private static bool IsLateReason(string reason) =>
|
private static bool IsLateReason(string reason) =>
|
||||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -94,7 +104,9 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
|
||||||
DateOnly? today = null,
|
DateOnly? today = null,
|
||||||
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
|
||||||
int schoolDaysElapsed = 0)
|
int schoolDaysElapsed = 0,
|
||||||
|
int holidayWeekdaysExcluded = 0,
|
||||||
|
DateOnly termStart = default)
|
||||||
{
|
{
|
||||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||||
@@ -126,6 +138,8 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
|||||||
YearAbsenceDayCount = yearEntries.Count,
|
YearAbsenceDayCount = yearEntries.Count,
|
||||||
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
|
||||||
SchoolDaysElapsed = schoolDaysElapsed,
|
SchoolDaysElapsed = schoolDaysElapsed,
|
||||||
|
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||||
|
TermStart = termStart,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
|
||||||
@@ -202,6 +216,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
private readonly IParticipationSessionRepository _participationSessions;
|
private readonly IParticipationSessionRepository _participationSessions;
|
||||||
|
private readonly AppLogger? _logger;
|
||||||
|
|
||||||
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
public ClassTeacherDetailsViewModel DetailsTab { get; }
|
||||||
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
|
||||||
@@ -275,7 +290,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
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,
|
||||||
|
AppLogger? logger = null)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_untis = untis;
|
_untis = untis;
|
||||||
@@ -285,6 +301,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
_students = students;
|
_students = students;
|
||||||
_participation = participation;
|
_participation = participation;
|
||||||
_participationSessions = participationSessions;
|
_participationSessions = participationSessions;
|
||||||
|
_logger = logger;
|
||||||
DetailsTab = detailsTab;
|
DetailsTab = detailsTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,9 +352,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
|
|
||||||
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 schoolDaysElapsed = CountSchoolWeekdays(yearStart, today, holidaysTask.Result);
|
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
|
||||||
|
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
|
||||||
|
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
|
||||||
|
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||||
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,
|
||||||
|
holidayWeekdaysExcluded, termStart)) Roster.Add(row);
|
||||||
|
|
||||||
StudentCount = Roster.Count;
|
StudentCount = Roster.Count;
|
||||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||||
@@ -456,28 +477,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
return days;
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
|
|
||||||
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
|
|
||||||
private static int CountWeekdays(DateOnly start, DateOnly end)
|
|
||||||
{
|
|
||||||
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)) count++;
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
|
||||||
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
|
||||||
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
|
||||||
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
|
||||||
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
|
/// 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
|
/// 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
|
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
|
||||||
/// Werktagen ausgeschlossen statt wie bisher grob geschätzt) — behebt zusätzlich zum
|
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
|
||||||
/// Schuljahresbeginn auch die kleineren Verzerrungen durch Herbst-/Weihnachts-/Osterferien
|
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
|
||||||
/// im weiteren Jahresverlauf (bislang bewusst hingenommene Vereinfachung, siehe TODO.md
|
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
|
||||||
/// "Klassenlehrer"-Feature — Trend & Fehlquote).
|
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
|
||||||
|
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
|
||||||
|
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
|
||||||
|
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
|
||||||
|
/// innerhalb des Zeitraums abzuziehen.
|
||||||
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
|
||||||
{
|
{
|
||||||
if (end < start) return 0;
|
if (end < start) return 0;
|
||||||
@@ -489,6 +503,16 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
|
||||||
|
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
|
||||||
|
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
|
||||||
|
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
|
||||||
|
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
|
||||||
|
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
|
||||||
|
/// ohnehin ausgeblendet).
|
||||||
|
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
|
||||||
|
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
|
||||||
|
|
||||||
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
|
||||||
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
|
||||||
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
|
||||||
@@ -511,10 +535,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
|||||||
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
|
||||||
.ToList();
|
.ToList();
|
||||||
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
|
||||||
|
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
|
||||||
|
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
catch (WebUntisIntegrationException)
|
catch (WebUntisIntegrationException ex)
|
||||||
{
|
{
|
||||||
|
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
|
||||||
return cached ?? [];
|
return cached ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1704,30 +1704,45 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
|||||||
tatsächlichen Sommerferien enden je nach Bundesland/Jahr aber erst Wochen später, und kurz nach
|
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"
|
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
|
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
|
großer Nenner). Mehrstufig gelöst, jede Stufe durch Nutzer-Feedback ausgelöst:
|
||||||
als Näherung für den ersten Unterrichtstag) kam ohne zusätzlichen WebUntis-Abruf aus, war aber
|
1. Erster Versuch: `EstimateTermStart` (frühester Fehlzeiten-Eintrag der ganzen Klasse als
|
||||||
nur eine Näherung. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits
|
Näherung für den tatsächlichen ersten Unterrichtstag) — ohne zusätzlichen WebUntis-Abruf, aber
|
||||||
(`getHolidays`-Bericht, in `LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon
|
nur eine Näherung.
|
||||||
implementiert, aber bis dahin nirgends im Desktop verdrahtet) — `EstimateTermStart` durch den
|
2. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits (`getHolidays`-Bericht, in
|
||||||
echten Kalender ersetzt:
|
`LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync` schon implementiert, aber bis dahin
|
||||||
- `WebUntisIntegrationService.GetHolidaysAsync` (neuer `UntisHolidayDto`) als dünner Wrapper,
|
nirgends im Desktop verdrahtet). Verdrahtet über `WebUntisIntegrationService.GetHolidaysAsync`
|
||||||
gleiches Muster wie `GetSchoolYearsAsync`.
|
(neuer `UntisHolidayDto`, gleiches Muster wie `GetSchoolYearsAsync`), gecacht (nicht über die
|
||||||
- Cache dafür bewusst nicht über die LiteDB-Tabellen von `UntisReportCacheService` (deren
|
LiteDB-Tabellen von `UntisReportCacheService` — deren heißes/kaltes Fenster ist auf sich
|
||||||
heißes/kaltes Fenster ist auf sich laufend ändernde Fehlzeiten/Klassenbucheinträge
|
laufend ändernde Fehlzeiten zugeschnitten, Ferien ändern sich dagegen innerhalb eines
|
||||||
zugeschnitten) — Ferien ändern sich innerhalb eines Schuljahrs praktisch nie. Stattdessen
|
Schuljahrs praktisch nie — sondern einfacher tagesgenauer Cache direkt in
|
||||||
einfacher tagesgenauer Cache direkt in `WebUntisSettingsService`
|
`WebUntisSettingsService`: `CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt,
|
||||||
(`CachedUntisHoliday`-Liste + `HolidaysFetchedAt`, unverschlüsselt, kein Geheimnis anders als
|
kein Geheimnis anders als iCal-URL/API-Zugangsdaten in derselben Datei). Neue
|
||||||
iCal-URL/API-Zugangsdaten in derselben Datei). Schlägt der Live-Abruf fehl, wird der zuletzt
|
`ClassTeacherOverviewViewModel.CountSchoolWeekdays` zieht Ferienzeiträume von der
|
||||||
bekannte Stand weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren.
|
Werktagszählung ab.
|
||||||
- `ClassTeacherOverviewViewModel.CountSchoolWeekdays` (ersetzt `CountWeekdays`) zählt Werktage
|
3. **Nutzer-Verifikation deckte auf: `getHolidays` liefert für dieses Konto nie einen
|
||||||
zwischen Schuljahresbeginn und heute abzüglich aller Ferienzeiträume — behebt damit nicht nur
|
Sommerferien-Eintrag** — geprüft anhand der tatsächlich gecachten Antwort (179 Einträge,
|
||||||
die große Verzerrung kurz nach Schuljahresbeginn, sondern nebenbei auch die bislang akzeptierte
|
11 Jahre Historie ab 2015): Herbst-/Weihnachts-/Osterferien und einzelne bewegliche
|
||||||
kleinere Ungenauigkeit durch Herbst-/Weihnachts-/Osterferien im weiteren Jahresverlauf (oben
|
Ferientage sind lückenlos dabei, aber kein einziger Juli-/August-Zeitraum, in keinem der
|
||||||
unter "Trend & Fehlquote" noch als bewusste Vereinfachung dokumentiert — damit erledigt).
|
11 Jahre. Vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
|
||||||
Reine, ohne ViewModel-Zustand testbare Kernlogik, gleiches Muster wie
|
Schuljahres-Datensätzen liegen (die bei 1.8./31.7. enden) statt "in" einem davon — WebUntis
|
||||||
`DetectWeekdayPatterns`/`DetectLatePatterns`.
|
scheint sie deshalb keinem Schuljahr zuzuordnen. `CountSchoolWeekdays` (Ferien innerhalb eines
|
||||||
Neue Abhängigkeit `WebUntisIntegrationService` in `ClassTeacherOverviewViewModel` (bereits als
|
Zeitraums abziehen) kann diese Lücke also grundsätzlich nicht schließen, unabhängig von
|
||||||
Singleton registriert, nur Konstruktor-Injection ergänzt).
|
Caching oder Implementierung. `EstimateTermStart` bleibt deshalb zusätzlich bestehen (behebt
|
||||||
|
die Sommerferien-Lücke am Startpunkt), `CountSchoolWeekdays` läuft ab diesem geschätzten
|
||||||
|
Starttag (behebt Herbst-/Weihnachts-/Osterferien & bewegliche Ferientage innerhalb des
|
||||||
|
restlichen Jahres — oben unter "Trend & Fehlquote" noch als bewusste Vereinfachung
|
||||||
|
dokumentiert, für den Teil jetzt erledigt). Beide Kombinationen als Regressionstest
|
||||||
|
festgehalten (`CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein`)
|
||||||
|
mit den vom Nutzer nachgezählten echten Werten (13 Schultage seit 13.08.2026).
|
||||||
|
- Herkunft des Nenners jetzt direkt im Tooltip nachvollziehbar statt nur intern verrechnet:
|
||||||
|
`ClassTeacherRosterRow.TermStart` ("seit dd.MM.") und `HolidayWeekdaysExcluded`
|
||||||
|
("X Ferientage abgezogen") — Lehre aus diesem Vorfall, bei dem der falsche Wert sonst erneut
|
||||||
|
unbemerkt geblieben wäre.
|
||||||
|
- `AppLogger` (optional, DI) protokolliert jeden `GetHolidaysAsync`-Aufruf: bei Erfolg Name +
|
||||||
|
Zeitraum jedes geladenen Ferieneintrags, bei Fehlschlag die Exception — nächster Diagnoseschritt
|
||||||
|
wäre sonst wieder nur stilles Rätselraten gewesen.
|
||||||
|
Neue Abhängigkeiten `WebUntisIntegrationService`, `AppLogger?` in `ClassTeacherOverviewViewModel`
|
||||||
|
(beide bereits als Singleton registriert, nur Konstruktor-Injection ergänzt).
|
||||||
**Nicht behoben, weil mit den vorhandenen Daten nicht erkennbar:** Kolleg*innen, die die
|
**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
|
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
|
Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus
|
||||||
|
|||||||
Reference in New Issue
Block a user