WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge

Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes
Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu
abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro
Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge,
bei WebUntis aufzufallen).

Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den
bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert
(gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) -
jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr
gewollt an.

UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens
stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird
dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine,
ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten
nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein
zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe.

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:10:01 +02:00
co-authored by Claude Sonnet 5
parent 056e864edd
commit 2b29dea824
13 changed files with 639 additions and 18 deletions
@@ -136,6 +136,32 @@ public interface IUntisSnapshotRepository
void Save(UntisSnapshotEntry entry); void Save(UntisSnapshotEntry entry);
void Delete(Guid id); void Delete(Guid id);
} }
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
public interface IUntisAbsenceCacheRepository
{
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
}
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
public interface IUntisClassRegisterCacheRepository
{
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
}
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
public interface IUntisCacheFetchStateRepository
{
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup. /// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository public interface IUntisSlotMappingRepository
{ {
+65
View File
@@ -0,0 +1,65 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
/// direkt für Bereichs-Queries nutzbar.
/// </summary>
public class UntisAbsenceCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public string StudentName { get; set; } = "";
public int? ExternKey { get; set; }
public int Date { get; set; }
public int AbsentPeriods { get; set; }
public int AbsentMinutes { get; set; }
public string? TeacherUsernames { get; set; }
public string? Subject { get; set; }
public string? AbsenceReason { get; set; }
public string? Note { get; set; }
public int? EntryId { get; set; }
public string? HandledOn { get; set; }
public bool Counts { get; set; }
public string? ExcuseNote { get; set; }
public int? PeriodNumber { get; set; }
public string? Status { get; set; }
public bool CountsAsFullDay { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
public class UntisClassRegisterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int Date { get; set; }
public string? Subject { get; set; }
public string StudentName { get; set; } = "";
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
}
public enum UntisCacheKind { Absences, ClassRegister }
/// <summary>
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
/// </summary>
public class UntisCacheFetchState
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public UntisCacheKind Kind { get; set; }
public DateTime? HotWindowFetchedAt { get; set; }
public int? ColdCoverageStartDate { get; set; }
}
+78
View File
@@ -1053,6 +1053,84 @@ public sealed class RepositoryTests
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId); Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
} }
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
[Fact]
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
]);
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
Assert.Single(result);
Assert.Equal(20260824, result[0].Date);
}
[Fact]
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
]);
repo.ReplaceRange("10c", 20260801, 20260831,
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
Assert.Single(untouched);
}
[Fact]
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisClassRegisterCacheRepository(db);
repo.InsertRange([new UntisClassRegisterCacheEntry
{
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
TeacherUsername = "mueller", CategoryName = "Positiv",
}]);
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
Assert.Equal("mueller", result.TeacherUsername);
}
[Fact]
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
{
using var db = NewInMemoryContext();
var repo = new UntisCacheFetchStateRepository(db);
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
repo.Save(new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
});
var result = repo.Get("10c", UntisCacheKind.Absences);
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
Assert.NotNull(result);
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
Assert.Equal(20260101, result.ColdCoverageStartDate);
Assert.Null(missing);
}
// ── WorkTaskRepository ──────────────────────────────────────────────────── // ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact] [Fact]
+9
View File
@@ -68,6 +68,9 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries"); public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings"); public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
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");
@@ -535,6 +538,12 @@ public class LiteDbContext : IDisposable
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true); AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
AnnualPlanEvents.EnsureIndex(x => x.StartDate); AnnualPlanEvents.EnsureIndex(x => x.StartDate);
AnnualPlanEvents.EnsureIndex(x => x.EndDate); AnnualPlanEvents.EnsureIndex(x => x.EndDate);
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
UntisAbsenceCache.EnsureIndex(x => x.Date);
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
UntisClassRegisterCache.EnsureIndex(x => x.Date);
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
} }
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
@@ -794,6 +794,50 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id); public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
} }
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
// ohnehin selbst ab (siehe UntisReportCacheService).
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
{
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisAbsenceCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisAbsenceCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
}
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
{
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisClassRegisterCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisClassRegisterCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
}
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
{
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+38
View File
@@ -474,6 +474,44 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
} }
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
{
private readonly List<UntisAbsenceCacheEntry> _all = [];
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
{
private readonly List<UntisClassRegisterCacheEntry> _all = [];
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
{
private readonly List<UntisCacheFetchState> _all = [];
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state)
{
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
@@ -0,0 +1,178 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisReportCacheServiceTests
{
private static readonly DateOnly Today = new(2026, 8, 25);
private static readonly DateOnly HotStart = Today.AddDays(-14);
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
[Fact]
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
{
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
Assert.Equal(HotStart, plan.HotWindowStart);
Assert.Equal(Today, plan.HotWindowEnd);
Assert.False(plan.FetchColdRange);
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
{
var requestStart = HotStart.AddDays(-30);
var requestEnd = HotStart.AddDays(-5);
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(requestStart, plan.ColdRangeStart);
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
{
var coveredSince = HotStart.AddDays(-60);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
{
var coveredSince = HotStart.AddDays(-30);
var earlierStart = HotStart.AddDays(-90);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(earlierStart, plan.ColdRangeStart);
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
}
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
[Fact]
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\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=ClassregEventPerStudent&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 absenceCache = new FakeUntisAbsenceCache();
var classRegisterCache = new FakeUntisClassRegisterCache();
var fetchStates = new FakeUntisCacheFetchStates();
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, fetchStates);
var today = DateOnly.FromDateTime(DateTime.Today);
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
Assert.Single(first);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
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)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<string> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri?.ToString() ?? "");
await Task.Yield();
return _responses.Dequeue();
}
}
}
+4
View File
@@ -169,6 +169,9 @@ public static class AppBootstrapper
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>(); services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>(); services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>(); services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -225,6 +228,7 @@ public static class AppBootstrapper
services.AddSingleton(_ => new SyncAuthService(new HttpClient())); services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings)); services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings)); services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN // stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar. // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -0,0 +1,143 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
/// [<c>RequestStart</c>..<c>RequestEnd</c>, nicht Teil dieses Records, siehe Aufrufer] tatsächlich
/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne
/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
public readonly record struct UntisCacheRefreshPlan(
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
int? ResultingColdCoverageStartDate);
/// <summary>
/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer
/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem
/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen
/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein
/// festes "heißes" Fenster der letzten <see cref="HotWindowDays"/> Tage wird höchstens stündlich
/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt");
/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient.
/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
/// über diesen Service selbst.
/// </summary>
public sealed class UntisReportCacheService(
WebUntisIntegrationService untis,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository classRegisterCache,
IUntisCacheFetchStateRepository fetchState)
{
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14;
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Absences);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
absenceCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.ClassRegister);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
classRegisterCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan));
return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
/// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
/// [<paramref name="today"/> <see cref="HotWindowDays"/>, <paramref name="today"/>], unabhängig
/// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens
/// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie
/// für <paramref name="requestStart"/> nötig, nie weiter.
public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd,
DateOnly today, bool forceRefresh, DateTime utcNow)
{
var hotStart = today.AddDays(-HotWindowDays);
var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null
|| utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval);
int? coverageStart = state?.ColdCoverageStartDate;
if (refreshHot)
coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart);
var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1);
var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd;
if (fetchCold) coverageStart = Int(requestStart);
return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart);
}
private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className,
UntisCacheKind kind, UntisCacheRefreshPlan plan) => new()
{
Id = previous?.Id ?? Guid.NewGuid(),
ClassName = className,
Kind = kind,
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
};
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100);
private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new()
{
ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date,
AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames,
Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId,
HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber,
Status = x.Status, CountsAsFullDay = x.CountsAsFullDay,
};
private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new(
e.StudentName, e.ExternKey, e.ClassName, e.Date, e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames,
e.Subject, e.AbsenceReason, e.Note, e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber,
e.Status, e.CountsAsFullDay);
private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new()
{
ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName,
TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup,
Text = x.Text,
};
private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new(
e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text);
}
@@ -58,17 +58,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
/// <summary> /// <summary>
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu /// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der /// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich, siehe /// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
/// <see cref="WebUntisIntegrationService.GetForeignClassRegisterEventsAsync"/>), sowie die /// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
/// Fehlzeiten der Klasse über alle Fächer (<see cref="WebUntisIntegrationService.GetClassAbsencesAsync"/>). /// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
/// Beides rein zur Ansicht, kein Übernehmen in lokale Daten. Bewusst unabhängig von jeder /// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
/// <see cref="Core.Models.LearningGroup"/> — Klassenlehrer ist man für die ganze Klasse, nicht für /// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
/// einen einzelnen Unterricht; die Klasse kommt aus <see cref="ClassTeacherOverviewViewModel"/> /// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
/// (letztlich aus den WebUntis-Einstellungen), nicht aus einer Gruppen-ID. /// nicht aus einer Gruppen-ID.
/// </summary> /// </summary>
public partial class ClassTeacherDetailsViewModel : ObservableObject public partial class ClassTeacherDetailsViewModel : ObservableObject
{ {
private readonly WebUntisIntegrationService _untis; private readonly UntisReportCacheService _cache;
private string _className = ""; private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = []; public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
@@ -81,9 +81,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen. /// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = ""; [ObservableProperty] private string _studentFilter = "";
public ClassTeacherDetailsViewModel(WebUntisIntegrationService untis) public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
{ {
_untis = untis; _cache = cache;
} }
public void Initialize(string className) public void Initialize(string className)
@@ -95,7 +95,14 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
} }
[RelayCommand] [RelayCommand]
private async Task Load() private Task Load() => LoadInternal(forceRefresh: false);
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand]
private Task Refresh() => LoadInternal(forceRefresh: true);
private async Task LoadInternal(bool forceRefresh)
{ {
var start = DateOnly.FromDateTime(StartDate.LocalDateTime); var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime); var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
@@ -105,8 +112,8 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); Busy = true; Entries.Clear(); AbsenceEntries.Clear();
try try
{ {
var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(_className, start, end); var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _untis.GetClassAbsencesAsync(_className, start, end); var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask); await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result var ordered = classRegisterTask.Result
@@ -62,6 +62,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
private readonly WebUntisSettingsService _settings; private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis; private readonly WebUntisIntegrationService _untis;
private readonly UntisReportCacheService _cache;
public ClassTeacherDetailsViewModel DetailsTab { get; } public ClassTeacherDetailsViewModel DetailsTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = []; public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
@@ -78,9 +79,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public Func<Task>? OnNavigateToSettings { get; set; } public Func<Task>? OnNavigateToSettings { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis, public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
ClassTeacherDetailsViewModel detailsTab) UntisReportCacheService cache, ClassTeacherDetailsViewModel detailsTab)
{ {
_settings = settings; _untis = untis; DetailsTab = detailsTab; _settings = settings; _untis = untis; _cache = cache; DetailsTab = detailsTab;
} }
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured)); partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
@@ -104,8 +105,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
{ {
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(DateTime.Today);
var studentsTask = _untis.GetStudentsAsync(className); var studentsTask = _untis.GetStudentsAsync(className);
var absencesTask = _untis.GetClassAbsencesAsync(className, today, today); var absencesTask = _cache.GetAbsencesAsync(className, today, today);
var classRegisterTask = _untis.GetForeignClassRegisterEventsAsync(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);
@@ -12,7 +12,10 @@
<DatePicker SelectedDate="{Binding StartDate}"/> <DatePicker SelectedDate="{Binding StartDate}"/>
<TextBlock Text="bis" VerticalAlignment="Center"/> <TextBlock Text="bis" VerticalAlignment="Center"/>
<DatePicker SelectedDate="{Binding EndDate}"/> <DatePicker SelectedDate="{Binding EndDate}"/>
<Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/> <Button Content="Laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"
ToolTip.Tip="Lädt aus dem lokalen Cache, sofern der aktuelle Zeitraum in der letzten Stunde schon einmal abgerufen wurde."/>
<Button Content="Jetzt wirklich neu abrufen" Command="{Binding RefreshCommand}" IsEnabled="{Binding !Busy}"
ToolTip.Tip="Ruft WebUntis direkt ab, auch wenn der Cache noch aktuell wäre."/>
</StackPanel> </StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8" <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
+25
View File
@@ -1337,6 +1337,31 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
ist jetzt optional (`UntisStudent.ExternKey`/`UntisStudentDto.ExternKey``int?`) — betraf auch ist jetzt optional (`UntisStudent.ExternKey`/`UntisStudentDto.ExternKey``int?`) — betraf auch
den bestehenden WebUntis-Klassenimport in der Kursansicht, der das aber schon immer über den bestehenden WebUntis-Klassenimport in der Kursansicht, der das aber schon immer über
`FirstNotEmpty` beim Weiterimport toleriert hatte und deshalb nicht auffiel. `FirstNotEmpty` beim Weiterimport toleriert hatte und deshalb nicht auffiel.
- [x] **"Klassenlehrer"-Feature — lokaler Cache für Fehlzeiten & Klassenbucheinträge (August 2026):**
Nutzer-Feedback: die Berichtszeilen sind starr genug für ein eigenes Datenmodell, Warnungen
sollen sofort da sein statt bei jedem Öffnen neu abgerufen zu werden, und — wichtigstes Motiv —
derselbe Bericht darf nicht mehrfach pro Stunde abgerufen werden, nur weil die Ansicht mehrfach
geöffnet wird (Sorge, bei WebUntis aufzufallen). Neue Modelle `UntisAbsenceCacheEntry`/
`UntisClassRegisterCacheEntry` (1:1-Abbildung der bestehenden DTOs, `Date` als `int`/yyyyMMdd wie
dort, nicht `DateOnly`, um die LiteDB-`DateTime`-Kind-Tücke zu vermeiden) plus
`UntisCacheFetchState` (ein Datensatz je Klasse+Berichtsart: wann das "heiße" Fenster zuletzt
aufgefrischt wurde, wie weit die "kalte" Historie lückenlos zurückreicht).
**Bewusst nicht synchronisiert**`UntisAbsenceCacheRepository`/`UntisClassRegisterCacheRepository`/
`UntisCacheFetchStateRepository` rufen `db.OnChange` nie auf (Sync ist opt-in pro Repository,
siehe `UntisSnapshotRepository`/`AnnualPlanEventRepository` für dasselbe Muster) — jedes Gerät
ruft WebUntis ohnehin selbst ab, und die Zeilenzahl wächst übers Schuljahr absichtlich an
(gewollte Historie, kein Pruning).
Neuer Service `UntisReportCacheService`: festes heißes Fenster `[heute14, heute]`
(unabhängig vom angefragten Bereich), höchstens stündlich automatisch aufgefrischt (dort kann
sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt"); alles davor gilt als endgültig
und wird, einmal abgerufen, dauerhaft aus dem Cache bedient. Die eigentliche Entscheidungslogik
(`UntisReportCacheService.Plan`) ist bewusst als reine, ohne Repositories/HTTP testbare Funktion
ausgelagert. `ClassTeacherOverviewViewModel`/`ClassTeacherDetailsViewModel` nutzen den Cache-Service
statt direkt `WebUntisIntegrationService`; das "Laden" in den Details respektiert die
Stunden-Sperre, ein zusätzlicher Button "Jetzt wirklich neu abrufen" umgeht sie bewusst
(`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat.
**Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv
für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt.
### 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