From 0962845eade871726aa0b1077396268974c3eb48 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 13 Sep 2026 22:04:07 +0200 Subject: [PATCH 1/5] fix: startup speed --- LehrerApp.Desktop/App.axaml.cs | 58 ++++++++++++++----- LehrerApp.Desktop/AppBootstrapper.cs | 6 +- .../Services/AnnualPlanSyncService.cs | 4 +- .../Services/UntisSyncService.cs | 4 +- LehrerApp.Desktop/Views/SplashWindow.axaml | 21 +++++-- LehrerApp.Desktop/Views/SplashWindow.axaml.cs | 6 ++ TODO.md | 9 +++ 7 files changed, 86 insertions(+), 22 deletions(-) diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index a5d40f2..4e3b2a0 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -26,6 +26,8 @@ public class App : Application public static IServiceProvider Services { get; private set; } = null!; private static ServiceProvider? _serviceProvider; private static bool _exitHandlerAttached; + private static Task _initialPolls = Task.CompletedTask; + private static readonly CancellationTokenSource StartupCancellation = new(); public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -65,7 +67,11 @@ public class App : Application promptVm.OnUnlocked = async password => { AppBootstrapper.DbPassword = password; - await StartMainAppAsync(desktop, promptWindow); + var unlockedSplash = new SplashWindow(); + desktop.MainWindow = unlockedSplash; + unlockedSplash.Show(); + promptWindow.Close(); + await StartMainAppAsync(desktop, unlockedSplash); }; desktop.MainWindow = promptWindow; promptWindow.Show(); @@ -79,14 +85,21 @@ public class App : Application private static async Task StartMainAppAsync( IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null) { - _serviceProvider = AppBootstrapper.BuildServices(); + var splash = windowToClose as SplashWindow; + var timer = System.Diagnostics.Stopwatch.StartNew(); + var progress = new Progress<(int Value, string Text)>(step => + splash?.SetProgress(step.Value, step.Text)); + _serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices( + (value, text) => ((IProgress<(int, string)>)progress).Report((value, text)))); Services = _serviceProvider; Services.GetRequiredService().Info("Anwendung gestartet."); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService()); // Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per // Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein // soll, kein dauerhaftes Archiv. - Services.GetRequiredService().PurgeOlderThan(DateTime.UtcNow.AddDays(-30)); + splash?.SetProgress(60, "Papierkorb aufräumen …"); + await Task.Run(() => Services.GetRequiredService() + .PurgeOlderThan(DateTime.UtcNow.AddDays(-30))); if (!_exitHandlerAttached) { @@ -99,19 +112,13 @@ public class App : Application // Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein. Services.GetRequiredService().Start(); + splash?.SetProgress(75, "Übersicht vorbereiten …"); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); var mainVm = Services.GetRequiredService(); WireCallbacks(mainVm); - // Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre - // CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das - // Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen - // aktiviert zugleich die periodischen Timer. - var initialPolls = new List(); - if (Services.GetService() is { } untisSync) - initialPolls.Add(untisSync.PollAsync()); - if (Services.GetService() is { } annualPlanSync) - initialPolls.Add(annualPlanSync.PollAsync()); - await Task.WhenAll(initialPolls); + splash?.SetProgress(90, "Hauptfenster öffnen …"); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); var main = new MainWindow { DataContext = mainVm }; main.EnableWindowSizePersistence(Services.GetRequiredService()); @@ -119,7 +126,30 @@ public class App : Application main.EnableFinalSync(syncEngine); desktop.MainWindow = main; main.Show(); + splash?.SetProgress(100, "Bereit"); windowToClose?.Close(); + AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet."); + + // Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht. + var untis = Services.GetService(); + var annualPlan = Services.GetService(); + if (untis is not null) + untis.DataChanged += () => Dispatcher.UIThread.Post(() => + { + Services.GetRequiredService().Load(); + Services.GetRequiredService().RefreshCommand.Execute(null); + }); + _initialPolls = Task.Run(async () => + { + try + { + await Task.WhenAll( + untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask, + annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask); + } + catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { } + catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); } + }); } /// Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den @@ -140,6 +170,8 @@ public class App : Application try { + StartupCancellation.Cancel(); + _initialPolls.GetAwaiter().GetResult(); serviceProvider.GetService()?.Checkpoint(); } catch (Exception ex) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index b28bb9b..a432af7 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -101,7 +101,7 @@ public static class AppBootstrapper Environment.Exit(0); } - public static ServiceProvider BuildServices() + public static ServiceProvider BuildServices(Action? reportProgress = null) { var services = new ServiceCollection(); @@ -130,7 +130,9 @@ public static class AppBootstrapper // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. var backupSettings = new BackupSettingsService(appData); var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() }; + reportProgress?.Invoke(10, "Sicherung erstellen …"); var backupPath = backup.CreateBackup(DbPath); + reportProgress?.Invoke(25, "Sicherung prüfen …"); // Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den // Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten. if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword)) @@ -363,7 +365,9 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); + reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …"); var provider = services.BuildServiceProvider(); + provider.GetRequiredService(); // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, // außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. diff --git a/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs index d1e3031..ba028eb 100644 --- a/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs +++ b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs @@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable _timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); } - public async Task PollAsync() + public async Task PollAsync(CancellationToken cancellationToken = default) { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try @@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable string icsText; try { - icsText = await _http.GetStringAsync(url).ConfigureAwait(false); + icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs index cbdbd36..f170a70 100644 --- a/LehrerApp.Desktop/Services/UntisSyncService.cs +++ b/LehrerApp.Desktop/Services/UntisSyncService.cs @@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); } - public async Task PollAsync() + public async Task PollAsync(CancellationToken cancellationToken = default) { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try @@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable string icsText; try { - icsText = await _http.GetStringAsync(url).ConfigureAwait(false); + icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/LehrerApp.Desktop/Views/SplashWindow.axaml b/LehrerApp.Desktop/Views/SplashWindow.axaml index 4e5e58f..21332c2 100644 --- a/LehrerApp.Desktop/Views/SplashWindow.axaml +++ b/LehrerApp.Desktop/Views/SplashWindow.axaml @@ -7,8 +7,21 @@ ShowInTaskbar="False" WindowDecorations="None" WindowStartupLocation="CenterScreen"> - + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/SplashWindow.axaml.cs b/LehrerApp.Desktop/Views/SplashWindow.axaml.cs index 3f46413..2fcd8fc 100644 --- a/LehrerApp.Desktop/Views/SplashWindow.axaml.cs +++ b/LehrerApp.Desktop/Views/SplashWindow.axaml.cs @@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views; public partial class SplashWindow : Window { public SplashWindow() => InitializeComponent(); + + public void SetProgress(int value, string status) + { + StartupProgress.Value = value; + StartupStatus.Text = status; + } } diff --git a/TODO.md b/TODO.md index 3bd4845..b543c50 100644 --- a/TODO.md +++ b/TODO.md @@ -4801,3 +4801,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe **→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht. + + +### Startoptimierung (September 2026) + +- [x] Backup, Backup-Pruefung und Datenbankinitialisierung laufen ausserhalb des UI-Threads. +- [x] Splashscreen mit echtem, phasenbasiertem Ladebalken und aktuellem Arbeitsschritt. +- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar. + Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen. + Die Zeit bis zum Hauptfenster wird im App-Log protokolliert. From b8193be6ec7817e04f96020c483c26add5a54218 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 13 Sep 2026 22:24:03 +0200 Subject: [PATCH 2/5] fix: Untis-Hub-Status ueber Geraete hinweg synchronisieren UntisHubJobState (Faelligkeits-Zeitstempel des Untis-Hubs) lief bisher ausserhalb des Sync - jedes Geraet fuehrte seine eigene Buchhaltung, wodurch auf allen Instanzen dieselben Punkte offen blieben und ein bereits erledigter Abgleich anderswo erneut WebUntis-Traffic ausgeloest haette (Nutzer-Feedback). Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Data.Tests/ChangeHookTests.cs | 20 +++++++++++++++++++ .../Repositories/AllRepositories.cs | 6 +++++- LehrerApp.Desktop/Services/UntisHubService.cs | 8 +++++++- LehrerApp.Sync.Tests/EventApplierTests.cs | 14 +++++++++++++ LehrerApp.Sync/EventApplier.cs | 1 + TODO.md | 20 +++++++++++++++++++ 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs index b0f4f6e..60a41f4 100644 --- a/LehrerApp.Data.Tests/ChangeHookTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookTests.cs @@ -57,4 +57,24 @@ public sealed class ChangeHookTests Assert.Null(exception); } + + // Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete + // (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform. + [Fact] + public void UntisHubJobStateRepository_Save_LoestOnChangeAus() + { + using var db = NewInMemoryContext(); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + var repo = new UntisHubJobStateRepository(db); + var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow }; + + repo.Save(state); + + var call = Assert.Single(calls); + Assert.Equal(nameof(UntisHubJobState), call.EntityType); + Assert.Equal(state.Id.ToString(), call.EntityId); + Assert.Equal("Save", call.Operation); + Assert.Same(state, call.Payload); + } } diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index a61ee29..304a6c2 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -907,7 +907,11 @@ public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRep public List GetAll() => db.UntisHubJobStates.FindAll().ToList(); - public void Save(UntisHubJobState state) => db.UntisHubJobStates.Upsert(state); + public void Save(UntisHubJobState state) + { + db.UntisHubJobStates.Upsert(state); + db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state); + } } public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository diff --git a/LehrerApp.Desktop/Services/UntisHubService.cs b/LehrerApp.Desktop/Services/UntisHubService.cs index c16fda5..1142960 100644 --- a/LehrerApp.Desktop/Services/UntisHubService.cs +++ b/LehrerApp.Desktop/Services/UntisHubService.cs @@ -72,8 +72,14 @@ public sealed class UntisHubService( 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.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId); + states.Where(s => s.Kind == kind && s.GroupId == groupId) + .OrderByDescending(s => s.LastRunAt) + .FirstOrDefault(); var rows = new List(); foreach (var group in eligibleGroups) diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index 6d6045a..118b9e8 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -38,6 +38,20 @@ public sealed class EventApplierTests Assert.Null(db.Students.FindById(student.Id)); } + [Fact] + public async Task ApplyAsync_UntisHubJobStateSave_SchreibtEntitaetDirektInDieCollection() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow }; + + await applier.ApplyAsync(MakeEvent(nameof(UntisHubJobState), state.Id.ToString(), "Save", state)); + + var saved = db.UntisHubJobStates.FindById(state.Id); + Assert.NotNull(saved); + Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind); + } + [Fact] public async Task ApplyAsync_UnbekannterEntityType_TutNichtsUndWirftNicht() { diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 8b7cee5..f0a6b9a 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n Simple(context => context.SubstitutionEntries); Simple(context => context.CompetencyDomains); Simple(context => context.Vorgaenge); + Simple(context => context.UntisHubJobStates); // Kaskaden-Fälle: dieselben internen LiteDbContext-Hilfsmethoden wie die jeweiligen // Repositories, damit die Kaskade nur an einer Stelle im Code existiert. diff --git a/TODO.md b/TODO.md index b543c50..df4c25e 100644 --- a/TODO.md +++ b/TODO.md @@ -2097,6 +2097,26 @@ vollständig enthält. Zeilen), damit ein KI-Client für eine dort gelistete Gruppe nicht zusätzlich `get_groups` aufrufen muss, nur um den Fehlzeitenabgleich für sie auszulösen. + **Nachtrag (Nutzer-Feedback, 2026-09-13):** `UntisHubJobState` (die Fälligkeits-Zeitstempel + hinter dem Hub) lief bislang außerhalb des Sync — jedes Gerät führte seine eigene Buchhaltung. + In der Praxis zeigte das auf allen Instanzen dieselben offenen Punkte, was dem Zweck des Hubs + widerspricht (möglichst wenig WebUntis-Traffic: ein bereits auf Gerät A erledigter Abgleich soll + auf Gerät B nicht erneut als fällig erscheinen und zu einem zweiten, unnötigen Abruf verleiten). + Anders als die drei bewusst unsynchronisierten Report-Caches (`UntisAbsenceCacheRepository` & + Co., siehe oben) enthält `UntisHubJobState` keine WebUntis-Rohdaten, sondern nur Zeitstempel + + Kurztext — unkritisch für Sync. + - `UntisHubJobStateRepository.Save` ruft jetzt `db.OnChange` wie die übrigen ~28 synchronisierten + Repositories auf; `EventApplier` bekommt dafür einen zusätzlichen `Simple`- + Eintrag. Kein `Delete` nötig (Interface kennt keins) — Zeilen werden nur überschrieben. + - Geräte-Pairing (`SnapshotService`) war bereits unberührt, da es die komplette DB-Datei kopiert. + - Race-Härtung: `UntisHubService.RecordRun` legt bei einem (Kind, GroupId), das lokal noch nie + lief, eine neue `Id` an; laufen zwei Geräte offline denselben, noch nie ausgeführten Job + unabhängig voneinander, entstehen dadurch kurzzeitig zwei Datensätze für dasselbe Paar (kein + Unique-Index darauf). `UntisHubService.BuildRows` wählt deshalb jetzt den Datensatz mit dem + jüngsten `LastRunAt` statt eines beliebigen — der verwaiste zweite Datensatz bleibt harmlos in + der DB stehen (gleiches akzeptiertes v1-Verhalten wie bei anderen Entitäten ohne serverseitige + Merge-Logik, siehe TODO 10.3). + ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. From eb1b2340b7a97d335e0ceb2e0b36e503574a26bc Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 13 Sep 2026 23:16:36 +0200 Subject: [PATCH 3/5] feat: expand teaching mode with live timeline and seating quick checks --- LehrerApp.Core/Models/Planning.cs | 19 ++ .../GroupOverviewViewModelTests.cs | 27 +++ .../SeatingQuickCheckTests.cs | 81 +++++++ .../TeachingTimelineViewModelTests.cs | 183 ++++++++++++++ .../Groups/GroupOverviewViewModel.cs | 19 ++ .../Groups/SeatingPlanViewModels.cs | 80 ++++++- .../Groups/TeachingModeViewModel.cs | 12 +- .../Groups/TeachingTimelineViewModel.cs | 224 ++++++++++++++++++ .../Views/Groups/GroupOverviewTabView.axaml | 14 ++ .../Groups/GroupOverviewTabView.axaml.cs | 6 + .../Views/Groups/SeatingPlanTabView.axaml | 39 ++- .../Views/Groups/TeachingModeWindow.axaml | 129 +++++++--- .../Views/Groups/TeachingModeWindow.axaml.cs | 54 ++++- .../Views/Planning/TimetableView.axaml.cs | 15 +- 14 files changed, 837 insertions(+), 65 deletions(-) create mode 100644 LehrerApp.Desktop.Tests/SeatingQuickCheckTests.cs create mode 100644 LehrerApp.Desktop.Tests/TeachingTimelineViewModelTests.cs create mode 100644 LehrerApp.Desktop/ViewModels/Groups/TeachingTimelineViewModel.cs diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 38bdfab..16e050b 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -55,6 +55,7 @@ public class Lesson : IHasAttachments /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in . public TimeOnly? StartTime { get; set; } public List Phases { get; set; } = []; + public TeachingTimelineState? TeachingTimeline { get; set; } public string? Homework { get; set; } /// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert /// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4). @@ -244,3 +245,21 @@ public class ReportGrade public bool IsLocked { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } + +/// Live timing is separate from the original lesson plan. +public class TeachingTimelineState +{ + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + public DateTime? HeldSinceUtc { get; set; } + public Guid? HeldPhaseId { get; set; } + public List Phases { get; set; } = []; + public List TransferredPhaseIds { get; set; } = []; +} + +public class TeachingPhaseTiming +{ + public Guid PhaseId { get; set; } + public double Minutes { get; set; } + public bool ExplicitlyStarted { get; set; } +} diff --git a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs index a37154f..38d7640 100644 --- a/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupOverviewViewModelTests.cs @@ -10,6 +10,33 @@ namespace LehrerApp.Desktop.Tests; /// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten. public sealed class GroupOverviewViewModelTests { + [Fact] + public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl() + { + var group = new LearningGroup { IsActive = true }; + var today = DateOnly.FromDateTime(DateTime.Today); + var lessons = new FakeLessons(); + var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" }; + var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" }; + lessons.Add(first); + lessons.Add(second); + lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled }); + lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) }); + lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today }); + var vm = NewVm(lessons: lessons, groups: new FakeGroups([group])); + vm.Initialize(group.Id, group.Name); + Assert.True(vm.HasTodayLessons); + Assert.Equal(2, vm.TodayLessons.Count); + Lesson? opened = null; + vm.OnOpenTeachingMode = lesson => opened = lesson; + vm.SelectedTeachingLesson = second; + vm.StartTeachingModeCommand.Execute(null); + Assert.Same(second, opened); + group.IsActive = false; + vm.Refresh(); + Assert.False(vm.HasTodayLessons); + } + private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, diff --git a/LehrerApp.Desktop.Tests/SeatingQuickCheckTests.cs b/LehrerApp.Desktop.Tests/SeatingQuickCheckTests.cs new file mode 100644 index 0000000..2d72904 --- /dev/null +++ b/LehrerApp.Desktop.Tests/SeatingQuickCheckTests.cs @@ -0,0 +1,81 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class SeatingQuickCheckTests +{ + private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false) + { + var group = Guid.NewGuid(); + var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) }; + var entries = new FakeEntries(); + var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2, + Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] }; + var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]), + new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]), + new FakeSessions([session]), entries, new FakeAspects()); + vm.Initialize(group, readOnly); + return (vm, entries, session, student); + } + + [Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters() + { + var (vm, entries, session, student) = Build(); + entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, + Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 }); + vm.CheckAttendanceCommand.Execute(null); + var seat = vm.Seats[0]; + Assert.True(seat.ShowQuickCheck); + Assert.False(vm.Seats[1].ShowQuickCheck); + seat.QuickNegativeCommand.Execute(null); + var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!; + Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance); + Assert.Equal(HomeworkStatus.Completed, entry.Homework); + Assert.Equal(3, entry.RaisedHandCount); + Assert.Equal(2, entry.CalledOnCount); + Assert.Equal(1, seat.DisplayOpacity); + seat.QuickPositiveCommand.Execute(null); + Assert.Equal(AttendanceStatus.Present, entry.Attendance); + } + + [Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance() + { + var (vm, entries, session, student) = Build(); + entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late }); + vm.CheckHomeworkCommand.Execute(null); + vm.Seats[0].QuickNegativeCommand.Execute(null); + var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!; + Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework); + Assert.True(entry.HomeworkMissing); + Assert.Equal(AttendanceStatus.Late, entry.Attendance); + vm.Seats[0].QuickPositiveCommand.Execute(null); + Assert.Equal(HomeworkStatus.Completed, entry.Homework); + Assert.False(entry.HomeworkMissing); + vm.EndQuickCheckCommand.Execute(null); + Assert.False(vm.Seats[0].ShowQuickCheck); + Assert.True(vm.Seats[0].ShowNormalActions); + } + + [Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession() + { + var (vm, _, _, _) = Build(); + var calls = 0; + vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; }; + vm.CheckAttendanceCommand.Execute(null); + await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null); + Assert.Equal(1, calls); + } + + [Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks() + { + var (vm, entries, session, _) = Build(readOnly: true); + vm.CheckHomeworkCommand.Execute(null); + vm.Seats[0].QuickNegativeCommand.Execute(null); + vm.Seats[1].QuickPositiveCommand.Execute(null); + Assert.False(vm.Seats[0].ShowQuickCheck); + Assert.Empty(entries.GetBySession(session.Id)); + } +} diff --git a/LehrerApp.Desktop.Tests/TeachingTimelineViewModelTests.cs b/LehrerApp.Desktop.Tests/TeachingTimelineViewModelTests.cs new file mode 100644 index 0000000..a7c0af0 --- /dev/null +++ b/LehrerApp.Desktop.Tests/TeachingTimelineViewModelTests.cs @@ -0,0 +1,183 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class TeachingTimelineViewModelTests +{ + private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc); + private DateTime _now; + private readonly FakeLessons _lessons = new(); + + private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true) + { + _now = _start.AddMinutes(5); + var local = _start.ToLocalTime(); + var lesson = new Lesson + { + Date = DateOnly.FromDateTime(local), + StartTime = scheduled ? TimeOnly.FromDateTime(local) : null, + Phases = [new() { Name = "Einstieg", DurationMinutes = 10 }, + new() { Name = "Arbeit", DurationMinutes = 10 }, + new() { Name = "Sicherung", DurationMinutes = 10 }] + }; + _lessons.Add(lesson); + return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now)); + } + + [Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary() + { + var (_, vm) = Build(); + Assert.True(vm.Phases[0].IsActive); + Assert.Equal(50, vm.Phases[0].Progress); + _now = _start.AddMinutes(10); + vm.Refresh(); + Assert.True(vm.Phases[0].IsCompleted); + Assert.True(vm.Phases[1].IsActive); + Assert.Single(vm.Phases, p => p.IsActive); + } + + [Fact] public void NoStartTime_RequiresExplicitStart() + { + var (_, vm) = Build(scheduled: false); + Assert.True(vm.NeedsStart); + Assert.DoesNotContain(vm.Phases, p => p.IsActive); + vm.StartNowCommand.Execute(null); + Assert.False(vm.NeedsStart); + Assert.True(vm.Phases[0].IsActive); + Assert.Equal(_now, vm.Phases[0].StartUtc); + } + + [Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan() + { + var (lesson, vm) = Build(); + vm.Phases[0].ExtendTenCommand.Execute(null); + Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc); + Assert.True(vm.Phases[2].IsOverflow); + Assert.Equal(10, lesson.Phases[0].DurationMinutes); + Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline); + } + + [Fact] public void FinishEarly_StartsNextPhaseNow() + { + var (_, vm) = Build(); + vm.Phases[0].FinishCommand.Execute(null); + Assert.True(vm.Phases[0].IsCompleted); + Assert.True(vm.Phases[1].IsActive); + Assert.Equal(_now, vm.Phases[1].StartUtc); + } + + [Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext() + { + var (lesson, vm) = Build(); + vm.Phases[0].HoldCommand.Execute(null); + _now = _start.AddMinutes(65); + var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now); + Assert.True(reopened.Phases[0].IsHeld); + Assert.True(reopened.Phases[0].IsActive); + Assert.Single(reopened.Phases, p => p.IsActive); + reopened.Phases[0].FinishCommand.Execute(null); + Assert.True(reopened.Phases[1].IsActive); + Assert.False(reopened.Phases[0].IsHeld); + Assert.Equal(_now, reopened.Phases[1].StartUtc); + } + + [Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase() + { + var (_, vm) = Build(); + var chosen = vm.Phases[2]; + chosen.BringForwardCommand.Execute(null); + Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name)); + Assert.True(chosen.IsActive); + Assert.Equal(_now, chosen.StartUtc); + Assert.False(vm.Phases[2].IsCompleted); + } + + [Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow() + { + var (lesson, vm) = Build(); + vm.Phases[0].ExtendTenCommand.Execute(null); + _now = _start.AddDays(1); + var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now); + var remainder = reopened.Phases[2]; + Assert.True(remainder.IsOverflow); + Assert.False(remainder.IsCompleted); + Assert.False(remainder.IsActive); + Assert.True(reopened.HasRemainder); + remainder.BringForwardCommand.Execute(null); + Assert.True(remainder.IsActive); + Assert.Equal(_now, remainder.StartUtc); + } + + [Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource() + { + var (lesson, _) = Build(); + var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" }; + _lessons.Add(target); + var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now); + vm.Phases[0].ExtendTenCommand.Execute(null); + vm.TransferRemainderCommand.Execute(null); + vm.TransferRemainderCommand.Execute(null); + var copy = Assert.Single(target.Phases); + Assert.Equal("Sicherung", copy.Name); + Assert.NotEqual(lesson.Phases[2].Id, copy.Id); + Assert.Equal(3, lesson.Phases.Count); + Assert.True(vm.Phases[2].IsTransferred); + } + + [Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds() + { + var (lesson, vm) = Build(); + vm.Phases[0].ExtendFiveCommand.Execute(null); + _now = _start.AddMinutes(28); + vm.Refresh(); + Assert.True(vm.Phases[2].IsActive); + _now = _start.AddDays(1); + var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now); + var remainder = reopened.Phases[2]; + Assert.False(remainder.IsCompleted); + Assert.False(remainder.IsActive); + Assert.True(reopened.HasRemainder); + Assert.Equal(5, remainder.RemainingMinutes); + remainder.BringForwardCommand.Execute(null); + Assert.True(remainder.IsActive); + Assert.Equal(_now.AddMinutes(5), remainder.EndUtc); + } + + [Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock() + { + var (lesson, _) = Build(scheduled: false); + var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now); + vm.StartNowCommand.Execute(null); + Assert.Null(lesson.TeachingTimeline); + Assert.True(vm.NeedsStart); + } + + [Fact] public void AlternativePhases_AreNotRunAlongsideMainPath() + { + var (lesson, _) = Build(); + lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 }); + var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now); + Assert.Equal(3, vm.Phases.Count); + Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc); + } + + [Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift() + { + using var stream = new MemoryStream(); + using var db = new LehrerApp.Data.LiteDbContext(stream); + var repository = new LehrerApp.Data.Repositories.LessonRepository(db); + var (lesson, _) = Build(); + repository.Save(lesson); + var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now); + vm.Phases[0].HoldCommand.Execute(null); + _now = _start.AddMinutes(40); + var loaded = repository.GetById(lesson.Id)!; + var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now); + Assert.True(reopened.Phases[0].IsActive); + Assert.True(reopened.Phases[0].IsHeld); + Assert.Equal(_start, reopened.Phases[0].StartUtc); + Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs index e31b560..c32cf8a 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupOverviewViewModel.cs @@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject private readonly GradingService _grading; private readonly SchoolYearService _schoolYear; + public ObservableCollection TodayLessons { get; } = []; + [ObservableProperty] private Lesson? _selectedTeachingLesson; + public bool HasTodayLessons => TodayLessons.Count > 0; + public Action? OnOpenTeachingMode { get; set; } + [RelayCommand] private void StartTeachingMode() + { + if (SelectedTeachingLesson is { } lesson) OnOpenTeachingMode?.Invoke(lesson); + } + private Guid _groupId; private string _groupName = ""; @@ -130,6 +139,16 @@ public partial class GroupOverviewViewModel : ObservableObject public void Refresh() { var today = DateOnly.FromDateTime(DateTime.Today); + TodayLessons.Clear(); + if (_groups.GetById(_groupId)?.IsActive == true) + foreach (var lesson in _lessons.GetByGroupAndRange(_groupId, today, today) + .Where(l => l.Status != LessonStatus.Cancelled) + .OrderBy(l => l.StartTime).ThenBy(l => l.LessonNumber)) + TodayLessons.Add(lesson); + var now = TimeOnly.FromDateTime(DateTime.Now); + SelectedTeachingLesson = TodayLessons.LastOrDefault(l => l.StartTime <= now) + ?? TodayLessons.FirstOrDefault(); + OnPropertyChanged(nameof(HasTodayLessons)); LoadNextLesson(today); LoadNextExam(today); LoadYearComparison(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs index 5a79c9e..db33e10 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/SeatingPlanViewModels.cs @@ -16,6 +16,45 @@ public partial class SeatingPlanTabViewModel : ObservableObject private readonly IParticipationRepository _participation; private readonly IParticipationAspectRepository _aspects; private readonly IDocumentationRepository? _documentation; + [ObservableProperty] private bool _isTeachingMode; + [ObservableProperty] private string _quickMode = ""; + public bool IsQuickMode => QuickMode.Length > 0; + public string QuickModeDisplay => QuickMode switch + { + "Attendance" => "Anwesenheit kontrollieren · Fehlend = Entschuldigung offen", + "Homework" => "Hausaufgaben kontrollieren", + _ => "Klicken: bewerten" + }; + [RelayCommand] private void CheckAttendance() => QuickMode = "Attendance"; + [RelayCommand] private void CheckHomework() => QuickMode = "Homework"; + [RelayCommand] private void EndQuickCheck() => QuickMode = ""; + partial void OnQuickModeChanged(string value) + { + if (value.Length > 0) IsEditMode = false; + foreach (var seat in Seats) seat.QuickMode = value; + OnPropertyChanged(nameof(IsQuickMode)); + OnPropertyChanged(nameof(QuickModeDisplay)); + } + + private void SaveQuickStatus(SeatCellViewModel seat, bool positive) + { + if (!IsEditable || !IsQuickMode || seat.SelectedOption.StudentId is not Guid studentId) return; + var session = EnsureTodaySession(); + if (session is null) return; + // Always merge with the latest entry, so quick checks preserve ratings and counters. + var entry = _participation.GetBySessionAndStudent(session.Id, studentId) + ?? new ParticipationEntry { SessionId = session.Id, StudentId = studentId }; + if (QuickMode == "Attendance") entry.Attendance = positive ? AttendanceStatus.Present : AttendanceStatus.ExcusePending; + else + { + entry.Homework = positive ? HomeworkStatus.Completed : HomeworkStatus.MissingOpen; + entry.HomeworkMissing = HomeworkDisplay.CountsAsMissing(entry.Homework); + } + _participation.Save(entry); + RefreshSeatLessonData(); + OnAssessmentChanged?.Invoke(); + } + private Guid _groupId; private SeatingPlan? _currentPlan; private bool _isReadOnly; @@ -235,7 +274,12 @@ public partial class SeatingPlanTabViewModel : ObservableObject ?? StudentSeatOption.Empty; var isHidden = hiddenSeats.Any(h => h.Row == row && h.Column == column); Seats.Add(new SeatCellViewModel(row, column, StudentOptions, option, OnSeatChanged, - CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation)); + CanEditLayout, ToggleSituationTag, IsEditable, isHidden, ToggleSeatHidden, TallyParticipation) + { + QuickMode = QuickMode, + OnQuickStatus = SaveQuickStatus, + OnQuickSpecial = AssessStudent + }); } UpdateAssignmentSummary(); RefreshSeatLessonData(); @@ -488,6 +532,7 @@ public partial class SeatingPlanTabViewModel : ObservableObject partial void OnIsEditModeChanged(bool value) { + if (value) QuickMode = ""; OnPropertyChanged(nameof(CanEditLayout)); foreach (var seat in Seats) { @@ -540,6 +585,32 @@ public sealed class ParticipationSessionOption(ParticipationSession session) public partial class SeatCellViewModel : ObservableObject { + [ObservableProperty] private string _quickMode = ""; + public bool ShowQuickCheck => QuickMode.Length > 0 && IsOccupied && CanRecordLesson; + public bool ShowNormalActions => ShowLessonOverview && QuickMode.Length == 0; + public bool ShowSituationActions => ShowNormalActions && CanRecordLesson; + public string QuickPositiveLabel => QuickMode == "Attendance" ? "Anwesend" : "Gemacht"; + public string QuickNegativeLabel => QuickMode == "Attendance" ? "Fehlend" : "Fehlt"; + public Action? OnQuickStatus { get; init; } + public Func? OnQuickSpecial { get; init; } + [RelayCommand] private void QuickPositive() => OnQuickStatus?.Invoke(this, true); + [RelayCommand] private void QuickNegative() => OnQuickStatus?.Invoke(this, false); + [RelayCommand] private Task QuickSpecial() => OnQuickSpecial?.Invoke(this) ?? Task.CompletedTask; + partial void OnQuickModeChanged(string value) + { + OnPropertyChanged(nameof(ShowQuickCheck)); + OnPropertyChanged(nameof(ShowNormalActions)); + OnPropertyChanged(nameof(ShowSituationActions)); + OnPropertyChanged(nameof(QuickPositiveLabel)); + OnPropertyChanged(nameof(QuickNegativeLabel)); + OnPropertyChanged(nameof(DisplayOpacity)); + } + partial void OnCanRecordLessonChanged(bool value) + { + OnPropertyChanged(nameof(ShowQuickCheck)); + OnPropertyChanged(nameof(ShowSituationActions)); + } + private readonly Action _onChanged; private bool _suppressChange; private readonly Action _toggleSituationTag; @@ -581,7 +652,7 @@ public partial class SeatCellViewModel : ObservableObject /// Opacity ist im DataTemplate bereits lokal an LessonOpacity gebunden gewesen; ein lokal /// gebundener Wert überschreibt aber jeden Style-Setter für dieselbe Eigenschaft, daher muss /// die Abblendung für ausgeblendete Plätze hier statt per CSS-Klasse erfolgen. - public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity; + public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity; private readonly Action _tally; @@ -615,6 +686,9 @@ public partial class SeatCellViewModel : ObservableObject partial void OnSelectedOptionChanged(StudentSeatOption value) { + OnPropertyChanged(nameof(ShowQuickCheck)); + OnPropertyChanged(nameof(ShowNormalActions)); + OnPropertyChanged(nameof(ShowSituationActions)); OnPropertyChanged(nameof(IsOccupied)); OnPropertyChanged(nameof(StudentName)); OnPropertyChanged(nameof(ShowLessonOverview)); @@ -624,6 +698,8 @@ public partial class SeatCellViewModel : ObservableObject partial void OnCanEditChanged(bool value) { + OnPropertyChanged(nameof(ShowNormalActions)); + OnPropertyChanged(nameof(ShowSituationActions)); OnPropertyChanged(nameof(ShowLessonOverview)); OnPropertyChanged(nameof(ShowSeat)); OnPropertyChanged(nameof(CanToggleHidden)); diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs index e8de90f..722494f 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingModeViewModel.cs @@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups; /// public class TeachingModeViewModel { + public TeachingTimelineViewModel Timeline { get; } public string GroupName { get; } public LessonViewerViewModel LessonInfo { get; } public SeatingPlanTabViewModel SeatingPlan { get; } @@ -38,9 +39,11 @@ public class TeachingModeViewModel SeatingPlanTabViewModel seatingPlan, ParticipationTabViewModel participation) { GroupName = group.Name; + Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive); LessonInfo = new LessonViewerViewModel(lesson, alternativePaths); SeatingPlan = seatingPlan; + SeatingPlan.IsTeachingMode = true; SeatingPlan.Initialize(group.Id, !group.IsActive); SeatingPlan.SelectOrCreateSessionForLesson(lesson); @@ -102,14 +105,19 @@ public partial class TeachingModeHomeworkViewModel : ObservableObject if (_previousLesson is null) return; _previousLesson.HomeworkChecked = value; if (value) _previousLesson.HomeworkCheckDismissed = false; - _lessons.Save(_previousLesson); + var latest = _lessons.GetById(_previousLesson.Id) ?? _previousLesson; + latest.HomeworkChecked = value; + if (value) latest.HomeworkCheckDismissed = false; + _lessons.Save(latest); } [RelayCommand] private void SaveCurrentHomework() { _lesson.Homework = CurrentHomework; - _lessons.Save(_lesson); + var latest = _lessons.GetById(_lesson.Id) ?? _lesson; + latest.Homework = CurrentHomework; + _lessons.Save(latest); SaveStatus = "Gespeichert."; } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/TeachingTimelineViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/TeachingTimelineViewModel.cs new file mode 100644 index 0000000..118d7bb --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/TeachingTimelineViewModel.cs @@ -0,0 +1,224 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public partial class TeachingTimelineViewModel : ObservableObject +{ + private readonly Lesson _lesson; + private readonly ILessonRepository _lessons; + private readonly Func _utcNow; + private TeachingTimelineState? _state; + public bool IsEditable { get; } + public ObservableCollection Phases { get; } = []; + public ObservableCollection TransferTargets { get; } = []; + [ObservableProperty] private Lesson? _transferTarget; + [ObservableProperty] private string _status = ""; + [ObservableProperty] private bool _needsStart; + public bool HasPhases => Phases.Count > 0; + public bool HasTransferTargets => TransferTargets.Count > 0; + public bool HasRemainder => Phases.Any(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred); + + public TeachingTimelineViewModel(Lesson lesson, ILessonRepository lessons, bool readOnly = false, + Func? utcNow = null) + { + _lesson = lesson; + _lessons = lessons; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + IsEditable = !readOnly; + _state = lesson.TeachingTimeline; + if (_state is not null) + { + // LiteDB returns local DateTimes by default; arithmetic below uses UTC. + _state.StartUtc = _state.StartUtc.ToUniversalTime(); + _state.EndUtc = _state.EndUtc.ToUniversalTime(); + _state.HeldSinceUtc = _state.HeldSinceUtc?.ToUniversalTime(); + } + foreach (var phase in lesson.Phases.Where(p => p.AlternativePathId is null)) + Phases.Add(new TeachingPhaseViewModel(phase, this)); + if (_state is null && lesson.StartTime is { } start) + CreateState(lesson.Date.ToDateTime(start).ToUniversalTime()); + foreach (var target in lessons.GetByGroupAndRange(lesson.GroupId, lesson.Date, lesson.Date.AddDays(120)) + .Where(l => l.Id != lesson.Id && l.Status is not (LessonStatus.Cancelled or LessonStatus.Conducted) + && (l.Date > lesson.Date || l.StartTime > lesson.StartTime || l.LessonNumber > lesson.LessonNumber)) + .OrderBy(l => l.Date).ThenBy(l => l.StartTime).ThenBy(l => l.LessonNumber)) + TransferTargets.Add(target); + TransferTarget = TransferTargets.FirstOrDefault(); + Refresh(); + } + + private void CreateState(DateTime start) + { + _state = new TeachingTimelineState + { + StartUtc = start, + EndUtc = start.AddMinutes(Phases.Sum(p => Math.Max(0, p.Source.DurationMinutes))), + Phases = Phases.Select(p => new TeachingPhaseTiming + { PhaseId = p.Source.Id, Minutes = Math.Max(0, p.Source.DurationMinutes) }).ToList() + }; + } + + [RelayCommand] private void StartNow() + { + if (!IsEditable || _state is not null) return; + CreateState(_utcNow()); + Save(); + Refresh(); + } + + public void Refresh() + { + NeedsStart = _state is null && HasPhases; + if (_state is null) return; + var now = _utcNow(); + var cursor = _state.StartUtc; + _state.Phases.RemoveAll(t => !Phases.Any(p => p.Source.Id == t.PhaseId)); + var ordered = _state.Phases.Select(t => Phases.FirstOrDefault(p => p.Source.Id == t.PhaseId)) + .OfType().ToList(); + // Keep surviving IDs in their live order when a plan was edited between openings. + foreach (var phase in Phases.Where(p => !ordered.Contains(p)).ToList()) + { + _state.Phases.Add(new TeachingPhaseTiming { PhaseId = phase.Source.Id, Minutes = Math.Max(0, phase.Source.DurationMinutes) }); + ordered.Add(phase); + } + for (var i = 0; i < ordered.Count; i++) + if (Phases.IndexOf(ordered[i]) != i) Phases.Move(Phases.IndexOf(ordered[i]), i); + foreach (var phase in Phases) + { + var timing = _state.Phases.First(t => t.PhaseId == phase.Source.Id); + var held = _state.HeldPhaseId == phase.Source.Id && _state.HeldSinceUtc.HasValue; + var extension = held ? Math.Max(0, (now - _state.HeldSinceUtc!.Value).TotalMinutes) : 0; + var end = cursor.AddMinutes(timing.Minutes + extension); + // Phases pushed entirely out of the lesson remain pending, even after closing + // the window overnight. They run only if explicitly brought forward. + var canRun = cursor < _state.EndUtc || timing.ExplicitlyStarted || held; + phase.StartUtc = cursor; + phase.EndUtc = end; + phase.IsActive = canRun && cursor <= now && (now < end || held) + && (now < _state.EndUtc || timing.ExplicitlyStarted || held); + phase.IsCompleted = canRun && !held && end <= now + && (end <= _state.EndUtc || timing.ExplicitlyStarted); + phase.IsOverflow = end > _state.EndUtc && !phase.IsCompleted; + phase.IsTransferred = _state.TransferredPhaseIds.Contains(phase.Source.Id); + phase.IsHeld = held; + var effectiveNow = timing.ExplicitlyStarted || held || now < _state.EndUtc ? now : _state.EndUtc; + var elapsed = Math.Clamp((effectiveNow - cursor).TotalMinutes, 0, timing.Minutes + extension); + phase.RemainingMinutes = phase.IsCompleted ? 0 : timing.Minutes + extension - elapsed; + phase.Progress = !canRun ? 0 : phase.IsCompleted ? 100 : elapsed / Math.Max(0.01, timing.Minutes + extension) * 100; + phase.TimeDisplay = $"{cursor.ToLocalTime():HH:mm}–{end.ToLocalTime():HH:mm}"; + phase.CanStartNow = IsEditable && !phase.IsCompleted && !phase.IsActive && !phase.IsTransferred; + cursor = end; + } + OnPropertyChanged(nameof(HasRemainder)); + } + + public void Extend(TeachingPhaseViewModel phase, int minutes) + { + Refresh(); + if (!IsEditable || !phase.IsActive || _state is null) return; + var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id); + timing.Minutes += minutes; + timing.ExplicitlyStarted = true; + Save(); Refresh(); + } + + public void Hold(TeachingPhaseViewModel phase) + { + Refresh(); + if (!IsEditable || !phase.IsActive || phase.IsHeld || _state is null) return; + _state.HeldPhaseId = phase.Source.Id; + _state.HeldSinceUtc = _utcNow(); + _state.Phases.First(p => p.PhaseId == phase.Source.Id).ExplicitlyStarted = true; + Save(); Refresh(); + } + + public void Finish(TeachingPhaseViewModel phase, bool advanceNext = true) + { + Refresh(); + if (!IsEditable || !phase.IsActive || _state is null) return; + _state.Phases.First(p => p.PhaseId == phase.Source.Id).Minutes = Math.Max(0, (_utcNow() - phase.StartUtc).TotalMinutes); + _state.HeldPhaseId = null; + _state.HeldSinceUtc = null; + if (advanceNext) + { + var nextIndex = _state.Phases.FindIndex(p => p.PhaseId == phase.Source.Id) + 1; + if (nextIndex < _state.Phases.Count) _state.Phases[nextIndex].ExplicitlyStarted = true; + } + Save(); Refresh(); + } + + public void BringForward(TeachingPhaseViewModel phase) + { + Refresh(); + if (!phase.CanStartNow || _state is null) return; + var active = Phases.FirstOrDefault(p => p.IsActive); + if (active is not null) Finish(active, advanceNext: false); + var timing = _state.Phases.First(p => p.PhaseId == phase.Source.Id); + timing.Minutes = phase.RemainingMinutes; + _state.Phases.Remove(timing); + var completed = Phases.TakeWhile(p => p.IsCompleted).Count(); + _state.Phases.Insert(Math.Min(completed, _state.Phases.Count), timing); + timing.ExplicitlyStarted = true; + var now = _utcNow(); + // A pending phase may be selected long after the scheduled end. Anchor it to + // now instead of letting yesterday's timestamps immediately complete it. + var prefix = _state.Phases.Take(completed).Sum(p => p.Minutes); + var delay = (now - _state.StartUtc.AddMinutes(prefix)).TotalMinutes; + if (completed == 0) _state.StartUtc = now; + else if (delay > 0) _state.Phases[completed - 1].Minutes += delay; + Save(); Refresh(); + } + + [RelayCommand] private void TransferRemainder() + { + Refresh(); + if (!IsEditable || _state is null || TransferTarget is null) return; + var target = _lessons.GetById(TransferTarget.Id); + if (target is null || target.Status is LessonStatus.Cancelled or LessonStatus.Conducted) return; + var remainder = Phases.Where(p => p.IsOverflow && !p.IsCompleted && !p.IsTransferred).ToList(); + if (remainder.Count == 0) return; + foreach (var phase in remainder) + { + var source = phase.Source; + target.Phases.Add(new LessonPhaseStep { Name = source.Name, DurationMinutes = (int)Math.Ceiling(phase.RemainingMinutes), + Activity = source.Activity, Material = source.Material, Shorthand = source.Shorthand }); + } + _lessons.Save(target); + _state.TransferredPhaseIds.AddRange(remainder.Select(p => p.Source.Id)); + Save(); Refresh(); + Status = $"{remainder.Count} Phase(n) nach {target.Date:dd.MM.yyyy} · {target.Topic} kopiert."; + } + + private void Save() + { + var latest = _lessons.GetById(_lesson.Id) ?? _lesson; + latest.TeachingTimeline = _state; + _lesson.TeachingTimeline = _state; + _lessons.Save(latest); + } +} + +public partial class TeachingPhaseViewModel(LessonPhaseStep source, TeachingTimelineViewModel owner) : ObservableObject +{ + public LessonPhaseStep Source { get; } = source; + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + public double RemainingMinutes { get; set; } + [ObservableProperty] private bool _isActive; + [ObservableProperty] private bool _isCompleted; + [ObservableProperty] private bool _isOverflow; + [ObservableProperty] private bool _isHeld; + [ObservableProperty] private bool _isTransferred; + [ObservableProperty] private bool _canStartNow; + [ObservableProperty] private double _progress; + [ObservableProperty] private string _timeDisplay = ""; + public bool IsEditable => owner.IsEditable; + [RelayCommand] private void ExtendFive() => owner.Extend(this, 5); + [RelayCommand] private void ExtendTen() => owner.Extend(this, 10); + [RelayCommand] private void Hold() => owner.Hold(this); + [RelayCommand] private void Finish() => owner.Finish(this); + [RelayCommand] private void BringForward() => owner.BringForward(this); +} diff --git a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml index 5660e34..260573f 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml @@ -1,5 +1,6 @@ + + + + + + + + + + + + IsVisible="{Binding ShowSituationActions}"> diff --git a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml index 1578210..888d3c0 100644 --- a/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml +++ b/LehrerApp.Desktop/Views/Groups/TeachingModeWindow.axaml @@ -1,5 +1,6 @@ + + + + + - + @@ -26,19 +42,22 @@ - +