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); }