feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-13 23:16:36 +02:00
parent b8193be6ec
commit eb1b2340b7
14 changed files with 837 additions and 65 deletions
@@ -44,6 +44,15 @@ public partial class GroupOverviewViewModel : ObservableObject
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<Lesson> TodayLessons { get; } = [];
[ObservableProperty] private Lesson? _selectedTeachingLesson;
public bool HasTodayLessons => TodayLessons.Count > 0;
public Action<Lesson>? 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();
@@ -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<SeatCellViewModel, bool>? OnQuickStatus { get; init; }
public Func<SeatCellViewModel, Task>? 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<SeatCellViewModel> _onChanged;
private bool _suppressChange;
private readonly Action<SeatCellViewModel, string> _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.</summary>
public double DisplayOpacity => IsHidden ? 0.4 : LessonOpacity;
public double DisplayOpacity => IsHidden ? 0.4 : ShowQuickCheck ? 1 : LessonOpacity;
private readonly Action<SeatCellViewModel, bool> _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));
@@ -20,6 +20,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
/// </summary>
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.";
}
}
@@ -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<DateTime> _utcNow;
private TeachingTimelineState? _state;
public bool IsEditable { get; }
public ObservableCollection<TeachingPhaseViewModel> Phases { get; } = [];
public ObservableCollection<Lesson> 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<DateTime>? 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<TeachingPhaseViewModel>().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);
}