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
@@ -155,6 +155,14 @@ public interface IUntisClassRegisterCacheRepository
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries); void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries); void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
} }
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
public interface IUntisStudentRosterCacheRepository
{
List<UntisStudentRosterCacheEntry> GetByClass(string className);
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
}
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit /// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService). /// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
public interface IUntisCacheFetchStateRepository public interface IUntisCacheFetchStateRepository
+15 -1
View File
@@ -46,7 +46,21 @@ public class UntisClassRegisterCacheEntry
public string? Text { get; set; } public string? Text { get; set; }
} }
public enum UntisCacheKind { Absences, ClassRegister } /// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
public class UntisStudentRosterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int? ExternKey { get; set; }
public string DisplayName { get; set; } = "";
}
public enum UntisCacheKind { Absences, ClassRegister, Roster }
/// <summary> /// <summary>
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster /// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
+17
View File
@@ -1108,6 +1108,23 @@ public sealed class RepositoryTests
Assert.Equal("mueller", result.TeacherUsername); Assert.Equal("mueller", result.TeacherUsername);
} }
[Fact]
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
{
using var db = NewInMemoryContext();
var repo = new UntisStudentRosterCacheRepository(db);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
var tenC = repo.GetByClass("10c");
var tenD = repo.GetByClass("10d");
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
Assert.Equal(42, tenC[0].ExternKey);
Assert.Single(tenD); // andere Klasse bleibt unberührt
}
[Fact] [Fact]
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt() public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
{ {
+2
View File
@@ -71,6 +71,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache"); public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache"); public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state"); public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events"); public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash"); public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
@@ -542,6 +543,7 @@ public class LiteDbContext : IDisposable
UntisAbsenceCache.EnsureIndex(x => x.Date); UntisAbsenceCache.EnsureIndex(x => x.Date);
UntisClassRegisterCache.EnsureIndex(x => x.ClassName); UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
UntisClassRegisterCache.EnsureIndex(x => x.Date); UntisClassRegisterCache.EnsureIndex(x => x.Date);
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
UntisCacheFetchStates.EnsureIndex("ux_class_kind", UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true); BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
} }
@@ -830,6 +830,21 @@ public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRe
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries); public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
} }
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
// selbst ab.
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
{
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
db.UntisStudentRosterCache.InsertBulk(entries);
}
}
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
{ {
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) => public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
@@ -1,3 +1,4 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher; using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit; using Xunit;
@@ -125,10 +126,6 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(ben.HasRecentClassRegisterEntry); Assert.True(ben.HasRecentClassRegisterEntry);
} }
private static UntisStudentDto Student(int? externKey, string displayName) => new( private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
UntisId: externKey ?? 0, ExternKey: externKey, ClassName: "6a", Name: displayName, LongName: null, new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
ForeName: null, DisplayName: displayName, Gender: null, BirthDate: null, BirthDateRaw: null,
EntryDate: null, EntryDateRaw: null, ExitDate: null, ExitDateRaw: null, Text: null,
MedicalReportDuty: null, Schulpflicht: null, Majority: null,
Address: new UntisStudentAddressDto(null, null, null, null, null, null), AttributeIL: null);
} }
+12
View File
@@ -500,6 +500,18 @@ public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries); public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
} }
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
{
private readonly List<UntisStudentRosterCacheEntry> _all = [];
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
_all.Where(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className);
_all.AddRange(entries);
}
}
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
{ {
private readonly List<UntisCacheFetchState> _all = []; private readonly List<UntisCacheFetchState> _all = [];
@@ -144,8 +144,9 @@ public sealed class UntisReportCacheServiceTests
var absenceCache = new FakeUntisAbsenceCache(); var absenceCache = new FakeUntisAbsenceCache();
var classRegisterCache = new FakeUntisClassRegisterCache(); var classRegisterCache = new FakeUntisClassRegisterCache();
var rosterCache = new FakeUntisStudentRosterCache();
var fetchStates = new FakeUntisCacheFetchStates(); var fetchStates = new FakeUntisCacheFetchStates();
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, fetchStates); var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates);
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
var first = await cache.GetClassRegisterEventsAsync("10c", today, today); var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
@@ -157,6 +158,35 @@ public sealed class UntisReportCacheServiceTests
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
} }
[Fact]
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var csv = Encoding.UTF8.GetBytes(
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
"1\t9001\t10c\tMUST\tMustermann\tMax\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
var settings = TestSupport.BuildWebUntisSettingsService();
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
var first = await cache.GetStudentRosterAsync("10c");
Assert.Single(first);
Assert.Equal("Max Mustermann", first[0].DisplayName);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetStudentRosterAsync("10c");
Assert.Single(second);
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK) private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{ {
Content = new StringContent(json, Encoding.UTF8, "application/json"), Content = new StringContent(json, Encoding.UTF8, "application/json"),
+1
View File
@@ -172,6 +172,7 @@ public static class AppBootstrapper
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>(); services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>(); services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>(); services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -29,11 +29,39 @@ public sealed class UntisReportCacheService(
WebUntisIntegrationService untis, WebUntisIntegrationService untis,
IUntisAbsenceCacheRepository absenceCache, IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository classRegisterCache, IUntisClassRegisterCacheRepository classRegisterCache,
IUntisStudentRosterCacheRepository rosterCache,
IUntisCacheFetchStateRepository fetchState) IUntisCacheFetchStateRepository fetchState)
{ {
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14; 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, public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default) DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{ {
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher; 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 /// (siehe UntisForeignClassRegisterEventDto) — WebUntis liefert Namen dort in anderer
/// Reihenfolge als im Schülerreport.</summary> /// Reihenfolge als im Schülerreport.</summary>
public static IReadOnlyList<ClassTeacherRosterRow> Build( public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentDto> students, IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences, IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries) IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries)
{ {
@@ -64,7 +65,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
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;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
@@ -81,10 +81,10 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
/// keine Klasse gewählt ist. /// keine Klasse gewählt ist.
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab) 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)); partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
@@ -107,13 +107,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
try try
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
var studentsTask = _untis.GetStudentsAsync(className); var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, today, today); var absencesTask = _cache.GetAbsencesAsync(className, today, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today); var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, today.AddDays(-7), today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask); await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
var todayAbsences = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result); 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)) classRegisterTask.Result))
Roster.Add(row); Roster.Add(row);
Status = $"{Roster.Count} Schüler*innen · Stand heute."; Status = $"{Roster.Count} Schüler*innen · Stand heute.";
+11
View File
@@ -1385,6 +1385,17 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
(dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem (dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem
reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung
ausgelagert, nicht Teil dieser Änderung. ausgelagert, nicht Teil dieser Änderung.
- Die Klassenlehrer-Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten und
Klassenbuch schon gecacht waren (Nutzer-Feedback: "fühlt sich an wie ein Live-Pull mit
CSV-Parsing"). Ursache: genau das passierte tatsächlich — der Schülerreport
(`GetStudentsAsync`, die Namen fürs Roster) lief komplett am Cache vorbei live gegen WebUntis,
bei jedem einzelnen Öffnen. Neues `UntisStudentRosterCacheEntry`/
`UntisStudentRosterCacheRepository` (gleiches "kein db.OnChange"-Prinzip wie die anderen beiden
Caches) plus `UntisReportCacheService.GetStudentRosterAsync` — anders als Fehlzeiten/Klassenbuch
ohne heißes/kaltes Fenster, da eine Klassenliste keine Historie hat, sondern nur "gilt der
letzte Abruf noch als frisch" (dieselbe Stundenschwelle). `ClassTeacherRosterRow.Build` nimmt
jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur
`ExternKey`/`DisplayName` wurden je gebraucht).
### 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