From eb1b2340b7a97d335e0ceb2e0b36e503574a26bc Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 13 Sep 2026 23:16:36 +0200 Subject: [PATCH] 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 @@ - +