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>
172 lines
9.7 KiB
C#
172 lines
9.7 KiB
C#
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,
|
||
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)
|
||
{
|
||
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);
|
||
}
|