Stundenplan verbesserung
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 00:53:32 +02:00
parent 2b299fc940
commit dade41ee8d
16 changed files with 639 additions and 75 deletions
@@ -0,0 +1,83 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
public sealed class TimetableUnitOption(Unit unit)
{
public Unit Model { get; } = unit;
public string Label { get; } = unit.Title;
public string Detail { get; } = unit.Status switch
{
UnitStatus.Active => "Laufende Einheit",
UnitStatus.Completed => "Abgeschlossene Einheit",
_ => "Geplante Einheit",
};
}
/// <summary>Ordnet eine direkt aus dem Stundenplan angelegte Stunde einer Einheit zu.</summary>
public partial class TimetableUnitPickerViewModel : ObservableObject
{
private readonly IUnitRepository _units;
private readonly Guid _groupId;
private readonly DateOnly _date;
public string ContextLabel { get; }
public ObservableCollection<TimetableUnitOption> Units { get; } = [];
[ObservableProperty] private TimetableUnitOption? _selectedUnit;
[ObservableProperty] private string _newUnitTitle = "";
[ObservableProperty] private string _error = "";
public Unit? Result { get; private set; }
public bool ResultIsNew { get; private set; }
public bool HasUnits => Units.Count > 0;
public TimetableUnitPickerViewModel(IUnitRepository units, Guid groupId, string groupName,
DateOnly date, int period)
{
_units = units;
_groupId = groupId;
_date = date;
ContextLabel = $"{groupName} · {date:dd.MM.yyyy} · {period}. Stunde";
foreach (var unit in units.GetByGroup(groupId)
.OrderBy(u => u.Status == UnitStatus.Active ? 0 : u.Status == UnitStatus.Planned ? 1 : 2)
.ThenByDescending(u => u.StartDate)
.ThenBy(u => u.Title, StringComparer.CurrentCultureIgnoreCase))
Units.Add(new TimetableUnitOption(unit));
SelectedUnit = Units.FirstOrDefault();
}
[RelayCommand]
private void Save()
{
Error = "";
if (!string.IsNullOrWhiteSpace(NewUnitTitle))
{
Result = new Unit
{
GroupId = _groupId,
Title = NewUnitTitle.Trim(),
StartDate = _date,
Status = UnitStatus.Active,
};
// Erst speichern, wenn auch der anschließende Stunden-Dialog bestätigt wurde. So
// hinterlässt ein Abbruch keine leere Einheit.
ResultIsNew = true;
return;
}
if (SelectedUnit is null)
{
Error = "Bitte eine Einheit auswählen oder eine neue benennen.";
return;
}
Result = SelectedUnit.Model;
ResultIsNew = false;
}
}
public sealed record TimetableLessonRequest(Guid GroupId, DateOnly Date, int PeriodNumber);
public sealed record TimetableLessonMoveRequest(Lesson Lesson, int SelectedPeriod);
@@ -5,6 +5,7 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
@@ -108,6 +109,8 @@ public partial class TimetableViewModel : ObservableObject
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public Func<Lesson, Task>? OnOpenLessonViewer { get; set; }
public Func<Lesson, Task>? OnOpenTeachingMode { get; set; }
public Func<TimetableLessonRequest, Task>? OnCreateLesson { get; set; }
public Func<TimetableLessonMoveRequest, Task>? OnMoveLesson { get; set; }
/// Öffentlich statt intern (kein InternalsVisibleTo in dieser Codebasis) - erlaubt Tests, die
/// "heute"-abhängiges Verhalten (Wochenraster-Badges, Unterrichtszeit-Erkennung) prüfen, ohne
@@ -301,9 +304,9 @@ public partial class TimetableViewModel : ObservableObject
var cancelled = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Cancelled && s.PeriodNumber == slot.PeriodNumber);
if (cancelled is not null) { items.Add(TodayLessonItem.ForCancelled(slot.GroupId, slot.PeriodNumber, group.Name, cancelled)); continue; }
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
var lesson = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
items.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
items.Add(new TodayLessonItem(slot.GroupId, today, slot.PeriodNumber, group.Name,
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title,
HasUnhandledHomework(slot.GroupId, today), lesson));
}
@@ -353,15 +356,15 @@ public partial class TimetableViewModel : ObservableObject
}
/// Springt aus dem Stundenplan direkt in den Verlaufsplan-Viewer der zugehörigen Lesson (4.5.2)
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson (Slot laut Stundenplan belegt,
/// aber noch keine konkrete Stunde geplant) bleibt es bei der bisherigen, gröberen Navigation
/// zum Planung-Tab der Gruppe: eine neue Lesson direkt von hier aus anzulegen bräuchte eine
/// Antwort auf "welcher Unit wird sie zugeordnet", die bewusst noch offen ist (siehe TODO 4.5.2).
/// — sofern für den Slot schon eine Lesson existiert. Ohne Lesson startet die Direktanlage;
/// der vorgeschaltete Einheiten-Dialog löst dabei die notwendige Unit-Zuordnung explizit.
[RelayCommand]
private async Task OpenTodayLesson(TodayLessonItem? item)
{
if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
else if (OnCreateLesson is not null)
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, item.Date, item.PeriodNumber));
else OnNavigateToGroup?.Invoke(item.GroupId);
}
@@ -380,7 +383,7 @@ public partial class TimetableViewModel : ObservableObject
/// nach der Stunde) Unterrichtszeit, geht es direkt in den Unterrichtsmodus — sonst wie
/// bisher in den (schreibgeschützten) Planungsviewer bzw., ohne Lesson, zur Einheitenplanung
/// der Gruppe. Das Popup-Menü (siehe TimetableView.axaml, MenuFlyout je Kachel) bietet
/// daneben immer alle vier Ziele explizit an, unabhängig von dieser Automatik.
/// daneben die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
[RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item)
{
@@ -391,6 +394,8 @@ public partial class TimetableViewModel : ObservableObject
await OnOpenTeachingMode(lesson);
else if (OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
}
else if (item.Date is { } date && OnCreateLesson is not null)
await OnCreateLesson(new TimetableLessonRequest(item.GroupId, date, item.PeriodNumber));
else OnNavigateToGroup?.Invoke(item.GroupId);
}
@@ -399,16 +404,20 @@ public partial class TimetableViewModel : ObservableObject
/// Start-/Endzeit gebunden — man klickt auch kurz vor Stundenbeginn oder in einer kurzen
/// Verzögerung danach noch typischerweise in Unterrichtsabsicht. Ohne konfiguriertes
/// Stundenraster oder an einem anderen Tag als heute bleibt es beim Planungsviewer.
/// Nimmt bewusst das Datum der Lesson selbst statt WeekCellItem.Date entgegen — Date ist dort
/// nur bei Kopfzeilen (WeekdayHeader) gesetzt, nicht bei regulären Stunden-Kacheln (ForSlot).
/// Nimmt das Datum der Lesson selbst entgegen; die Kachel trägt ihr Datum zusätzlich für die
/// Direktanlage einer noch nicht existierenden Stunde.
private static readonly TimeSpan TeachingTimeTolerance = TimeSpan.FromMinutes(10);
private bool IsAroundTeachingTime(DateOnly lessonDate, int periodNumber)
{
var nowSnapshot = Clock();
if (lessonDate != DateOnly.FromDateTime(nowSnapshot)) return false;
if (_periodSchedule.GetTimes(periodNumber) is not { } times) return false;
var now = TimeOnly.FromDateTime(nowSnapshot);
return now >= times.Start.Add(-TeachingTimeTolerance) && now <= times.End.Add(TeachingTimeTolerance);
// DateTime statt TimeOnly.Add: Letzteres springt nahe Mitternacht auf den anderen
// Tagesrand und macht aus z.B. 00:05 ± 10 Minuten ein umgekehrtes Vergleichsfenster.
var start = lessonDate.ToDateTime(times.Start).Subtract(TeachingTimeTolerance);
var end = lessonDate.ToDateTime(times.End).Add(TeachingTimeTolerance);
if (end < start) end = end.AddDays(1); // nur für ein ggf. über Mitternacht laufendes Raster
return nowSnapshot >= start && nowSnapshot <= end;
}
[RelayCommand]
@@ -487,14 +496,14 @@ public partial class TimetableViewModel : ObservableObject
continue;
}
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
var lesson = FindLessonForSlot(slot.GroupId, date, period);
var hasExam = _exams.GetByGroup(slot.GroupId).Any(e => e.Date == date);
var isHoliday = IsFreeDay(date, schoolHolidays, publicHolidayDates);
var colorHex = isHoliday ? "#BDBDBD" : ColorFor(group?.Name ?? "");
var holidayBadge = HolidayBadgeFor(date, weekday, schoolHolidays, publicHolidayDates);
var isLastBeforeExam = IsLastBeforeExamFor(date, weekday, slot.GroupId, publicHolidayDates);
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today,
WeekItems.Add(WeekCellItem.ForSlot(weekday, period, date == today, date,
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
colorHex, holidayBadge, hasExam, isLastBeforeExam,
@@ -560,6 +569,31 @@ public partial class TimetableViewModel : ObservableObject
p.Activity?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
p.Material?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true)) == true;
/// Eine Doppelstunde wird als eine Lesson an der ersten Periode gespeichert. Für die zweite
/// Rasterzelle liefern wir dieselbe Lesson nur dann, wenn der Stundenplan dort unmittelbar
/// fortgesetzt wird und der geplante Verlauf länger als die erste Periode ist. Eine exakt an
/// der Zielperiode verankerte Lesson hat immer Vorrang.
private Lesson? FindLessonForSlot(Guid groupId, DateOnly date, int period)
{
var lessons = _lessons.GetByGroupAndDate(groupId, date);
var exact = lessons.FirstOrDefault(l => l.LessonNumber == period);
if (exact is not null) return exact;
// Historische/manuell angelegte Einträge hatten häufig keine Stundennummer. Solange es
// davon nur einen an diesem Tag gibt, bleibt das frühere Verhalten erhalten und er wird
// dem vorhandenen Gruppen-Slot zugeordnet.
var withoutPeriod = lessons.Where(l => l.LessonNumber is null).ToList();
if (withoutPeriod.Count == 1) return withoutPeriod[0];
var previous = lessons.Where(l => l.LessonNumber is int p && p == period - 1)
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
if (previous?.LessonNumber is not int anchor) return null;
var isConsecutiveSlot = _slots.GetByGroup(groupId)
.Any(s => s.Weekday == date.DayOfWeek && s.PeriodNumber == period);
var firstPeriodMinutes = _periodSchedule.GetDurationMinutes(anchor);
return isConsecutiveSlot && firstPeriodMinutes > 0 &&
previous.Phases.Sum(p => p.DurationMinutes) > firstPeriodMinutes ? previous : null;
}
/// <summary>4.5.4: Hat die letzte vor <paramref name="date"/> liegende Lesson dieser Gruppe eine
/// Hausaufgabe, die weder als kontrolliert noch als bewusst übersprungen markiert ist? Schaut
/// bewusst nur auf die unmittelbar vorherige Lesson (nicht auf die gesamte Historie) — sobald
@@ -824,7 +858,9 @@ public partial class WeekCellItem : ObservableObject
/// Grundlage für den Direktsprung in den Verlaufsplan-Viewer (4.5.2).
public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null;
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
public bool HasNoLesson => Lesson is null;
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
[ObservableProperty] private string _weatherSymbol = "";
[ObservableProperty] private string _weatherTooltip = "";
[ObservableProperty] private bool _hasWeatherWarning;
@@ -879,18 +915,28 @@ public partial class WeekCellItem : ObservableObject
IsSubstitutionSupervision = isSubstitution,
};
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, DateOnly date, string subjectLabel,
string groupName, string room, string topic, string colorHex, string holidayBadge,
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
bool hasUnhandledHomework = false, Lesson? lesson = null) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday,
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, Date = date,
SubjectLabel = subjectLabel, GroupName = groupName, Room = room, Topic = topic,
ColorHex = colorHex, HolidayBadge = holidayBadge, HasExam = hasExam,
IsLastBeforeExam = isLastBeforeExam, HasExperiment = hasExperiment, GroupId = groupId,
IsHoliday = isHoliday, HasUnhandledHomework = hasUnhandledHomework, Lesson = lesson,
};
// Kompatible Überladung für isolierte ViewModel-Tests und ältere Aufrufer ohne konkreten
// Wochenbezug. Produktiv wird die datierte Variante verwendet.
public static WeekCellItem ForSlot(DayOfWeek day, int period, bool isToday, string subjectLabel,
string groupName, string room, string topic, string colorHex, string holidayBadge,
bool hasExam, bool isLastBeforeExam, bool hasExperiment, Guid groupId, bool isHoliday,
bool hasUnhandledHomework = false, Lesson? lesson = null) => ForSlot(day, period, isToday,
DateOnly.FromDateTime(DateTime.Today), subjectLabel, groupName, room, topic, colorHex,
holidayBadge, hasExam, isLastBeforeExam, hasExperiment, groupId, isHoliday,
hasUnhandledHomework, lesson);
public static WeekCellItem ForSubstitutionLesson(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSubstitutionLesson = true,
@@ -937,12 +983,13 @@ public class UpcomingExamItem(DateOnly date, string groupName, string title)
public string Title { get; } = title;
}
public enum TimetableLessonDestination { TeachingMode, Viewer, SeatingPlan, Planning }
public enum TimetableLessonDestination { TeachingMode, Viewer, Create, Move, SeatingPlan, Planning }
public sealed record TimetableDestinationOption(TimetableLessonDestination Kind, string Label);
public class TodayLessonItem
{
public Guid GroupId { get; private init; }
public DateOnly Date { get; private init; }
public int PeriodNumber { get; private init; }
public string GroupName { get; private init; } = "";
public string Room { get; private init; } = "";
@@ -960,23 +1007,24 @@ public class TodayLessonItem
/// Direktsprung in den Verlaufsplan-Viewer (4.5.2) und den Unterrichtsmodus (14.x).
public Lesson? Lesson { get; private init; }
public bool HasLesson => Lesson is not null;
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
public string PlanningStatusLabel => Lesson is null ? "Nicht geplant" : LessonStatusDisplay.ToName(Lesson.Status);
public string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Stunde anlegen";
public TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
public TodayLessonItem(Guid groupId, DateOnly date, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
Lesson? lesson = null)
{
GroupId = groupId; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
GroupId = groupId; Date = date; PeriodNumber = periodNumber; GroupName = groupName; Room = room;
ColorHex = colorHex; LessonTopic = lessonTopic; ExamTitle = examTitle;
HasUnhandledHomework = hasUnhandledHomework; Lesson = lesson;
}
public static TodayLessonItem ForSubstitution(int periodNumber, SubstitutionEntry entry) => new(
entry.GroupId ?? Guid.Empty, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
entry.GroupId ?? Guid.Empty, entry.Date, periodNumber, entry.GroupLabel, "", "#8E24AA", entry.Description, null)
{ IsSubstitution = true };
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
groupId, periodNumber, groupName, "", "#757575",
groupId, entry.Date, periodNumber, groupName, "", "#757575",
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
{ IsCancelled = true };
}