fix: Klassenlehrer-Übersicht rief Schülerreport bislang live statt gecacht ab

Die Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten
und Klassenbuch schon lokal gecacht waren (Nutzer-Feedback: "fühlt sich an
wie ein Live-Pull mit CSV-Parsing"). Grund: genau das passierte - der
Schülerreport (Namen fürs Roster) lief komplett am Cache vorbei bei jedem
Öffnen live gegen WebUntis.

Neues UntisStudentRosterCacheEntry/UntisStudentRosterCacheRepository
(gleiches "kein db.OnChange"-Prinzip wie die bestehenden Caches) plus
UntisReportCacheService.GetStudentRosterAsync - ohne heißes/kaltes Fenster,
da eine Klassenliste keine Historie hat, nur dieselbe Stundenschwelle
"gilt der letzte Abruf noch als frisch".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:18:53 +02:00
co-authored by Claude Sonnet 5
parent 69cc16bbd2
commit 0c8bb58e1b
12 changed files with 149 additions and 14 deletions
+1
View File
@@ -172,6 +172,7 @@ public static class AppBootstrapper
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -29,11 +29,39 @@ public sealed class UntisReportCacheService(
WebUntisIntegrationService untis,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository classRegisterCache,
IUntisStudentRosterCacheRepository rosterCache,
IUntisCacheFetchStateRepository fetchState)
{
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14;
/// 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".
/// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der
/// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback).
public async Task<IReadOnlyList<UntisStudentRosterCacheEntry>> GetStudentRosterAsync(string className,
bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Roster);
if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow))
{
var fresh = await untis.GetStudentsAsync(className, token);
rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry
{
ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName,
}));
fetchState.Save(new UntisCacheFetchState
{
Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster,
HotWindowFetchedAt = DateTime.UtcNow,
});
}
return rosterCache.GetByClass(className);
}
private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) =>
forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval;
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
@@ -22,7 +23,7 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
/// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer
/// Reihenfolge als im Schülerreport.</summary>
public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentDto> students,
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries)
{
@@ -64,7 +65,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis;
private readonly UntisReportCacheService _cache;
public ClassTeacherDetailsViewModel DetailsTab { get; }
@@ -81,10 +81,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
/// keine Klasse gewählt ist.
public Func<Task>? OnNavigateToSettings { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab)
{
_settings = settings; _untis = untis; _cache = cache; DetailsTab = detailsTab;
_settings = settings; _cache = cache; DetailsTab = detailsTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
@@ -107,13 +107,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var studentsTask = _untis.GetStudentsAsync(className);
var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, today, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result.Students, todayAbsences,
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result))
Roster.Add(row);
Status = $"{Roster.Count} Schüler*innen · Stand heute.";