WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle

Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:30:15 +02:00
co-authored by Claude Sonnet 5
parent e65a729f97
commit 4eb4d0a946
30 changed files with 3333 additions and 8 deletions
+240
View File
@@ -890,6 +890,246 @@ war zudem redundant (das Bearbeiten-Tab ist direkt anklickbar) und fühlte sich
- `TimetableViewModel.ShowEditorCommand` entfernt (kein Aufrufer mehr) statt umbenannt — es tat
ohnehin nur `ActiveTabIndex = 1`, was jetzt nirgends mehr gebraucht wird.
**Nachtrag zu 4.3, neunte Iteration (WebUntis-iCal-Abgleich):** Nutzer-Feedback: der eigene
Stundenplan ist zwar schon in der App hinterlegt, aber das Schulsystem (WebUntis) veröffentlicht
zusätzlich einen persönlichen iCal-Feed mit Echtzeit-Änderungen (Vertretung, Ausfall,
Raumänderung). Wunsch: die App soll diesen Feed periodisch selbst abrufen, in zwei Stufen — erst
eine "fuzzy logic", die die regulären WebUntis-Fächer dem eigenen Stundenplan zuordnet und
"bewacht", dass keines davon plötzlich nicht mehr passt, dann ein Abgleich gegen einen lokalen
Schnappschuss, um konkrete Änderungen zu erkennen und als `SubstitutionEntry` zu übernehmen.
Vor dem Entwurf wurde der echte iCal-Feed des Nutzers einmalig testweise abgerufen (danach
sofort wieder gelöscht), um nicht blind gegen die RFC-5545-Spezifikation zu entwickeln: iCal4j-
generiert, keine RRULE-Wiederholung (jede Wochenstunde ist bereits ein eigenes VEVENT über ein
Schuljahr, UID pro Wochen-Slot stabil), kein Line-Folding, keine VALARM-Blöcke — ein
selbstgeschriebener schlanker Parser reicht, keine neue NuGet-Abhängigkeit (Nutzerentscheidung,
Alternative wäre `Ical.Net` gewesen). Alle gesampelten Termine hatten `STATUS:CONFIRMED` (Feed
kurz nach Schuljahresbeginn abgerufen) — das Design verlässt sich deshalb primär auf den
Schnappschuss-Abgleich, nicht auf eine bestimmte WebUntis-Kodierung von Vertretungen.
- **`LehrerApp.Core/Services/IcsParser.cs`**: minimaler RFC-5545-Teilparser (VEVENT-Blöcke,
`KEY;PARAM=VAL:VALUE`-Zeilen, Escaping, defensives Line-Unfolding und UTC-`Z`-Handling trotz im
echten Feed nicht beobachtet) → `List<UntisIcsEvent>`.
- **`LehrerApp.Core/Services/UntisMatchingService.cs`** (Stufe 1): leitet aus einem vollen Fetch
das reguläre Wochenmuster ab (häufigste Fach/Klassen-Kombination je Wochentag+Uhrzeit über alle
Wochen), löst Uhrzeit → Stundennummer über `PeriodScheduleService` auf und Klassen-Token →
`LearningGroup` (exakt, dann normalisiert; bei kombinierten Klassen wird die Gruppe mit
vorhandenem `TimetableSlot` bevorzugt). Lehrkraft-Kürzel wird nicht hartkodiert, sondern als
häufigstes letztes Wort in `DESCRIPTION` erkannt. Liefert zusätzlich die Gegenrichtung:
vorhandene `TimetableSlot`s ohne passendes iCal-Muster.
- **`LehrerApp.Core/Services/UntisDiffService.cs`** (Stufe 2): vergleicht einen neuen Fetch gegen
den letzten lokalen Schnappschuss je iCal-UID — abweichendes Fach/Klasse → `SubstitutionEntry`
(`Kind=Lesson`), `STATUS:CANCELLED` oder ein im Lookahead-Fenster (14 Tage) verschwundener
Termin → `Kind=Cancelled`. Nur Termine mit einer **bestätigten** `UntisSlotMapping` erzeugen
automatisch Einträge — unbestätigte Muster fließen nur in die Abweichungs-Zählung ein.
- **Neue Modelle** (`LehrerApp.Core/Models/UntisSync.cs`): `UntisSnapshotEntry` (lokale
Sicherungskopie je Termin, für den Abgleich) und `UntisSlotMapping` (vom Nutzer bestätigte
Zuordnung Wochenmuster → Gruppe, inkl. aufgelöster Stundennummer). `SubstitutionEntry` um
`ExternalId` (iCal-UID) erweitert — macht wiederholte Abgleich-Läufe idempotent (Update statt
Duplikat, neue `ISubstitutionEntryRepository.GetByExternalId`). Beide neuen Repositories feuern
bewusst **keinen** `db.OnChange` (siehe Kommentar in `AllRepositories.cs`) — der Schnappschuss
ist reines lokales Abgleich-Zwischenmaterial, die Zuordnung hängt an der pro Gerät hinterlegten
WebUntis-URL, beides ergibt über Sync keinen Mehrwert.
- **`WebUntisSettingsService`** (`LehrerApp.Desktop/Services/`): exaktes Abbild von
`AiSettingsService`/`SyncSettingsService` — die iCal-URL trägt ein eingebettetes Auth-Token und
wird deshalb wie ein Passwort behandelt (AES-256-GCM über `SyncCrypto`, eigener
dateirechte-geschützter Schlüssel, nie Klartext in der JSON-Konfigurationsdatei).
- **`UntisSyncService`** (`LehrerApp.Desktop/Services/`): Timer/Gate/Dispose-Muster wie
`SyncEngine` (60-Minuten-Intervall, `SemaphoreSlim(1,1)` mit `WaitAsync(0)` statt Warteschlange),
nur registriert, wenn URL hinterlegt und aktiviert (`AppBootstrapper.cs`, gleiches
bedingte-Registrierung-Muster wie beim Sync-Server). Der reine Verarbeitungskern
(`ProcessIcsText`) ist ohne HTTP-Zugriff gehalten und direkt mit vorgefertigtem ICS-Text
testbar (public statt internal, da diese Codebasis kein `InternalsVisibleTo` nutzt).
- **Settings-Tab "Stundenplan-Abgleich"**: iCal-URL-Eingabe (maskiert), Aktivieren, "Jetzt
abrufen", Status letzter Abgleich, "Zuordnung prüfen…" öffnet `UntisMappingReviewDialog` — Liste
der erkannten Wochenmuster mit vorgeschlagener Gruppe (vorbefüllt bei sicherem Match), die der
Nutzer bestätigt oder ändert, bevor automatische Vertretungen dafür geschrieben werden (bewusste
Design-Entscheidung: die erstmalige Zuordnung ist fehleranfällig, laufende Tages-Änderungen
danach nicht mehr — sie landen im ohnehin jederzeit von Hand korrigierbaren
`SubstitutionEntry`-Mechanismus).
- **Abweichungs-Banner** im Stundenplan (`TimetableViewModel.HasUntisMismatch`): vergleicht nur
den bereits lokal bestätigten Zuordnungsstand gegen die aktuellen `TimetableSlot`s (kein
erneuter iCal-Abruf beim Seitenaufruf) — bleibt komplett verborgen, solange der Abgleich nicht
aktiviert ist.
- Wie beim Sync-Server deckt ein `AppBootstrapper.RestartApplication()` das Registrieren von
`UntisSyncService` ab (nur einmalig beim Start bedingt registriert, kein
Live-Re-Registrierungspfad).
**Nachtrag zu 4.3, zehnte Iteration (Bugfix Zuordnungs-Gedächtnis + Aufsichten):**
Nutzer-Feedback nach erstem echtem Ausprobieren: *"Kann es sein, dass er meine Verbesserungen gar
nicht einspeichert"* — Ursache war, dass `UntisMappingReviewDialogViewModel.LoadAsync` beim
erneuten Öffnen die Zeilen immer frisch aus der Mustererkennung aufgebaut hat, ohne zuvor
bestätigte `UntisSlotMapping`s zu berücksichtigen — eine manuelle Korrektur war zwar tatsächlich
gespeichert, wurde beim nächsten Öffnen aber von der reinen Algorithmus-Vermutung überschrieben
angezeigt. Zusätzlich legte jedes Speichern für denselben Slot ein **neues** `UntisSlotMapping`
mit neuer Id an, statt das vorhandene zu aktualisieren — bei zwei Mappings für denselben
(Weekday,StartTime)-Schlüssel hätte `UntisDiffService.Diff` beim nächsten Poll mit einer
`ArgumentException` abgebrochen (unbehandelt im Timer-Callback, hätte den gesamten Prozess
beendet).
- `UntisMappingReviewDialogViewModel` bekommt `IUntisSlotMappingRepository` injiziert, lädt
bestehende Zuordnungen vor dem Aufbau der Zeilen und übergibt sie an `UntisMappingRow`, das die
vorherige Bestätigung bevorzugt vor dem reinen Algorithmus-Vorschlag vorbefüllt.
- `UntisMappingRow.BuildMapping()` (neu) trägt beim erneuten Bestätigen desselben Slots die
vorhandene Id weiter, statt immer `Guid.NewGuid()` zu vergeben — Speichern ist jetzt ein
echtes Update, kein Duplikat.
- `UntisDiffService` baut die Mapping-Lookup-Tabelle zusätzlich defensiv per `GroupBy` statt
direktem `ToDictionary` (das zuletzt angelegte Mapping gewinnt), falls doch einmal mehrere
Zeilen für denselben Slot existieren — kein Crash mehr, nur ein stillschweigend ignoriertes
Altmapping. `UntisSyncService.PollAsync` fängt außerdem jede Ausnahme aus der Verarbeitung
selbst ab (vorher nur der HTTP-Abruf) und trägt sie in den Sync-Status ein, statt den
Timer-Callback unbehandelt abstürzen zu lassen.
Zweites Feedback: *"Zwei Termine sind meine Aufsichten, die nicht zugeordnet werden können. Vom
Zeitraster und von der Dauer her, könnten die erfasst werden, oder es muss noch diese Option
geben."* — Termine ohne Klassenbezug (Aufsicht/Springstunde, DESCRIPTION nur Lehrkraft-Kürzel)
liegen typischerweise in einer Pause zwischen zwei Unterrichtsstunden, nicht auf einer
konfigurierten Stunden-Startzeit — `UntisMatchingService.ResolvePeriodNumber` (exakte oder nur
minutengenau tolerante Übereinstimmung) konnte sie deshalb grundsätzlich nie auflösen, und der
Review-Dialog bot dafür auch keine passende Bestätigungsoption (nur eine Lerngruppen-Auswahl).
- `UntisSlotMapping` um `Kind` (`SubstitutionKind`, Standard `Lesson`) und `AfterPeriod` erweitert;
`GroupId`/`PeriodNumber` sind jetzt nullable (nur bei `Kind=Lesson` gesetzt).
- `UntisMatchingService.BuildMatches`: Muster ohne Klassenanteil bekommen statt einer
Stundennummer ein `AfterPeriod` — die Pause direkt vor der Startzeit (letzte Stunde, deren Ende
≤ Startzeit liegt; 0 vor der ersten Stunde) — anders als bei Unterrichtsstunden **immer**
auflösbar, da eine Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer
Startzeit.
- `UntisMappingReviewDialog`: Zeilen ohne Klassenbezug zeigen statt der Lerngruppen-ComboBox eine
Checkbox "Als Aufsicht bestätigen".
- `UntisDiffService`: Kind-abhängige Behandlung — bei `Supervision` erzeugt ein verschwundener
oder als `STATUS:CANCELLED` markierter Termin einen `SubstitutionEntry { Kind = Supervision,
AfterPeriod = ... }` statt `Cancelled` (das bleibt Unterrichtsstunden vorbehalten, da die
Anzeige dafür Gruppe/Fach aus einem `TimetableSlot` herleitet, den es für Aufsichten nicht
gibt). Feingranulare "noch da, aber Ort geändert"-Erkennung für Aufsichten bewusst nicht
gebaut (kein zusätzliches Location-Feld auf dem Mapping) — reicht für den gemeldeten Fall
(Aufsicht verschwindet/wird übernommen) und hält den Umfang klein.
**Nachtrag zu 4.3, elfte Iteration (Bugfix Doppelstunden):** Nutzer-Feedback nach dem
Zuordnen aller Muster: *"Ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
Warnung, dass 16 Stunden ohne Untis-Zuordnung sind."* Ursache: WebUntis fasst eine Doppelstunde
(zwei aufeinanderfolgende Stunden desselben Fachs/derselben Gruppe) zu einem EINZIGEN VEVENT über
beide Stundenzeiten hinweg zusammen (z.B. 07:5009:20 für Stunde 1+2), während der Stundenplan der
App dafür zwei separate `TimetableSlot`-Einträge haben kann. `UntisMatchingService` löste bisher
nur eine einzelne Stundennummer aus der Startzeit auf — die zweite Stunde jeder Doppelstunde
konnte dadurch nie als zugeordnet gelten, unabhängig davon, was im Review-Dialog bestätigt wurde
(sie tauchte dort als eigene Zeile gar nicht erst auf).
- `UntisSlotMatch`/`UntisSlotMapping` um `CoveredPeriods` (`List<int>`) erweitert — alle Stunden,
die ein WebUntis-Termin überdeckt (via neuer `UntisMatchingService.ResolveCoveredPeriods`:
alle konfigurierten Stundenraster-Einträge, die vollständig innerhalb [Start, Ende) des Termins
liegen). `PeriodNumber` bleibt als erste/primäre Stunde erhalten (u.a. für
`UntisDiffService`, das weiterhin nur die erste Stunde einer Doppelstunde in einen
`SubstitutionEntry` schreibt — bewusste Vereinfachung, siehe unten).
- `UntisMatchingService.BuildMatches`/`TimetableViewModel.LoadUntisMismatch`: der
"zugeordnet"-Abgleich prüft jetzt gegen ALLE `CoveredPeriods` einer bestätigten Zuordnung, nicht
nur gegen die erste Stunde.
- `UntisMappingReviewDialog` zeigt Doppelstunden-Zeilen als "1.2. Stunde (07:50, Doppelstunde)"
statt nur der ersten Stunde, damit sichtbar ist, dass eine Bestätigung beide Stunden abdeckt.
- Bewusst nicht angegangen: `UntisDiffService` schreibt bei einer geänderten/ausgefallenen
Doppelstunde weiterhin nur einen `SubstitutionEntry` für die erste Stunde (ein Eintrag kann nur
eine `PeriodNumber` tragen) — für den gemeldeten Fall (Zuordnungs-Zählung im Stundenplan-Banner)
nicht relevant, bleibt als bekannte Einschränkung dokumentiert statt den Umfang zu sprengen.
**Nachtrag zu 4.3, zwölfte Iteration (verifiziert: einmalige Vertretungsaufsicht meldet sich nur
einmal ab):** Nutzer-Sorge, nachdem eine einmalige Vertretungsaufsicht für eine einzelne Woche im
Review-Dialog als Aufsicht bestätigt wurde: *"Die fehlt natürlich in der Woche drauf wieder. Wird
dann bis zum Ende des gültigen Stundenplans diese Stunde als entfallene Aufsicht geführt?"* — Kein
Codefehler, aber ein berechtigter Verdacht angesichts einer dauerhaft bestätigten
`UntisSlotMapping`; per neuem Regressionstest
(`UntisSyncServiceTests.ProcessIcsText_EinmaligeVertretungsaufsicht_MeldetEntfallenNurEinmal`)
verifiziert, dass die "entfallen"-Erkennung in `UntisDiffService` an die konkrete, tatsächlich im
Snapshot gesehene Zeile EINES Datums gekoppelt ist, nicht an eine dauerhaft erwartete
wöchentliche Wiederholung der Zuordnung: sobald eine verschwundene Zeile einmal gemeldet wurde,
wird ihre Snapshot-Zeile gelöscht (`SnapshotIdsToDelete`) — für künftige Wochen entsteht ohne
einen neuen, tatsächlich von WebUntis gemeldeten Termin an diesem Slot gar keine neue
Snapshot-Zeile mehr, die erneut "verschwinden" könnte. Einzige bekannte Randnotiz: die bestätigte
`UntisSlotMapping` selbst bleibt als (harmloser) verwaister Datensatz in der Datenbank stehen, da
ihr Muster nach dem einmaligen Vorkommen bei einem erneuten Abruf nicht mehr auftaucht und der
Review-Dialog dafür deshalb auch keine Zeile zum Entfernen mehr anbietet — bislang nicht als
eigenständiges Problem gemeldet, deshalb kein eigener Aufräum-Mechanismus gebaut.
**Nachtrag zu 4.3, dreizehnte Iteration (Sichtbarkeit im Wochenraster + zusätzliche Aufsichten +
Fach in der Gruppenauswahl):** Nutzer-Feedback: *"Wäre es doch auch schön, wenn das im
Stundenplan für die nächste Woche irgendwie erkenntlich ist [...] Auch die Extra-Aufsicht ist
dann nicht im Plan. So macht doch der Sync nur so halb Sinn."*
- **Sichtbarkeit verifiziert, kein Code nötig:** Das Wochenraster (`TimetableViewModel.
BuildWeekOverview`) liest `SubstitutionEntry` bereits für die jeweils angezeigte Woche
(`WeekOffset`), unabhängig davon, ob der Eintrag von Hand oder automatisch über den
WebUntis-Abgleich entstanden ist — beides landet in derselben Tabelle. Ein Ausfall ersetzt die
reguläre Kachel vollständig (Aufschrift "Ausfall" + `Description` direkt sichtbar als Text,
keine Extra-Hover-Lösung nötig, siehe `WeekCellItem.ForCancelled`/`TimetableView.axaml`).
Per neuem Test (`Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation`,
`WeekOffset` über `NextWeekCommand` statt direkter Zuweisung, da nur die Befehle `Load()` erneut
auslösen) erstmals mit `WeekOffset != 0` bestätigt — vorher gab es dafür keinen Test.
- **Echte Lücke gefunden und behoben:** `UntisDiffService` erzeugte für eine bestätigte
Aufsichts-Zuordnung bisher nur bei `STATUS:CANCELLED` oder Verschwinden einen Eintrag — das
reguläre Vorkommen selbst (die Vertretungsaufsicht in der Woche, in der sie tatsächlich
stattfindet) blieb unsichtbar. Neue Logik: `UntisDiffService.Diff` bekommt optional
`existingSupervisionDuties` (`ISupervisionDutyRepository`, über `UntisSyncService`
durchgereicht) — eine bestätigte Supervision-Zuordnung OHNE passende reguläre `SupervisionDuty`
an Wochentag+Pause gilt selbst schon als meldenswerte (zusätzliche) Vertretung und erzeugt bei
jedem tatsächlichen Vorkommen sofort einen `SubstitutionEntry` (idempotent über die iCal-UID
als `ExternalId`). Mit passender regulärer Duty bleibt es wie zuvor bei reiner
Abweichungs-Erkennung (Cancelled/verschwunden).
- **Fach in der Kursauswahl:** Nutzer-Feedback: *"Meine Klasse habe ich 3-mal. Ohne das Fach
dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen."* Gleiche Begründung wie
bei `TimetableSlotDialogViewModel` ("10c (Chemie)" bei Namenskonflikt) — hier über eine neue
kleine Anzeige-Hülle `UntisGroupOption { LearningGroup Group; string DisplayLabel; }` gelöst
(bewusst immer mit Fach statt nur bei erkanntem Konflikt, da dieser Dialog direkt an
`LearningGroup`-Objekte statt an Label-Strings bindet — einfacher als eine
Konfliktbevorzugungs-Logik nachzubauen). `UntisMappingReviewDialogViewModel` bekommt dafür
`ISubjectRepository` injiziert.
**Nachtrag zu 4.3, vierzehnte Iteration (Bugfix: von Anfang an fehlende Stunden):** Nutzer-
Feedback: *"Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht im Plan
[...] die Klasse ist dort weg, also der Unterricht wird definitiv nicht stattfinden."* Ursache:
das gesamte Diffing war bis dahin rein **reaktiv** — es erkannte nur Termine, die zwischen zwei
Abrufen aus dem Feed **verschwanden** (vorher gesehen, jetzt weg). Ein Ausfall, den WebUntis von
Anfang an nie als Termin gelistet hatte (weil er schon beim allerersten Abruf der App feststand),
hinterließ nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können — für das
Schnappschuss-Diffing sah es aus, als hätte es diese Stunde nie gegeben, es gab also nichts zu
erkennen.
- `UntisDiffService.Diff` bekommt eine zweite, **aktive** Prüfung für bestätigte
`Lesson`-Zuordnungen: für jedes vom Feed bereits abgedeckte künftige Datum des Zuordnungs-
Wochentags (bis zum jüngsten im aktuellen Fetch gesehenen Datum, `newEvents.Max(Date)` —
begrenzt auf das, was WebUntis tatsächlich schon veröffentlicht hat, damit nicht Tage jenseits
des Feed-Horizonts fälschlich als Ausfall gelten) wird geprüft, ob ein passender Termin
existiert; fehlt er, wird unabhängig vom Schnappschuss-Verlauf ein `SubstitutionEntry` erzeugt
(idempotent über einen aus Zuordnung+Datum abgeleiteten Schlüssel statt einer iCal-UID, die es
für eine fehlende Stunde naturgemäß nie gab).
- Die bisherige rein reaktive Schnappschuss-Erkennung für `Lesson`-Zuordnungen entfällt dadurch
(sie ist jetzt ein Sonderfall der aktiven Prüfung) — für Aufsichten (`Supervision`) bleibt sie
unverändert bestehen, da dort kein fester wöchentlicher Anspruch existiert (siehe Nachtrag zur
dreizehnten Iteration).
- Neuer Parameter `freeDates` (Ferien/Feiertage) verhindert, dass die aktive Prüfung während
Schulferien fälschlich Ausfälle meldet — `UntisSyncService` berechnet ihn aus
`ISchoolHolidayRepository`/`PublicHolidayService`/`SchoolCalendarSettingsService`, dieselbe
Logik wie `TimetableViewModel.IsFreeDay`, hier bewusst separat gehalten statt geteilt, da
`UntisDiffService` (LehrerApp.Core) absichtlich frei von Desktop-ViewModel-Abhängigkeiten
bleibt.
- Der am 26.08. vom Nutzer vermutete Sonderfall (eigener Unterrichtsausfall, möglicherweise durch
einen manuell eingetragenen Sondereinsatz überschrieben) wurde nicht weiter untersucht — vom
Nutzer selbst als plausible, nicht fehlerhafte Erklärung eingeordnet.
**Nachtrag zu 4.3, fünfzehnte Iteration (Bugfix: manuelle Zuordnung bei kombinierten Kursen
wirkungslos):** Nutzer-Feedback: *"Der Mathematik E-Kurs Dienstag 3.&4. Stunde ist ein Kurs aus
den Klassen 10a, 10b und 10c. Die Logik möchte ihn immer meiner Klasse 10c alleine geben. Ich
habe das aufgelöst und den Mathematik E-Kurs ausgewählt. Diese manuelle Verknüpfung ist aber
jetzt scheinbar vergessen und es taucht jedes Mal die Vertretung auf."* Ursache: beim Bestätigen
wird `UntisSlotMapping.ClassToken` kompakt ohne Leerzeichen gespeichert (`"10a;10b;10c"`),
WebUntis trennt die Klassen in `DESCRIPTION` aber mit `"; "` (Semikolon + Leerzeichen, z.B.
`"10a; 10b; 10c; Gastro HED"`, siehe echtes Beispiel im Planungsdokument). Der Abweichungs-
Vergleich in `UntisDiffService.HasDeviated` war ein reiner Teilstring-Vergleich
(`evt.Description.Contains(mapping.ClassToken)`) — der schlug dadurch für **jede** kombinierte/
differenzierte Gruppe (mehr als ein Klassen-Token) strukturell fehl, unabhängig davon, ob die
manuell gewählte Gruppe stimmte: jede einzelne Bestätigung eines Kurses mit mehreren Klassen
wurde bei jedem Poll erneut als "abweichend" gemeldet.
- Fix: `HasDeviated` entfernt vor dem Teilstring-Vergleich alle Leerzeichen aus der rohen
`DESCRIPTION` (gleiches Prinzip wie `UntisMatchingService.Normalize` beim
Gruppennamen-Abgleich) — formatunabhängig, verlässt sich nicht auf eine bestimmte
WebUntis-Trennzeichen-Konvention.
- Betraf ausschließlich kombinierte Gruppen (mehr als ein Klassen-Token) — einzelne Klassen
(`"10c"` in `"10c HED"`) waren nie betroffen, da dort kein Trennzeichen im Spiel ist; deshalb
ist der Fehler dem Nutzer erst bei diesem speziellen Kurs aufgefallen.
### 4.4 Wochen-/Tagesansicht
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.