Files
2026-09-13 23:16:36 +02:00

225 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}