diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 1b361c5..6a2a50d 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -76,6 +76,9 @@ public class AiLesson public List Phases { get; set; } = []; public string? Homework { get; set; } public string? Reflection { get; set; } + /// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner + /// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben. + public string? PlanningIdeas { get; set; } } public class AiPhaseStep diff --git a/LehrerApp.Core/Models/LearningGroup.cs b/LehrerApp.Core/Models/LearningGroup.cs index a4a0a89..bf6c2a9 100644 --- a/LehrerApp.Core/Models/LearningGroup.cs +++ b/LehrerApp.Core/Models/LearningGroup.cs @@ -27,6 +27,14 @@ public class LearningGroup /// public int? WebUntisLessonId { get; set; } /// + /// Blendet diese Lerngruppe im UntisHub (siehe -Layer im Desktop-Projekt: + /// UntisHubService) aus, auch wenn gesetzt ist - z.B. für + /// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf + /// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze + /// verhalten sich unverändert wie bisher). + /// + public bool ExcludedFromUntisHub { get; set; } + /// /// Vorgängergruppe aus dem Hochstufen () — /// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg. /// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 38bdfab..5bf9415 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -54,7 +54,15 @@ public class Lesson : IHasAttachments /// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell /// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in . public TimeOnly? StartTime { get; set; } + /// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan () + /// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor + /// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von + /// (die ist nach der Stunde) oder (die ist auf Einheitenebene, nicht je + /// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste + /// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen. + public string? PlanningIdeas { 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). @@ -94,6 +102,14 @@ public class LessonPhaseStep public string Activity { get; set; } = ""; public string Material { get; set; } = ""; public string Shorthand { get; set; } = ""; + /// Der über erzeugte, vollständige + /// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag + /// gemacht hatte — anders als der reine Vorschlagstext (AiPhaseStep.MaterialSuggestion, + /// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll + /// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im + /// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten + /// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26. + public string? MaterialPrompt { get; set; } /// /// null = Hauptweg. Sonst Verweis auf einen benannten aus /// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören — @@ -244,3 +260,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.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.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/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/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index dc8427a..48cc0ac 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -188,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, StartTime = l.StartTime, Homework = l.Homework, Reflection = l.Reflection, + PlanningIdeas = l.PlanningIdeas, Phases = ToAiPhases(l.Phases, pathNames), }) .ToList(); @@ -270,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal)) diffs.Add("Reflexion geändert"); + if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal)) + diffs.Add("Planungsideen geändert"); + var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name); var existingPhases = ToAiPhases(existing.Phases, pathNames); if (!PhasesEqual(existingPhases, proposed.Phases)) @@ -586,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, StartTime = ai.StartTime, Homework = ai.Homework, Reflection = ai.Reflection, + PlanningIdeas = ai.PlanningIdeas, // Status bleibt bei einer Änderung erhalten — sonst würde eine bereits // durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant" // zurückgesetzt (die KI kennt/liefert diesen Status gar nicht). @@ -597,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, + // Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin + // im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog. + MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion) + ? null : BuildMaterialPrompt(unit, ai, p), AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId) ? pathId : null, }).ToList(), 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/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index 623dfab..4d0a796 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -24,14 +24,20 @@ public record TimeEntryDto( Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date, TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description); -public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand); +/// ist der beim letzten KI-"Übernehmen" gespeicherte +/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen +/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne +/// erst den Desktop-Dialog öffnen zu müssen. +public record LessonPhaseDto( + Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand, + string? MaterialPrompt); public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes); public record LessonDto( Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, - string? Homework, LessonStatus Status, List Competencies, List Phases, - List Attachments); + string? Homework, string? PlanningIdeas, LessonStatus Status, List Competencies, + List Phases, List Attachments); /// Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten /// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) - diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index d2ad82c..cb10c1b 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -188,11 +188,12 @@ public class LessonPlanTools( return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert."); } - [Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")] + [Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")] public async Task UpdateLesson( [Description("ID der Einzelstunde.")] Guid lessonId, [Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null, [Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null, + [Description("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = null, [Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null, [Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null, [Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null, @@ -204,6 +205,7 @@ public class LessonPlanTools( var changes = new StringBuilder(); if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; } if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; } + if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; } if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; } if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; } if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; } @@ -228,6 +230,7 @@ public class LessonPlanTools( [Description("Tätigkeit/Sozialform.")] string activity = "", [Description("Material.")] string material = "", [Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "", + [Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); @@ -240,7 +243,7 @@ public class LessonPlanTools( var phase = new LessonPhaseStep { Name = name, DurationMinutes = durationMinutes, Activity = activity, - Material = material, Shorthand = shorthand, + Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt, }; lesson.Phases.Add(phase); lessons.Save(lesson); @@ -256,6 +259,7 @@ public class LessonPlanTools( [Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null, [Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null, [Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null, + [Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); @@ -269,6 +273,7 @@ public class LessonPlanTools( if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; } if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; } if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; } + if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; } if (changes.Length == 0) return new WriteResultDto(true, phase.Id, "Keine Änderung nötig."); @@ -409,7 +414,7 @@ public class LessonPlanTools( } private static LessonDto ToDto(Lesson l) => new( - l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Competencies, - l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(), + l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies, + l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(), l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList()); } diff --git a/LehrerApp.Desktop/Services/UntisHubService.cs b/LehrerApp.Desktop/Services/UntisHubService.cs index c16fda5..837cb42 100644 --- a/LehrerApp.Desktop/Services/UntisHubService.cs +++ b/LehrerApp.Desktop/Services/UntisHubService.cs @@ -40,7 +40,7 @@ public sealed class UntisHubService( public List GetRows() { var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear()) - .Where(g => g.WebUntisLessonId is not null) + .Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub) .OrderBy(g => g.Name) .ToList(); return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); @@ -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.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/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/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index 54a6b7b..175c256 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -826,6 +826,7 @@ public partial class AddGroupDialogViewModel : ObservableObject [ObservableProperty] private bool _isDifferentiated; [ObservableProperty] private bool _requiresLessonPlanning = true; [ObservableProperty] private int? _webUntisLessonId; + [ObservableProperty] private bool _excludedFromUntisHub; [ObservableProperty] private string _nameError = ""; [ObservableProperty] private string _gradeLevelError = ""; @@ -866,6 +867,7 @@ public partial class AddGroupDialogViewModel : ObservableObject IsDifferentiated = group.IsDifferentiated; RequiresLessonPlanning = group.RequiresLessonPlanning; WebUntisLessonId = group.WebUntisLessonId; + ExcludedFromUntisHub = group.ExcludedFromUntisHub; OnPropertyChanged(nameof(DialogTitle)); OnPropertyChanged(nameof(SaveButtonText)); } @@ -913,6 +915,7 @@ public partial class AddGroupDialogViewModel : ObservableObject Result.IsDifferentiated = IsDifferentiated; Result.RequiresLessonPlanning = RequiresLessonPlanning; Result.WebUntisLessonId = WebUntisLessonId; + Result.ExcludedFromUntisHub = ExcludedFromUntisHub; _groups.Save(Result); } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 417f93b..b4a95fd 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -737,6 +737,7 @@ public partial class LessonDialogViewModel : ObservableObject [ObservableProperty] private int? _lessonNumber; [ObservableProperty] private string _topic = ""; [ObservableProperty] private string _startTimeText = ""; + [ObservableProperty] private string _planningIdeas = ""; [ObservableProperty] private string _homework = ""; [ObservableProperty] private bool _homeworkChecked; [ObservableProperty] private bool _homeworkCheckDismissed; @@ -823,6 +824,7 @@ public partial class LessonDialogViewModel : ObservableObject LessonNumber = editingLesson.LessonNumber; Topic = editingLesson.Topic; StartTimeText = editingLesson.StartTime?.ToString("HH:mm") ?? ""; + PlanningIdeas = editingLesson.PlanningIdeas ?? ""; Homework = editingLesson.Homework ?? ""; HomeworkChecked = editingLesson.HomeworkChecked; HomeworkCheckDismissed = editingLesson.HomeworkCheckDismissed; @@ -888,6 +890,7 @@ public partial class LessonDialogViewModel : ObservableObject Activity = source?.Activity ?? "", Material = source?.Material ?? "", Shorthand = source?.Shorthand ?? "", + MaterialPrompt = source?.MaterialPrompt, }; item.OnChanged = RecomputeTimes; item.OnRemove = RemovePhase; @@ -1075,6 +1078,7 @@ public partial class LessonDialogViewModel : ObservableObject Result.LessonNumber = LessonNumber; Result.Topic = Topic.Trim(); Result.StartTime = startTime; + Result.PlanningIdeas = string.IsNullOrWhiteSpace(PlanningIdeas) ? null : PlanningIdeas.Trim(); Result.Phases = Phases.Select(p => p.ToModel()).ToList(); Result.Homework = string.IsNullOrWhiteSpace(Homework) ? null : Homework.Trim(); Result.HomeworkChecked = HomeworkChecked; @@ -1103,6 +1107,13 @@ public partial class PhaseStepEditItem : ObservableObject [ObservableProperty] private string _material = ""; [ObservableProperty] private string _shorthand = ""; [ObservableProperty] private string _computedTimeDisplay = ""; + /// Gespeicherter Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI beim + /// letzten "Übernehmen" einen Medienvorschlag für diese Phase gemacht hatte. Steuert die + /// Sichtbarkeit des Kopieren-Buttons im Verlaufsplan-Editor. + [ObservableProperty] private string? _materialPrompt; + + public bool HasMaterialPrompt => !string.IsNullOrWhiteSpace(MaterialPrompt); + partial void OnMaterialPromptChanged(string? value) => OnPropertyChanged(nameof(HasMaterialPrompt)); /// Checkbox-Zustand im Editor: unchecked→checked öffnet den Zuweisen-Dialog /// (); checked→unchecked entfernt die Zuordnung. @@ -1162,6 +1173,7 @@ public partial class PhaseStepEditItem : ObservableObject Activity = Activity.Trim(), Material = Material.Trim(), Shorthand = Shorthand.Trim(), + MaterialPrompt = MaterialPrompt, AlternativePathId = AlternativePathId, }; } 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/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml index fd63de9..d98b768 100644 --- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml @@ -84,6 +84,9 @@ ToolTip.Tip="lsid aus WebUntis (Unterricht -> Mein Unterricht -> Berichte-Symbol der Zeile). Wird von WebUntis pro Schuljahr neu vergeben und muss deshalb jedes Schuljahr aktualisiert werden."/> + + 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 @@ - +