Stundenplan: Wochenraster, Wochennavigation, Ferien/Feiertage (Kapitel 4.3)

Neuer Stundenplan mit "Heute"-Standardansicht (Tagesliste unten angedockt,
gruppenübergreifendes Wochenraster mit Fach/Klasse/Raum/Thema, Vor-/Zurück-
Navigation zwischen Kalenderwochen) und separatem Bearbeiten-Raster für die
wöchentliche Zuordnung. Badges für Ferien-/Klausur-Nähe und ausgegraute
Ferientage direkt im Plan statt einer separaten Liste. Ferien-/Feiertage-
Pflege (Bundesland, Schulferien) sitzt jetzt in den Einstellungen statt im
Stundenplan selbst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 02:29:18 +02:00
co-authored by Claude Sonnet 5
parent ee1a47641e
commit 99b2de6519
26 changed files with 2154 additions and 18 deletions
@@ -0,0 +1,90 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Planning;
/// <summary>Zuweisen/Bearbeiten/Entfernen eines Stundenplan-Termins (4.3.3).</summary>
public partial class TimetableSlotDialogViewModel : ObservableObject
{
private readonly ITimetableSlotRepository _slots;
private readonly Dictionary<string, Guid> _groupIdsByName;
private readonly TimetableSlot? _editing;
public DayOfWeek Weekday { get; }
public int PeriodNumber { get; }
public string WeekdayLabel { get; }
public string DialogTitle { get; }
public bool IsEditing => _editing is not null;
[ObservableProperty] private string _selectedGroupName = "";
[ObservableProperty] private string _room = "";
[ObservableProperty] private string _groupError = "";
public string[] GroupOptions { get; }
/// null = unverändert/Abbruch, sonst das neue/aktualisierte Ergebnis.
public TimetableSlot? Result { get; private set; }
public bool Deleted { get; private set; }
public TimetableSlotDialogViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, TimetableSlot? editing)
{
_slots = slots;
_editing = editing;
Weekday = weekday;
PeriodNumber = periodNumber;
WeekdayLabel = weekday switch
{
DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag", _ => weekday.ToString(),
};
DialogTitle = $"{WeekdayLabel}, {periodNumber}. Stunde";
var availableGroups = groups.GetBySchoolYear(schoolYear.CurrentSchoolYear()).OrderBy(g => g.Name).ToList();
_groupIdsByName = availableGroups.ToDictionary(g => g.Name, g => g.Id);
GroupOptions = availableGroups.Select(g => g.Name).ToArray();
if (editing is not null)
{
SelectedGroupName = availableGroups.FirstOrDefault(g => g.Id == editing.GroupId)?.Name ?? "";
Room = editing.Room ?? "";
}
}
[RelayCommand]
private void Save()
{
GroupError = "";
if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByName.TryGetValue(SelectedGroupName, out var groupId))
{
GroupError = "Bitte eine Gruppe auswählen.";
return;
}
var slot = _editing ?? new TimetableSlot { Weekday = Weekday, PeriodNumber = PeriodNumber };
slot.GroupId = groupId;
slot.Room = string.IsNullOrWhiteSpace(Room) ? null : Room.Trim();
try
{
_slots.Save(slot);
Result = slot;
}
catch (InvalidOperationException ex)
{
GroupError = ex.Message;
}
}
[RelayCommand]
private void Delete()
{
if (_editing is null) return;
_slots.Delete(_editing.Id);
Deleted = true;
}
}
@@ -0,0 +1,456 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
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 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;
public ObservableCollection<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> WeekItems { get; } = [];
public ObservableCollection<HoursWarningItem> HoursWarnings { get; } = [];
public ObservableCollection<TodayLessonItem> TodayItems { get; } = [];
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _todayLabel = "";
[ObservableProperty] private string _weekRangeLabel = "";
[ObservableProperty] private int _weekOffset;
public bool IsCurrentWeek => WeekOffset == 0;
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings,
PublicHolidayService publicHolidays, SchoolYearService schoolYear)
{
_slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams;
_schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings;
_publicHolidays = publicHolidays; _schoolYear = schoolYear;
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(); }
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);
BuildGrid(holidayBadges);
BuildWeekOverview(today, publicHolidayDates);
BuildToday(today);
BuildHoursWarnings();
}
// ── "Heute": Tagesliste ──────────────────────────────────────────────────
private void BuildToday(DateOnly today)
{
TodayItems.Clear();
var slotsToday = _slots.GetAll().Where(s => s.Weekday == today.DayOfWeek).OrderBy(s => s.PeriodNumber).ToList();
foreach (var slot in slotsToday)
{
var group = _groups.GetById(slot.GroupId);
if (group is null) continue;
var lesson = _lessons.GetByGroupAndDate(slot.GroupId, today).FirstOrDefault();
var exam = _exams.GetByGroup(slot.GroupId).FirstOrDefault(e => e.Date == today);
TodayItems.Add(new TodayLessonItem(slot.GroupId, slot.PeriodNumber, group.Name,
slot.Room ?? "", ColorFor(group.Name), lesson?.Topic, exam?.Title));
}
}
[RelayCommand]
private void OpenGroup(Guid groupId) => OnNavigateToGroup?.Invoke(groupId);
[RelayCommand]
private void ShowEditor() => ActiveTabIndex = 1;
// ── "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.
/// </summary>
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates)
{
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));
WeekItems.Add(WeekCellItem.Corner());
foreach (var weekday in Weekdays)
WeekItems.Add(WeekCellItem.WeekdayHeader(weekday, dateByWeekday[weekday], dateByWeekday[weekday] == today));
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
WeekItems.Add(WeekCellItem.PeriodLabel(period));
foreach (var weekday in Weekdays)
{
var slot = allSlots.FirstOrDefault(s => s.Weekday == weekday && s.PeriodNumber == period);
if (slot is null) { WeekItems.Add(WeekCellItem.Empty(weekday, period)); continue; }
var date = dateByWeekday[weekday];
var group = groups.GetValueOrDefault(slot.GroupId);
var subject = group?.SubjectId is { } subjectId ? _subjects.GetById(subjectId) : null;
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));
}
}
}
/// <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 is not null && lesson.Phases.Any(p =>
p.Name.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
p.Activity.Contains("Experiment", StringComparison.OrdinalIgnoreCase) ||
p.Material.Contains("Experiment", StringComparison.OrdinalIgnoreCase));
/// <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)
{
Cells.Clear();
var allSlots = _slots.GetAll();
var groupNames = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id, g => g.Name);
Cells.Add(TimetableCellItem.Corner());
foreach (var weekday in Weekdays)
Cells.Add(TimetableCellItem.WeekdayHeader(weekday));
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));
}
}
}
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 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 bool HasBadge => BadgeText.Length > 0;
public bool IsAssigned => Slot is not null;
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
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 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>Zelle im schreibgeschützten Wochenraster der "Heute"-Ansicht.</summary>
public class WeekCellItem
{
public bool IsHeader { get; private init; }
public bool IsPeriodLabel { get; private init; }
public bool IsSlotCell => !IsHeader && !IsPeriodLabel;
public bool IsAssigned { 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 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 IsHoliday { get; private init; }
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasTopic => !string.IsNullOrWhiteSpace(Topic);
public static WeekCellItem Corner() => new() { IsHeader = true };
public static WeekCellItem WeekdayHeader(DayOfWeek day, DateOnly date, bool isToday) => new()
{
IsHeader = true,
IsToday = isToday,
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 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) => 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,
};
}
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 TodayLessonItem(Guid groupId, int periodNumber, string groupName, string room,
string colorHex, string? lessonTopic, string? examTitle)
{
public Guid GroupId { get; } = groupId;
public int PeriodNumber { get; } = periodNumber;
public string GroupName { get; } = groupName;
public string Room { get; } = room;
public string ColorHex { get; } = colorHex;
public string? LessonTopic { get; } = lessonTopic;
public string? ExamTitle { get; } = examTitle;
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
public bool HasLessonTopic => !string.IsNullOrWhiteSpace(LessonTopic);
public bool HasExam => ExamTitle is not null;
}