feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s

Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 23:41:12 +02:00
co-authored by Claude Sonnet 5
parent bd0d7e47ea
commit ba52109eaa
20 changed files with 660 additions and 44 deletions
@@ -0,0 +1,88 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
public static class UntisHubActions
{
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
DateOnly start, DateOnly end, UntisHubService hub)
{
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>())
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
}
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
}
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
}
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
{
var dialog = new OpenUntisPeriodsDialog();
await dialog.ShowDialog(owner);
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
}
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
{
var loaded = false;
var wasBusy = false;
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName != "Busy") return;
var busy = isBusy(vm);
if (wasBusy && !busy) loaded = true;
wasBusy = busy;
};
return () => loaded;
}
}
@@ -0,0 +1,97 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public enum UntisHubDueState { Ok, Due, Overdue }
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
public sealed record UntisHubJobRow(
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
/// <summary>
/// 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
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
/// nur die Fälligkeits-Zeitstempel dazu.
/// </summary>
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<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
}
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
jobStates.Save(new UntisHubJobState
{
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
});
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
/// <see cref="UntisReportCacheService.Plan"/>): 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<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
var rows = new List<UntisHubJobRow>();
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");
}
}