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,
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> 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> 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);
}