Files
LehrerApp/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs
T
2026-08-24 21:52:00 +02:00

961 lines
48 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 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 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 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,
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;
_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(DateTime.Today);
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 = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
items.Add(new TodayLessonItem(slot.GroupId, 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 (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).
[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 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);
}
[RelayCommand]
private async Task OpenWeekCell(WeekCellItem? item)
{
if (item is null || item.GroupId == Guid.Empty) return;
if (item.Lesson is { } lesson && OnOpenLessonViewer is not null) await OnOpenLessonViewer(lesson);
else OnNavigateToGroup?.Invoke(item.GroupId);
}
[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 = _lessons.GetByGroupAndDate(slot.GroupId, date).FirstOrDefault();
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,
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;
/// <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);
/// 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 string OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
[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, 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,
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,
};
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 class TodayLessonItem
{
public Guid GroupId { 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 OpenButtonLabel => Lesson is not null ? "Verlaufsplan ansehen" : "Zur Lerngruppe";
public TodayLessonItem(Guid groupId, 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;
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)
{ IsSubstitution = true };
public static TodayLessonItem ForCancelled(Guid groupId, int periodNumber, string groupName, SubstitutionEntry entry) => new(
groupId, 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})");
}