Files
LehrerApp/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
T

1058 lines
54 KiB
C#
Raw 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 CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
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;
// ── Bundesland: deutsche Anzeige (4.3.5) ─────────────────────────────────────
// Wird sowohl vom Stundenplan (Anzeige) als auch von den Einstellungen (Pflege) verwendet.
public static class GermanStateDisplay
{
private static readonly (GermanState State, string Name)[] Entries =
[
(GermanState.BW, "Baden-Württemberg"),
(GermanState.BY, "Bayern"),
(GermanState.BE, "Berlin"),
(GermanState.BB, "Brandenburg"),
(GermanState.HB, "Bremen"),
(GermanState.HH, "Hamburg"),
(GermanState.HE, "Hessen"),
(GermanState.MV, "Mecklenburg-Vorpommern"),
(GermanState.NI, "Niedersachsen"),
(GermanState.NW, "Nordrhein-Westfalen"),
(GermanState.RP, "Rheinland-Pfalz"),
(GermanState.SL, "Saarland"),
(GermanState.SN, "Sachsen"),
(GermanState.ST, "Sachsen-Anhalt"),
(GermanState.SH, "Schleswig-Holstein"),
(GermanState.TH, "Thüringen"),
];
public static string[] Options { get; } = Entries.Select(e => e.Name).ToArray();
public static string Label(GermanState s) => Entries.First(e => e.State == s).Name;
public static GermanState FromLabel(string label) =>
Entries.FirstOrDefault(e => e.Name == label).State;
}
// ── Stundenplan: "Heute"-Übersicht (Standardansicht, Wochenraster + Tagesliste) + Bearbeiten (4.3) ──
public partial class TimetableViewModel : ObservableObject
{
private const int FirstPeriod = 1;
private const int LastPeriod = 10;
private const int GridColumns = 6; // Label/Kopf-Spalte + 5 Wochentage (Mo-Fr)
private static readonly DayOfWeek[] Weekdays =
[DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday];
private static readonly string[] WeekdayColorPalette =
["#7F77DD", "#1D9E75", "#D85A30", "#D4537E", "#378ADD", "#639922", "#EF9F27", "#4C86A8"];
private readonly ITimetableSlotRepository _slots;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly ILessonRepository _lessons;
private readonly IExamRepository _exams;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolYearService _schoolYear;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IUntisSlotMappingRepository _untisMappings;
private readonly WebUntisSettingsService _untisSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly SchoolWeatherService? _schoolWeather;
private readonly SemaphoreSlim _weatherGate = new(1, 1);
private WeatherSnapshot? _weatherSnapshot;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
public ObservableCollection<TodaySupervisionItem> TodaySupervisions { get; } = [];
public ObservableCollection<TodaySpecialAssignmentItem> TodaySpecialAssignments { get; } = [];
public ObservableCollection<UpcomingExamItem> UpcomingExams { get; } = [];
public ObservableCollection<WeekWeatherWarningItem> WeekWeatherWarnings { get; } = [];
/// Cells/WeekItems zeilenweise gruppiert (GridColumns Zellen je Zeile), zusätzlich zur
/// flachen Liste - Grundlage für die zeilenweise Höhensteuerung in der View (Aufsicht-Zeilen
/// deutlich schmaler als Stunden-/Kopfzeilen). Grid.RowDefinitions lässt sich in Avalonia
/// nicht per {Binding} setzen (Compiler lehnt jede Binding-Form dafür ab, siehe
/// AVLN3000-Fehler „Unable to find suitable setter or adder for property RowDefinitions“) -
/// deshalb hier stattdessen echte Unterzeilen mit je eigener, ganz normal bindbarer Height.
public ObservableCollection<TimetableRowItem> GridRows { get; } = [];
public ObservableCollection<WeekRowItem> WeekRows { get; } = [];
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _todayLabel = "";
[ObservableProperty] private string _weekRangeLabel = "";
[ObservableProperty] private int _weekOffset;
public bool IsCurrentWeek => WeekOffset == 0;
// ── WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") ──────
// Kein erneuter iCal-Abruf hier - vergleicht nur den lokal bereits bestätigten
// Zuordnungsstand (UntisSlotMapping, siehe UntisMappingReviewDialog) gegen die aktuellen
// TimetableSlots. Bleibt komplett verborgen, solange der Abgleich nicht aktiviert ist.
[ObservableProperty] private bool _hasUntisMismatch;
[ObservableProperty] private string _untisMismatchLabel = "";
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public Func<Task>? OnImportWebUntisTimetable { get; set; }
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
/// vom tatsächlichen Wochentag/der Uhrzeit im Testlauf abzuhängen.
public Func<DateTime> Clock { get; set; } = () => DateTime.Now;
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear,
ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions,
IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings,
PeriodScheduleService periodSchedule, SchoolWeatherService? schoolWeather = null)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
_supervisionDuties = supervisionDuties; _substitutions = substitutions;
_untisMappings = untisMappings; _untisSettings = untisSettings;
_periodSchedule = periodSchedule;
_schoolWeather = schoolWeather;
Load();
}
partial void OnWeekOffsetChanged(int value) => OnPropertyChanged(nameof(IsCurrentWeek));
[RelayCommand]
private void PreviousWeek() { WeekOffset--; Load(); }
[RelayCommand]
private void NextWeek() { WeekOffset++; Load(); }
[RelayCommand]
private void CurrentWeek() { WeekOffset = 0; Load(); }
[RelayCommand]
private async Task AddSubstitution()
{
if (OnAddSubstitution is null) return;
await OnAddSubstitution();
Load();
}
[RelayCommand]
private async Task ImportWebUntisTimetable()
{
if (OnImportWebUntisTimetable is null) return;
await OnImportWebUntisTimetable();
Load();
}
public void Load()
{
var today = DateOnly.FromDateTime(Clock());
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var publicHolidayDates = new HashSet<DateOnly>();
foreach (var year in new[] { today.Year - 1, today.Year, today.Year + 1 })
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) publicHolidayDates.Add(h.Date);
var holidayBadges = ComputeHolidayBadges(today, publicHolidayDates);
var examProximity = ComputeExamProximity(today, publicHolidayDates);
var duties = _supervisionDuties.GetAll();
BuildGrid(holidayBadges, duties);
BuildWeekOverview(today, publicHolidayDates, duties);
ApplyWeatherToWeekHeaders(today);
_ = LoadWeatherAsync(today);
BuildToday(today);
BuildHoursWarnings();
BuildUpcomingExams(today);
LoadUntisMismatch();
}
private async Task LoadWeatherAsync(DateOnly today)
{
if (_schoolWeather?.IsAvailable != true) return;
if (_weatherSnapshot is not null &&
DateTime.UtcNow - _weatherSnapshot.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
return;
if (!await _weatherGate.WaitAsync(0)) return;
try
{
_weatherSnapshot = await _schoolWeather.GetWeatherAsync();
ApplyWeatherToWeekHeaders(today);
}
catch (SchoolWeatherException) { /* Wetter ist eine optionale Planungshilfe. */ }
finally { _weatherGate.Release(); }
}
private void ApplyWeatherToWeekHeaders(DateOnly today)
{
WeekWeatherWarnings.Clear();
var datedHeaders = WeekItems.Where(x => x.IsHeader && x.Date.HasValue).ToList();
foreach (var header in datedHeaders)
{
var date = header.Date!.Value;
var warnings = _weatherSnapshot?.Warnings
.Where(x => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date))
.ToList() ?? [];
var summary = date >= today && _weatherSnapshot is not null
? SchoolDayWeatherSummary.From(_weatherSnapshot, date)
: null;
header.SetWeather(summary, warnings);
}
if (_weatherSnapshot is null || datedHeaders.Count == 0) return;
var from = datedHeaders.Min(x => x.Date!.Value);
var until = datedHeaders.Max(x => x.Date!.Value);
foreach (var warning in _weatherSnapshot.Warnings
.Where(x => Enumerable.Range(0, until.DayNumber - from.DayNumber + 1)
.Select(from.AddDays)
.Any(date => date >= today && SchoolDayWeatherSummary.WarningTouchesDate(x, date)))
.DistinctBy(x => x.Identifier)
.OrderBy(x => x.Onset))
WeekWeatherWarnings.Add(new WeekWeatherWarningItem(warning));
}
private void LoadUntisMismatch()
{
if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; }
// CoveredPeriods statt nur PeriodNumber: bei einer von WebUntis zu einem Termin
// zusammengefassten Doppelstunde bestätigt eine einzige Zuordnung mehrere TimetableSlots
// auf einmal (siehe UntisSlotMapping.CoveredPeriods-Dokumentation).
var confirmedKeys = _untisMappings.GetAll()
.Where(m => m.Confirmed && m.Kind == SubstitutionKind.Lesson && m.GroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Weekday, period, GroupId: m.GroupId!.Value)))
.ToHashSet();
var mismatchCount = _slots.GetAll()
.Count(s => !confirmedKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)));
HasUntisMismatch = mismatchCount > 0;
UntisMismatchLabel = mismatchCount == 1
? "1 Stundenplan-Eintrag ohne bestätigte WebUntis-Zuordnung."
: $"{mismatchCount} Stundenplan-Einträge ohne bestätigte WebUntis-Zuordnung.";
}
[RelayCommand]
private void ReviewUntisMismatch() => OnNavigateToSettings?.Invoke(SettingsTab.WebUntis);
// ── Anstehende Klausurtermine (4.4.3) ────────────────────────────────────
/// <summary>
/// Mehrtägiger Vorausblick über alle Gruppen hinweg (nicht nur "heute", wie die Tagesliste,
/// und unabhängig von <see cref="WeekOffset"/>, anders als das Wochenraster) — Abgabefristen
/// sind bewusst nicht enthalten, dafür gibt es noch kein eigenes Datenmodell (siehe TODO.md).
/// </summary>
private const int UpcomingExamsHorizonDays = 21;
private void BuildUpcomingExams(DateOnly today)
{
UpcomingExams.Clear();
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
var horizon = today.AddDays(UpcomingExamsHorizonDays);
var upcoming = _exams.GetAll()
.Where(e => e.Date >= today && e.Date <= horizon)
.OrderBy(e => e.Date)
.Take(10);
foreach (var exam in upcoming)
UpcomingExams.Add(new UpcomingExamItem(exam.Date, groupNames.GetValueOrDefault(exam.GroupId, "?"), exam.Title));
}
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
/// <summary>
/// Vertretungsstunden (<see cref="SubstitutionEntry"/>, Kind Lesson) überschreiben für ihre
/// Stunde die normale, aus dem Stundenplan abgeleitete Anzeige — sie beschreiben, was an
/// diesem konkreten Tag tatsächlich stattfindet. Stunden ohne passenden Stundenplan-Slot (z.B.
/// Vertretung in einer fremden Gruppe) werden zusätzlich angehängt.
/// </summary>
private void BuildToday(DateOnly today)
{
var substitutionsToday = _substitutions.GetByDate(today);
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).ToList();
var items = new List<TodayLessonItem>();
var coveredPeriods = new HashSet<int>();
foreach (var slot in slotsToday)
{
coveredPeriods.Add(slot.PeriodNumber);
var lessonSub = substitutionsToday.FirstOrDefault(s => s.Kind == SubstitutionKind.Lesson && s.PeriodNumber == slot.PeriodNumber);
if (lessonSub is not null) { items.Add(TodayLessonItem.ForSubstitution(slot.PeriodNumber, lessonSub)); continue; }
var group = _groups.GetById(slot.GroupId);
if (group is null) continue;
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 = FindLessonForSlot(slot.GroupId, today, slot.PeriodNumber);
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
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));
}
foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.Lesson &&
s.PeriodNumber is int p && !coveredPeriods.Contains(p)))
items.Add(TodayLessonItem.ForSubstitution(sub.PeriodNumber!.Value, sub));
TodayItems.Clear();
foreach (var item in items.OrderBy(i => i.PeriodNumber)) TodayItems.Add(item);
TodaySupervisions.Clear();
foreach (var item in BuildTodaySupervisionItems(today, substitutionsToday)) TodaySupervisions.Add(item);
TodaySpecialAssignments.Clear();
foreach (var sub in substitutionsToday.Where(s => s.Kind == SubstitutionKind.SpecialAssignment))
{
var periodLabel = sub.IsAllDay ? "Ganztägig" : $"{sub.FromPeriod}.{sub.ToPeriod}. Stunde";
TodaySpecialAssignments.Add(new TodaySpecialAssignmentItem(periodLabel, sub.Description, sub.GroupLabel));
}
}
private List<TodaySupervisionItem> BuildTodaySupervisionItems(DateOnly today, List<SubstitutionEntry> substitutionsToday)
{
var dutiesToday = _supervisionDuties.GetAll().Where(d => d.Weekday == today.DayOfWeek).ToList();
var subsToday = substitutionsToday.Where(s => s.Kind == SubstitutionKind.Supervision).ToList();
var afterPeriods = dutiesToday.Select(d => d.AfterPeriod)
.Concat(subsToday.Select(s => s.AfterPeriod!.Value))
.Distinct().OrderBy(p => p);
var result = new List<TodaySupervisionItem>();
foreach (var afterPeriod in afterPeriods)
{
var sub = subsToday.FirstOrDefault(s => s.AfterPeriod == afterPeriod);
if (sub is not null) { result.Add(new TodaySupervisionItem(afterPeriod, sub.Description, isSubstitution: true)); continue; }
var duty = dutiesToday.First(d => d.AfterPeriod == afterPeriod);
result.Add(new TodaySupervisionItem(afterPeriod, duty.Location, isSubstitution: false));
}
return result;
}
[RelayCommand]
private void OpenGroup(Guid groupId)
{
if (groupId == Guid.Empty) return; // Vertretung in fremder Gruppe ohne echte GroupId
OnNavigateToGroup?.Invoke(groupId);
}
/// 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 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);
}
/// Startet den Unterrichtsmodus (14.x) für eine konkrete, heute stattfindende Stunde —
/// bewusst nur aus der "Heute"-Tagesliste heraus erreichbar (nicht dem Wochenraster), da der
/// Modus für das aktive Unterrichten HEUTE gedacht ist, nicht zum Durchblättern anderer Tage.
[RelayCommand]
private async Task StartTeachingMode(TodayLessonItem? item)
{
if (item?.Lesson is not { } lesson || OnOpenTeachingMode is null) return;
await OnOpenTeachingMode(lesson);
}
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen. Existiert eine Lesson UND ist gerade (heute, mit Toleranz vor/
/// 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 die weiteren Ziele (einschließlich Anlegen/Verschieben) explizit an.
[RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item)
{
if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson)
{
if (IsAroundTeachingTime(lesson.Date, item.PeriodNumber) && OnOpenTeachingMode is not null)
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);
}
/// Bewusst mit Toleranz vor/nach der eingetragenen Stundenzeit (siehe
/// <see cref="PeriodScheduleService"/>, "Stundenraster" in den Einstellungen) statt exakt an
/// 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 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;
// 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]
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ──
/// <summary>
/// Zeigt Fach, Klasse, Raum und (falls für den Tag hinterlegt) das Thema der Stunde für die
/// per <see cref="WeekOffset"/> gewählte Kalenderwoche (MoFr, wie das Bearbeiten-Raster) —
/// anders als die Tagesliste auch für Tage, die noch nicht "heute" sind, damit z.B. der
/// parallele Kurs oder die nächste Stunde in der Woche auf einen Blick sichtbar sind. Die
/// Badges (Ferien-Nähe, Klausur-Nähe) werden je Zelle am dort angezeigten Datum ausgerichtet,
/// nicht am realen "heute" — sonst würde beim Blättern in andere Wochen ein Badge angezeigt,
/// das eigentlich zu einer ganz anderen Woche gehört. Aufsicht-Zeilen (wiederkehrend + einmalige
/// Vertretungsaufsicht) werden zwischen den betroffenen Stundenzeilen eingefügt, Vertretungsstunden
/// ersetzen für ihre Stunde die sonst aus dem Stundenplan abgeleitete Anzeige.
/// </summary>
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates, List<SupervisionDuty> duties)
{
WeekItems.Clear();
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
var monday = currentWeekMonday.AddDays(WeekOffset * 7);
var friday = monday.AddDays(4);
WeekRangeLabel = $"{monday:dd.MM.} {friday:dd.MM.yyyy}";
var allSlots = _slots.GetAll();
var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
var schoolHolidays = _schoolHolidays.GetAll();
var dateByWeekday = Weekdays.ToDictionary(w => w, w => monday.AddDays((int)w - (int)DayOfWeek.Monday));
var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod);
var substitutionsThisWeek = dateByWeekday.Values.SelectMany(d => _substitutions.GetByDate(d)).ToList();
WeekItems.Add(WeekCellItem.Corner());
foreach (var weekday in Weekdays)
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
AddWeekSupervisionRowIfAny(0, dutiesByPeriod, substitutionsThisWeek, dateByWeekday);
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
WeekItems.Add(WeekCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var date = dateByWeekday[weekday];
var lessonSub = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Lesson && s.Date == date && s.PeriodNumber == period);
if (lessonSub is not null)
{
WeekItems.Add(WeekCellItem.ForSubstitutionLesson(weekday, period, date == today, lessonSub));
continue;
}
var specialAssignment = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.SpecialAssignment && s.Date == date &&
(s.IsAllDay || (period >= s.FromPeriod && period <= s.ToPeriod)));
if (specialAssignment is not null)
{
WeekItems.Add(WeekCellItem.ForSpecialAssignment(weekday, period, date == today, specialAssignment));
continue;
}
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
var group = groups.GetValueOrDefault(slot.GroupId);
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
var cancelled = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Cancelled && s.Date == date && s.PeriodNumber == period);
if (cancelled is not null)
{
WeekItems.Add(WeekCellItem.ForCancelled(weekday, period, date == today, cancelled,
subject?.ShortName is { Length: > 0 } csn ? csn : subject?.Name ?? "",
group?.Name ?? "?", slot.GroupId));
continue;
}
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, date,
subject?.ShortName is { Length: > 0 } sn ? sn : subject?.Name ?? "",
group?.Name ?? "?", slot.Room ?? "", lesson?.Topic ?? "",
colorHex, holidayBadge, hasExam, isLastBeforeExam,
MentionsExperiment(lesson), slot.GroupId, isHoliday,
HasUnhandledHomework(slot.GroupId, date), lesson));
}
AddWeekSupervisionRowIfAny(period, dutiesByPeriod, substitutionsThisWeek, dateByWeekday);
}
WeekRows.Clear();
foreach (var rowCells in WeekItems.Chunk(GridColumns))
WeekRows.Add(new WeekRowItem(rowCells));
}
private void AddWeekSupervisionRowIfAny(int afterPeriod, ILookup<int, SupervisionDuty> dutiesByPeriod,
List<SubstitutionEntry> substitutionsThisWeek, Dictionary<DayOfWeek, DateOnly> dateByWeekday)
{
var hasAny = dutiesByPeriod.Contains(afterPeriod) ||
substitutionsThisWeek.Any(s => s.Kind == SubstitutionKind.Supervision && s.AfterPeriod == afterPeriod);
if (!hasAny) return;
WeekItems.Add(WeekCellItem.SupervisionRowLabel(afterPeriod));
foreach (var weekday in Weekdays)
{
var date = dateByWeekday[weekday];
var substitution = substitutionsThisWeek.FirstOrDefault(s =>
s.Kind == SubstitutionKind.Supervision && s.Date == date && s.AfterPeriod == afterPeriod);
if (substitution is not null)
{
WeekItems.Add(WeekCellItem.SupervisionCell(substitution.Description, isSubstitution: true));
continue;
}
var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday);
WeekItems.Add(WeekCellItem.SupervisionCell(duty?.Location ?? "", isSubstitution: false));
}
}
/// <summary>Badge "1"/"2" für die Zelle mit Datum <paramref name="date"/> selbst, analog zu
/// <see cref="ComputeHolidayBadges"/>, aber an diesem konkreten Datum statt an "heute"
/// ausgerichtet — nötig, damit das Badge beim Blättern durch die Wochen zur richtigen Woche
/// gehört.</summary>
private string HolidayBadgeFor(DateOnly date, DayOfWeek weekday, List<SchoolHoliday> schoolHolidays,
HashSet<DateOnly> publicHolidayDates)
{
var nextHoliday = schoolHolidays.Where(h => h.StartDate > date).MinBy(h => h.StartDate);
if (nextHoliday is null) return "";
var count = CountOccurrences(weekday, date, nextHoliday.StartDate, publicHolidayDates);
return count is 1 or 2 ? count.ToString() : "";
}
/// <summary>Analog zu <see cref="ComputeExamProximity"/>, aber am Zelldatum statt an "heute"
/// ausgerichtet.</summary>
private bool IsLastBeforeExamFor(DateOnly date, DayOfWeek weekday, Guid groupId, HashSet<DateOnly> publicHolidayDates)
{
var nextExam = _exams.GetByGroup(groupId).Where(e => e.Date >= date).MinBy(e => e.Date);
if (nextExam is null) return false;
return CountOccurrences(weekday, date, nextExam.Date, publicHolidayDates) == 1;
}
private static bool MentionsExperiment(Lesson? lesson) =>
lesson?.Phases?.Any(p => p is not null &&
(p.Name?.Contains("Experiment", StringComparison.OrdinalIgnoreCase) == true ||
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, wenn der Stundenplan dort unmittelbar mit derselben
/// Lerngruppe fortgesetzt wird. Das darf nicht von bereits eingetragenen Phasendauern abhängen:
/// Gerade ein früher Entwurf hat oft erst Datum und Thema, gehört aber trotzdem zum kompletten
/// Doppelstundenblock. 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 periodsInBlock = _slots.GetByGroup(groupId)
.Where(s => s.Weekday == date.DayOfWeek)
.Select(s => s.PeriodNumber)
.ToHashSet();
// Vom angefragten Slot innerhalb desselben lückenlosen Blocks rückwärts zur verankerten
// Lesson laufen. Damit funktionieren neben Doppelstunden auch seltene Dreifachblöcke.
for (var candidatePeriod = period - 1;
periodsInBlock.Contains(candidatePeriod) && periodsInBlock.Contains(candidatePeriod + 1);
candidatePeriod--)
{
var blockLesson = lessons.Where(l => l.LessonNumber == candidatePeriod)
.OrderByDescending(l => l.UpdatedAt).FirstOrDefault();
if (blockLesson is not null) return blockLesson;
}
return 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
/// eine neuere Lesson stattfindet, ist eine noch ältere offene Hausaufgabe nicht mehr das, worauf
/// sich "letzte Stunde" bezieht. Lookback-Fenster von 120 Tagen deckt auch längere Ferienpausen
/// ab, ohne unbegrenzt weit zurückzuscannen.</summary>
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
{
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
.FirstOrDefault();
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
return !previousLesson.HomeworkChecked && !previousLesson.HomeworkCheckDismissed;
}
/// <summary>Fällt <paramref name="date"/> auf einen gesetzlichen Feiertag oder in Schulferien?</summary>
private static bool IsFreeDay(DateOnly date, List<SchoolHoliday> schoolHolidays, HashSet<DateOnly> publicHolidayDates) =>
publicHolidayDates.Contains(date) || schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate);
// ── Bearbeiten-Raster ─────────────────────────────────────────────────────
private void BuildGrid(Dictionary<(DayOfWeek Weekday, Guid GroupId), string> badges, List<SupervisionDuty> duties)
{
Cells.Clear();
var allSlots = _slots.GetAll();
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
var dutiesByPeriod = duties.ToLookup(d => d.AfterPeriod);
Cells.Add(TimetableCellItem.Corner());
foreach (var weekday in Weekdays)
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
AddGridSupervisionRowIfAny(0, dutiesByPeriod);
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
Cells.Add(TimetableCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
var groupName = slot is not null ? groupNames.GetValueOrDefault(slot.GroupId, "?") : "";
var badge = slot is not null ? badges.GetValueOrDefault((weekday, slot.GroupId), "") : "";
Cells.Add(TimetableCellItem.ForSlot(weekday, period, slot, groupName, ColorFor(groupName), badge));
}
AddGridSupervisionRowIfAny(period, dutiesByPeriod);
}
GridRows.Clear();
foreach (var rowCells in Cells.Chunk(GridColumns))
GridRows.Add(new TimetableRowItem(rowCells));
}
/// <summary>Aufsicht wird nur in den Einstellungen gepflegt (siehe SettingsView) — im
/// Bearbeiten-Raster ist die Zeile bewusst nur Anzeige, kein weiterer Klick-Dialog nötig für
/// eine so kleine, seltene Pflegeaufgabe.</summary>
private void AddGridSupervisionRowIfAny(int afterPeriod, ILookup<int, SupervisionDuty> dutiesByPeriod)
{
if (!dutiesByPeriod.Contains(afterPeriod)) return;
Cells.Add(TimetableCellItem.SupervisionRowLabel(afterPeriod));
foreach (var weekday in Weekdays)
{
var duty = dutiesByPeriod[afterPeriod].FirstOrDefault(d => d.Weekday == weekday);
Cells.Add(TimetableCellItem.SupervisionCell(duty?.Location ?? ""));
}
}
private static string ColorFor(string name)
{
if (name.Length == 0) return "#9E9E9E";
var hash = 0;
foreach (var c in name) hash = hash * 31 + c;
return WeekdayColorPalette[Math.Abs(hash) % WeekdayColorPalette.Length];
}
[RelayCommand]
private async Task EditCell(TimetableCellItem? cell)
{
if (cell is null || !cell.IsSlotCell || OnEditSlot is null) return;
await OnEditSlot(cell);
Load();
}
// ── Abgleich mit Wochenstunden (4.3.4) ────────────────────────────────────
private void BuildHoursWarnings()
{
HoursWarnings.Clear();
var currentYear = _schoolYear.CurrentSchoolYear();
var slotCounts = _slots.GetAll().GroupBy(s => s.GroupId).ToDictionary(g => g.Key, g => g.Count());
foreach (var group in _groups.GetBySchoolYear(currentYear).Where(g => g.HoursPerWeek.HasValue))
{
var assigned = slotCounts.GetValueOrDefault(group.Id, 0);
if (assigned != group.HoursPerWeek!.Value)
HoursWarnings.Add(new HoursWarningItem(group.Name, assigned, group.HoursPerWeek.Value));
}
}
// ── Badges: letzte/vorletzte Stunde vor Ferien, letzte Stunde vor Klausur ────────────────
/// <summary>
/// Zählt, wie oft <paramref name="weekday"/> zwischen (inkl.) <paramref name="from"/> und
/// (exkl.) <paramref name="until"/> eintritt — gesetzliche Feiertage werden übersprungen, da
/// an ihnen ohnehin kein Unterricht stattfindet.
/// </summary>
private static int CountOccurrences(DayOfWeek weekday, DateOnly from, DateOnly until, HashSet<DateOnly> publicHolidays)
{
var count = 0;
for (var date = from; date < until; date = date.AddDays(1))
if (date.DayOfWeek == weekday && !publicHolidays.Contains(date)) count++;
return count;
}
/// <summary>
/// Badge "1"/"2" für die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
/// anstehenden Schulferien. Pro (Wochentag, Gruppe) statt pro einzelnem <see cref="TimetableSlot"/>
/// berechnet: eine Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben
/// Gruppe und bekommt dadurch automatisch dasselbe Badge, ohne gesonderte Blockerkennung.
/// </summary>
private Dictionary<(DayOfWeek Weekday, Guid GroupId), string> ComputeHolidayBadges(
DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
var result = new Dictionary<(DayOfWeek, Guid), string>();
var nextHoliday = _schoolHolidays.GetAll().Where(h => h.StartDate > today).MinBy(h => h.StartDate);
if (nextHoliday is null) return result;
foreach (var group in _slots.GetAll().GroupBy(s => (s.Weekday, s.GroupId)))
{
var count = CountOccurrences(group.Key.Weekday, today, nextHoliday.StartDate, publicHolidayDates);
if (count is 1 or 2) result[group.Key] = count.ToString();
}
return result;
}
/// <summary>
/// Markiert die letzte Stunde eines Wochentags vor der nächsten anstehenden Klausur derselben
/// Gruppe (sofern eine ansteht) — analog zum Ferien-Badge, aber je Gruppe an deren eigenem
/// nächsten Klausurtermin statt an einem gemeinsamen Ferientermin ausgerichtet.
/// </summary>
private Dictionary<(DayOfWeek Weekday, Guid GroupId), bool> ComputeExamProximity(
DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
var result = new Dictionary<(DayOfWeek, Guid), bool>();
foreach (var groupSlots in _slots.GetAll().GroupBy(s => s.GroupId))
{
var nextExam = _exams.GetByGroup(groupSlots.Key).Where(e => e.Date >= today).MinBy(e => e.Date);
if (nextExam is null) continue;
foreach (var weekday in groupSlots.Select(s => s.Weekday).Distinct())
{
var count = CountOccurrences(weekday, today, nextExam.Date, publicHolidayDates);
if (count == 1) result[(weekday, groupSlots.Key)] = true;
}
}
return result;
}
}
public class TimetableCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSupervisionRow { get; private init; }
public bool IsSupervisionCell { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public TimetableSlot? Slot { get; private init; }
public string GroupName { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string BadgeText { get; private init; } = "";
public string SupervisionLocation { get; private init; } = "";
public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasBadge => BadgeText.Length > 0;
public bool IsAssigned => Slot is not null;
public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell;
public static TimetableCellItem Corner() => new() { IsHeader = true, Text = "" };
public static TimetableCellItem WeekdayHeader(DayOfWeek day) => new()
{
IsHeader = true,
Text = day.ToString() switch
{
"Monday" => "Mo", "Tuesday" => "Di", "Wednesday" => "Mi",
"Thursday" => "Do", "Friday" => "Fr", _ => day.ToString(),
},
};
public static TimetableCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static TimetableCellItem SupervisionRowLabel(int afterPeriod) => new()
{
IsSupervisionRow = true,
Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)",
};
public static TimetableCellItem SupervisionCell(string location) => new()
{
IsSupervisionCell = true,
SupervisionLocation = location,
};
public static TimetableCellItem ForSlot(DayOfWeek day, int period, TimetableSlot? slot, string groupName,
string colorHex, string badgeText) => new()
{
Weekday = day, PeriodNumber = period, Slot = slot, GroupName = groupName, ColorHex = colorHex,
BadgeText = badgeText,
Text = slot is null ? "" : groupName + (string.IsNullOrWhiteSpace(slot.Room) ? "" : $" · {slot.Room}"),
};
}
/// <summary>
/// Eine Zeile im Bearbeiten-Raster (GridColumns Zellen). Aufsicht-Zeilen bekommen eine deutlich
/// schmalere RowHeight als normale Kopf-/Stundenzeilen - eine echte, per Height ganz normal
/// bindbare Eigenschaft je Unterzeile statt eines vollflächigen UniformGrid-Rasters, das allen
/// Zeilen zwangsläufig dieselbe Höhe geben würde.
/// </summary>
public class TimetableRowItem(IReadOnlyList<TimetableCellItem> cells)
{
public IReadOnlyList<TimetableCellItem> Cells { get; } = cells;
public double RowHeight { get; } = cells[0].IsSupervisionRow ? 22 : 46;
}
/// <summary>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
public partial class WeekCellItem : ObservableObject
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSupervisionRow { get; private init; }
public bool IsSupervisionCell { get; private init; }
public bool IsSlotCell => !IsHeader && !IsPeriodLabel && !IsSupervisionRow && !IsSupervisionCell;
public bool IsAssigned { get; private init; }
public bool IsSubstitutionLesson { get; private init; }
public bool IsSubstitutionSupervision { get; private init; }
public bool IsSpecialAssignment { get; private init; }
public bool IsCancelled { get; private init; }
public string Text { get; private init; } = "";
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public bool IsToday { get; private init; }
public DateOnly? Date { get; private init; }
public Guid GroupId { get; private init; }
public string SubjectLabel { get; private init; } = "";
public string GroupName { get; private init; } = "";
public string Room { get; private init; } = "";
public string Topic { get; private init; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string HolidayBadge { get; private init; } = "";
public bool HasHolidayBadge => HolidayBadge.Length > 0;
public bool HasExam { get; private init; }
public bool IsLastBeforeExam { get; private init; }
public bool HasExperiment { get; private init; }
public bool HasUnhandledHomework { get; private init; }
public bool IsHoliday { get; private init; }
public string SupervisionLocation { get; private init; } = "";
public bool HasSupervision => SupervisionLocation.Length > 0;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
public bool HasGroupId => GroupId != Guid.Empty;
/// Nur bei einer regulären, zugewiesenen Zelle mit bereits existierender Lesson gesetzt —
/// 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 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;
public bool HasWeather => WeatherSymbol.Length > 0;
public bool HasWeatherInformation => HasWeather || HasWeatherWarning;
partial void OnWeatherSymbolChanged(string value)
{
OnPropertyChanged(nameof(HasWeather));
OnPropertyChanged(nameof(HasWeatherInformation));
}
partial void OnHasWeatherWarningChanged(bool value) =>
OnPropertyChanged(nameof(HasWeatherInformation));
public void SetWeather(SchoolDayWeatherSummary? summary, IReadOnlyList<WeatherWarning> warnings)
{
WeatherSymbol = summary?.Symbol ?? "";
WeatherTooltip = summary?.Tooltip ?? string.Join("\n", warnings.Select(x =>
$"⚠ {x.Headline} ({SchoolDayWeatherSummary.WarningPeriod(x)})"));
HasWeatherWarning = warnings.Count > 0;
}
public static WeekCellItem Corner() => new() { IsHeader = true };
public static WeekCellItem WeekdayHeader(DayOfWeek day, DateOnly date, bool isToday) => new()
{
IsHeader = true,
IsToday = isToday,
Date = date,
Text = (day switch
{
DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi",
DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", _ => day.ToString(),
}) + $" {date:dd.MM.}",
};
public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period };
public static WeekCellItem SupervisionRowLabel(int afterPeriod) => new()
{
IsSupervisionRow = true,
Text = afterPeriod == 0 ? "Aufsicht (vor 1.)" : $"Aufsicht (n. {afterPeriod}.)",
};
public static WeekCellItem SupervisionCell(string location, bool isSubstitution) => new()
{
IsSupervisionCell = true,
SupervisionLocation = location,
IsSubstitutionSupervision = isSubstitution,
};
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, 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,
SubjectLabel = "Vertretung", GroupName = entry.GroupLabel, Topic = entry.Description,
ColorHex = "#8E24AA", GroupId = entry.GroupId ?? Guid.Empty,
};
public static WeekCellItem ForSpecialAssignment(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsSpecialAssignment = true,
SubjectLabel = "Sondereinsatz",
GroupName = string.IsNullOrWhiteSpace(entry.GroupLabel) ? "" : entry.GroupLabel,
Topic = entry.Description,
ColorHex = "#00838F", GroupId = entry.GroupId ?? Guid.Empty,
};
public static WeekCellItem ForCancelled(DayOfWeek day, int period, bool isToday, SubstitutionEntry entry,
string subjectLabel, string groupName, Guid groupId) => new()
{
Weekday = day, PeriodNumber = period, IsAssigned = true, IsToday = isToday, IsCancelled = true,
SubjectLabel = subjectLabel, GroupName = groupName, Topic = entry.Description,
ColorHex = "#757575", GroupId = groupId,
};
}
/// <summary>Zeile im Wochenraster - siehe TimetableRowItem, dieselbe Rolle für WeekItems.</summary>
public class WeekRowItem(IReadOnlyList<WeekCellItem> cells)
{
public IReadOnlyList<WeekCellItem> Cells { get; } = cells;
public double RowHeight { get; } = cells[0].IsSupervisionRow ? 22 : 76;
}
public class HoursWarningItem(string groupName, int assigned, int expected)
{
public string GroupName { get; } = groupName;
public string Text { get; } = $"{groupName}: {assigned} von {expected} Wochenstunden eingetragen";
}
public class UpcomingExamItem(DateOnly date, string groupName, string title)
{
public DateOnly Date { get; } = date;
public string DateDisplay { get; } = date.ToString("dd.MM.yyyy");
public string GroupName { get; } = groupName;
public string Title { get; } = title;
}
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; } = "";
public string ColorHex { get; private init; } = "#9E9E9E";
public string? LessonTopic { get; private init; }
public string? ExamTitle { get; private init; }
public bool IsSubstitution { get; private init; }
public bool IsCancelled { get; private init; }
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
public bool HasExam => ExamTitle is not null;
public bool HasGroupId => GroupId != Guid.Empty;
public bool HasUnhandledHomework { get; private init; }
/// Nur gesetzt, wenn für diesen Slot/Tag bereits eine Lesson existiert — Grundlage für den
/// 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 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, DateOnly date, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle, bool hasUnhandledHomework = false,
Lesson? lesson = null)
{
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, 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, entry.Date, periodNumber, groupName, "", "#757575",
string.IsNullOrWhiteSpace(entry.Description) ? null : entry.Description, null)
{ IsCancelled = true };
}
public class TodaySupervisionItem(int afterPeriod, string description, bool isSubstitution)
{
public int AfterPeriod { get; } = afterPeriod;
public string PeriodLabel { get; } = afterPeriod == 0 ? "Vor der 1. Stunde" : $"Nach der {afterPeriod}. Stunde";
public string Description { get; } = description;
public bool IsSubstitution { get; } = isSubstitution;
}
public class TodaySpecialAssignmentItem(string periodLabel, string description, string groupLabel)
{
public string PeriodLabel { get; } = periodLabel;
public string Description { get; } = description;
public string GroupLabel { get; } = groupLabel;
public bool HasGroupLabel => !string.IsNullOrWhiteSpace(GroupLabel);
public string DisplayText { get; } =
$"{periodLabel} — {description}" + (string.IsNullOrWhiteSpace(groupLabel) ? "" : $" ({groupLabel})");
}