using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; namespace LehrerApp.Desktop.Services; public enum UntisHubDueState { Ok, Due, Overdue } /// Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer /// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit. public sealed record UntisHubJobRow( UntisHubJobKind Kind, Guid? GroupId, string GroupName, DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel); /// /// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro /// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst /// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe /// ). Die eigentlichen Abgleiche laufen weiterhin über die /// bestehenden Vergleichsdialoge (siehe ); dieser Service verwaltet /// nur die Fälligkeits-Zeitstempel dazu. /// public sealed class UntisHubService( IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears) { private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14); private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21); private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60); private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90); private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14); private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21); private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs = [ (UntisHubJobKind.OffenePeriods, "Offene Stunden"), (UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"), (UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"), ]; public List GetRows() { var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear()) .Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub) .OrderBy(g => g.Name) .ToList(); return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); } /// Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das /// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige /// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt /// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig. public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) { var now = DateTime.UtcNow; Save(kind, groupId, now, summary); if (kind == UntisHubJobKind.FehlzeitenLang) Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt"); } private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) => jobStates.Save(new UntisHubJobState { Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(), Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary, }); /// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie /// ): aus den fälligkeitsrelevanten Lerngruppen und den /// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei /// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen. public static List BuildRows( IReadOnlyList eligibleGroups, IReadOnlyList states, DateTime utcNow) { // Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die // denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen, // bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe // (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen. UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) => states.Where(s => s.Kind == kind && s.GroupId == groupId) .OrderByDescending(s => s.LastRunAt) .FirstOrDefault(); var rows = new List(); foreach (var group in eligibleGroups) { rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name, State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow)); rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name, State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow)); } foreach (var (kind, label) in GlobalJobs) rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow)); return rows; } private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName, UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow) { var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow); return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary, dueState, dueLabel); } private static (UntisHubDueState, string) DueStatus( DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow) { if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft"); var age = utcNow - lastRunAt.Value; var days = (int)age.TotalDays; if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen"); if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen"); return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft"); } }