961 lines
48 KiB
C#
961 lines
48 KiB
C#
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 (Mo–Fr, 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})");
|
||
}
|