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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml.cs
index 160f35e..21147c4 100644
--- a/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml.cs
@@ -1,8 +1,14 @@
using Avalonia.Controls;
+using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupOverviewTabView : UserControl
{
public GroupOverviewTabView() => InitializeComponent();
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (DataContext is GroupOverviewViewModel vm) vm.OnOpenTeachingMode = TeachingModeWindow.Open;
+ }
}
diff --git a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
index c323265..87c1611 100644
--- a/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/SeatingPlanTabView.axaml
@@ -24,8 +24,8 @@
-
-
+
@@ -72,26 +72,35 @@
-
+
+
+
+
+ DisplayMemberBinding="{Binding DisplayName}" MinWidth="210" IsEnabled="{Binding !IsTeachingMode}"/>
+
-
+
+
+
-
+
+
+
@@ -158,10 +167,20 @@
IsVisible="{Binding HasDayHighlightBadge}"
ToolTip.Tip="Tagesflagge"/>
+
+
+
+
+
+
+
+ IsVisible="{Binding ShowNormalActions}">
+ 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 @@
-
+
-
+
+
+
+
+
-
+
@@ -48,44 +67,78 @@
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+