using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; namespace LehrerApp.Desktop.Services; /// Ergebnis von : was für eine Anfrage /// [RequestStart..RequestEnd, 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 /// ). public readonly record struct UntisCacheRefreshPlan( bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd, bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd, int? ResultingColdCoverageStartDate); /// /// 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 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 /// -Implementierung) - jedes Gerät füllt seinen Cache /// über diesen Service selbst. /// 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> 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> 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 /// [..] live nachgeladen werden, /// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest /// [, ], 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 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); }