Files
LehrerApp/TODO.md
T
adminandClaude Sonnet 5 a088823b30
CI / build-and-test (push) Canceled after 0s
Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit
Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den
betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und
Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen
jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:24:16 +02:00

3855 lines
310 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# LehrerApp — Entwicklungs-Roadmap
Strukturierte ToDo-Liste aller offenen Entwicklungsschritte.
Jeder Punkt ist so formuliert, dass er einzeln an einen Agenten übergeben werden kann.
**Stand:** 2026-08-18
**Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig
---
## 0. Konventionen für alle Aufgaben
Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
- **MVVM:** Logik in ViewModels (`CommunityToolkit.Mvvm`, `[ObservableProperty]` / `[RelayCommand]`),
Views enthalten nur Binding. Kein Code-Behind außer Dialog-Verdrahtung.
- **DI:** Neue Repositories/Services/ViewModels in `LehrerApp.Desktop/AppBootstrapper.cs` registrieren.
Repositories + Listen-VMs = Singleton, Detail-/Dialog-VMs = Transient.
- **Daten:** Neue Collections in `LehrerApp.Data/LiteDbContext.cs` anlegen, Indexe in `EnsureIndexes()`.
Interface nach `LehrerApp.Core/Interfaces/IRepositories.cs`, Implementierung nach
`LehrerApp.Data/Repositories/AllRepositories.cs`.
- **Dialoge:** `Window` mit `Close(true/false)`, DataContext manuell gesetzt (Muster siehe
[AddGroupDialog.axaml.cs](LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml.cs)).
- **Sprache:** Alle UI-Texte auf Deutsch. Datumsformat `dd.MM.yyyy`, Kultur `de-DE`.
- **Modelle:** `UpdatedAt` bei jedem Speichern setzen (Grundlage für Sync-Konfliktauflösung).
---
## 1. Klausuren & Leistungsüberprüfungen
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
**Niveaudifferenzierung (E/G/Förder) ist bereits umgesetzt** — nicht aus dieser Liste, sondern
ein separater Bedarf (Nachteilsausgleich/Binnendifferenzierung): `LearningGroup.IsDifferentiated`
(Checkbox in den Stammdaten) blendet eine Niveau-Zuordnung je Schüler ein (`GroupMembership.Niveau`,
Schüler-Tab). Klausuren bekommen optional ein `Niveau`; Punkteeingabe und Auswertung filtern dann
automatisch auf die passenden Schüler. Workflow: G-Klausur normal anlegen, per "Duplizieren"
(1.1.4) die E-Variante mit eigenen Aufgaben/Punkten ableiten (Niveau wird beim Duplizieren bewusst
zurückgesetzt). Vollständig individuelle Förderklausuren (nur für einen einzelnen Schüler) laufen
pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene Schüler-Zuordnung.
### 1.1 Klausur anlegen und verwalten
- [x] **1.1.1** Dialog `AddExamDialog` — Titel, Datum, Fach, Klausurnummer, Notizen.
Ersetzt den TODO-Stub `AddExam()` in [GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs).
Umgesetzt als `ExamDialog`/`ExamDialogViewModel`.
- [x] **1.1.2** Klausur bearbeiten/löschen (Kontextmenü im Klausuren-Tab, Löschen mit Rückfrage).
- [x] **1.1.3** Statuswechsel `Planned → Conducted → Graded → Returned` per Button,
inkl. Farbcodierung des Status im DataGrid.
- [x] **1.1.4** Klausur aus bestehender Klausur duplizieren (Aufgaben + Notenschlüssel übernehmen,
neues Datum) — für Parallelkurse.
### 1.2 Aufgabenstruktur (`ExamTask`)
- [x] **1.2.1** Editor für die Aufgabenliste: Nr., Titel, Maximalpunkte, Gewichtung.
Zeilen hinzufügen/entfernen/umsortieren. Umgesetzt im `ExamDialog` (Teil von 1.1.1).
- [x] **1.2.2** Automatische Anzeige der Gesamtpunktzahl, Warnung bei 0 Punkten.
- [ ] **1.2.3** Optional: Teilaufgaben (a/b/c) — erfordert Modellerweiterung, vorher entscheiden.
- [x] **1.2.4** Zuordnung von Kompetenzen (`CompetencyItem`) zu einzelnen Aufgaben —
Voraussetzung für die Kompetenzauswertung in 8.3.
### 1.3 Notenschlüssel
- [x] **1.3.1** Editor für `GradingKey` mit Vorbelegung aus
`GradingService.DefaultKey1To6()` / `DefaultKey0To15()` je nach `GradingSystem` der Gruppe.
Umgesetzt im `ExamDialog` (Teil von 1.1.1), analog zum Aufgaben-Editor.
- [x] **1.3.2** Notenschlüssel als wiederverwendbare Vorlage speichern (neues Modell
`GradingKeyTemplate` + Repository) und in Einstellungen verwalten.
- [x] **1.3.3** Live-Vorschau: Punktegrenzen absolut anzeigen (z.B. "Note 2 ab 45 von 60 P.").
- [x] **1.3.4** Validierung: lückenlose, absteigende Prozentgrenzen, keine Dopplungen
(`GradingService.ValidateGradingKey`).
### 1.4 Punkteeingabe & Korrektur
- [x] **1.4.1** Eingaberaster: Zeilen = Schüler, Spalten = Aufgaben, letzte Spalte Summe + Note.
Note wird live über `GradingService.CalculateGrade()` berechnet.
Umgesetzt als eigener Dialog `ExamGradingDialog`, erreichbar über "Punkte eingeben"
im Klausuren-Tab (Button/Kontextmenü bei ausgewählter Klausur).
- [x] **1.4.2** Tastaturnavigation unterstützt zwei Korrektur-Workflows (Nutzer-Feedback:
"Arbeit für Arbeit" bei kleinen/leichten Klausuren z.B. Jahrgang 5, sonst eher "Aufgabe für
Aufgabe") ohne Moduswechsel — beide Reihenfolgen bilden sich auf dieselbe Tabelle ab.
Tab bewegt sich nativ zeilenweise zur nächsten Zelle. Enter läuft ebenfalls zeilenweise
(nächste Aufgabe desselben Schülers, am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile)
— für "Arbeit für Arbeit". Pfeil-Hoch/Pfeil-Runter laufen spaltenweise (gleiche Aufgabe,
nächster/vorheriger Schüler, am Spaltenende weiter zur Nachbaraufgabe, `ScrollIntoView`
scrollt dabei automatisch auch horizontal mit) — für "Aufgabe für Aufgabe". Beides bricht
am Tabellenende einfach ab, kein Rundlauf zurück zum Anfang. Direkte Zifferneingabe und
halbe Punkte (Komma oder Punkt) weiterhin erlaubt.
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).
- [x] **1.4.5** Validierung: Punkte < 0 oder > Maximalpunkte der Aufgabe wird rot markiert und nicht
gespeichert (`PointsCell.TrySetValue`).
- [x] **1.4.6** Autosave nach jeder Zelle (kein expliziter Speichern-Button nötig).
### 1.5 Klausurauswertung
- [x] **1.5.1** Notenspiegel (Häufigkeitsverteilung als Balken), Durchschnitt, Median,
Anteil unter 4 / unter 5 Punkten. Umgesetzt als neuer Dialog `ExamEvaluationDialog`,
erreichbar über "Auswertung" im Klausuren-Tab. Bei Notensystem 16 zeigt der Schwellenwert
sinngemäß "Anteil nicht ausreichend (Note 5/6)" statt der Punktegrenzen. Abwesende werden
aus allen Statistiken ausgeschlossen (löst damit auch den Hinweis aus 1.4.3 ein).
- [x] **1.5.2** Aufgabenanalyse: durchschnittlicher Erfüllungsgrad pro Aufgabe in Prozent,
Kennzeichnung auffällig schwacher Aufgaben (Ø < 50 %, rot markiert).
- [x] **1.5.3** Notenschlüssel nachträglich verschieben und Auswirkung sofort im Notenspiegel sehen —
Änderungen wirken sich live aus, erst "Übernehmen" schreibt sie in die Klausur zurück.
- [x] **1.5.4** Export der Auswertung — CSV-Export direkt im Dialog; inzwischen auf die gemeinsame
Export-Infrastruktur aus 11.1 und den zentralen `CsvBuilder` umgestellt.
### 1.6 Klausuren-Hauptseite (gruppenübergreifend)
Der Sidebar-Punkt "Klausuren" (`NavItem.Exams`) war bisher ein `PlaceholderViewModel` ohne
Funktion — die vollständige Verwaltung existierte nur innerhalb einer Gruppe (1.11.5). Nutzer
unterrichten oft mehrere Kurse gleichzeitig und brauchen einen gruppenübergreifenden Überblick.
- [x] **1.6.1** Neue Seite `ExamsOverviewViewModel`/`ExamsOverviewView` ersetzt den Placeholder.
Detailbereich zur ausgewählten Klausur oben (ca. 5560 % Höhe), darunter eine scrollbare
Liste aller Klausuren des aktuellen Schuljahres — bewusst kein separates Menü, sondern
Bestandteil derselben Seite (Nutzerwunsch: "keine eigene Hauptseite in dem Ausmaß").
- [x] **1.6.2** Sortierung nach unsichtbarem Prioritäts-Score statt nach Datum — neuer Service
`ExamPriorityService` (`LehrerApp.Core/Services/ExamPriorityService.cs`) leitet Status
(`ExamListStatus`: Planned/AwaitingCorrection/CorrectionInProgress/CorrectionStuck/
AwaitingReturn/Returned) und Score aus dem vorhandenen `Exam.Status` plus dem
Korrekturfortschritt ab — kein neuer, manuell zu pflegender "Korrektur läuft"-Status.
Der Korrekturfortschritt selbst kommt aus `ExamCorrectionCounter` (ebenda), das auch
`DashboardViewModel.LoadOpenCorrections` jetzt nutzt (vorher zwei Kopien derselben Zählung).
Farbcodierung wiederverwendet die validierten `AppStatus{Ok,Info,Warning,Danger}Brush`
aus `Styles/SemanticBrushes.axaml` (Klassenlehrer-Bereich) statt neuer Hex-Werte.
- [x] **1.6.3** Nutzer-Feedback zum "hängt fest"-Fall: ein einzelner Schüler, der nie
nachschreibt, blockierte sonst dauerhaft die live abgeleitete Korrektur-Erkennung, weil
niemand eine leere Zeile anfasst, für die es nichts einzutragen gibt. Zwei Bausteine statt
eines manuellen Override: (a) neuer Button "Rest als abwesend markieren" im
`ExamGradingDialog` setzt bei allen noch unberührten Zeilen "Abwesend" in einem Klick;
(b) bleibt eine Klausur trotzdem länger als `ExamPriorityService.StuckAfterDays` (21 Tage)
in Bearbeitung hängen, fällt sie aus der dringenden Zone in eine ruhige
`CorrectionStuck`-Einstufung statt dauerhaft oben zu kleben.
- [x] **1.6.4** Parallelkurse (gleiche Arbeit, mehrere Kurse — z.B. zwei Chemie-Kurse im selben
Jahrgang): neues optionales Feld `Exam.SharedExamGroupId`, beim Duplizieren (1.1.4) auf die
Id der Ursprungsklausur gesetzt. Die Klausuren-Hauptseite zeigt dafür einen Kurs-Umschalter
im Detailbereich; Zeilen mit Verknüpfung tragen ein 🔗-Symbol in der Liste.
- [x] **1.6.5** Zwei neue nullable Datumsfelder `Exam.ApprovalGrantedAt`/`AnnouncedAt`
("Genehmigung erteilt"/"Ankündigung bei Schülern gemacht", Label verhandelbar) — bewusst
keine weiteren Workflow-Statusstufen, weil sie zeitlich oft parallel statt sequenziell zum
Hauptstatus laufen. Als anklickbare Zeilen im Detailbereich, die das Datum auf heute setzen
bzw. wieder löschen.
- [x] **1.6.6** Detailbereich zeigt je nach Status Korrekturfortschritt (Balken + Zahl) oder
Notenspiegel (Balken je Note + Durchschnitt, gleiche Berechnung wie 1.5.1 über
`GradingService`/`GradeBarItem`) — kein Kompetenz-Breakdown in dieser Iteration. Button
"Zum Kurs" springt in den Klausuren-Tab der jeweiligen Gruppe (dort wie gehabt Zugriff auf
Kompetenzen-Tab, 8.2/8.3).
- [x] **1.6.7** Persönliche Klausurlast pro Woche (Nutzer-Feedback). Bewusst **keine**
klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
Woche) — das übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) mit
eigenem Alarm. Was dort fehlt: die eigene Belastung über alle Kurse hinweg, unabhängig von
der Klasse — mehr Klausuren in derselben Woche bedeuten unabhängig davon mehr
Erstellungsaufwand vorher und mehr Korrekturaufwand danach. Neuer
`ExamWeekLoadService` (`LehrerApp.Core/Services/ExamWeekLoadService.cs`) zählt eigene
Klausuren je ISO-Kalenderwoche; da eine LiteDB-Datei genau einem Nutzer gehört, sind "alle
Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren". Ab drei
Klausuren in derselben Woche gilt sie als spürbar belastet (zwei aus zwei unterrichteten
Fächern sind normal, eine dritte die eigentliche Häufung). Zwei Einstiege: (a) im
`ExamDialog` direkt beim Ändern des Datums ein Hinweis, wenn die Klausur die Woche über
den Schwellenwert heben würde — greift am Entscheidungspunkt, bevor überhaupt gespeichert
wird; (b) neue Dashboard-Karte "Klausurwochen" (9), die bevorstehende Wochen (60 Tage
Vorausschau) mit auffälliger Häufung unter den noch **geplanten** Klausuren auflistet —
bereits durchgeführte/korrigierte Klausuren deckt schon die Karte "Offene Korrekturen" ab.
---
## 2. Noten & Zeugnisnoten
Modell `Grade` existiert in [Planning.cs](LehrerApp.Core/Models/Planning.cs), `GradeRepository` ebenfalls.
Der Tab "Noten" in [GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml) ist jetzt
vollständig umgesetzt (siehe unten), ebenso der gleichnamige Tab im Schülerdetail (2.5).
### 2.1 Notenübersicht der Gruppe
- [x] **2.1.1** Matrix: Zeilen = Schüler, Spalten = alle Leistungen (Klausuren, Mitarbeit je Halbjahr,
sonstige Noten). Zelle zeigt Note/Punkte.
- [x] **2.1.2** Spalte "Gesamt" mit gewichtetem Durchschnitt über `GradingService.WeightedAverage()`.
- [x] **2.1.3** Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte.
- [x] **2.1.4** Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt `GroupMembership.Period`.
### 2.2 Einzelnoten pflegen
- [x] **2.2.1** Dialog "Note hinzufügen": Kategorie (`GradeCategory`), Wert, Datum, Gewichtung, Notiz.
- [x] **2.2.2** Note bearbeiten / löschen mit Historie (wer/wann geändert) — mindestens `CreatedAt` sichtbar.
- [x] **2.2.3** Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal.
### 2.3 Gewichtungsschema
- [x] **2.3.1** Neues Modell `GradingScheme` je Gruppe: prozentuale Anteile von
Klausuren / Mitarbeit / sonstige Leistungen (z.B. 50/40/10).
- [x] **2.3.2** UI zur Bearbeitung, Validierung auf Summe 100 %.
- [x] **2.3.3** Voreinstellung je Gruppentyp (`Class` vs. `Course`) in den Einstellungen.
### 2.4 Zeugnisnote
- [x] **2.4.1** Berechnung der Zeugnisnote aus Schema (2.3) + allen Teilnoten,
Rundungsregel konfigurierbar (kaufmännisch / pädagogisch abweichbar).
- [x] **2.4.2** Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
- [x] **2.4.3** Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
- [x] **2.4.4** Export der Zeugnisnotenliste (siehe 11.2).
- [x] **2.4.5** Fehlquote je Schüler im Zeugnisnoten-Dialog anzeigen (Nutzer-Feedback: "ab 50 %
darf eine 5 gegeben werden"). `ReportGradeDialogViewModel` berechnet je Schüler über
`AttendanceBalanceService.Calculate` die Fehlquote aus den `ParticipationSession`s dieser
Gruppe im gewählten Zeitraum (Halbjahr/Schuljahr) — bewusst nur diese Gruppe, anders als
`StudentDetailViewModel.LoadAttendanceBalance`, das gruppenübergreifend über den ganzen
Schüler rechnet. Anzeige als kleine Zeile unter dem Schülernamen
(`ReportGradeRow.AbsenceRateDisplay`), ab `HighAbsenceThresholdPercent = 50` rot
hervorgehoben (`AbsenceRateColorHex`) mit Tooltip-Hinweis auf die Regel — reine Information,
kein automatisches Übersteuern der berechneten Note.
### 2.5 Notenentwicklung
- [x] **2.5.1** Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail).
- [x] **2.5.2** Auffälligkeiten markieren: Abfall um ≥ 1 Note, Versetzungsgefährdung (Note 5/6).
Umgesetzt über [GradeOverviewViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GradeOverviewViewModels.cs)
(Matrix-Tab, Einzelnoten-Dialoge), [ReportGradeViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ReportGradeViewModels.cs)
(Zeugnisnoten-Dialog) und die neuen Modelle `GradingScheme`/`ReportGrade` in
[Planning.cs](LehrerApp.Core/Models/Planning.cs). Das Gewichtungsschema wird pro Gruppe gesucht, sonst
die Voreinstellung des Gruppentyps (Einstellungen → Notenschema), sonst ein Fallback 50/40/10 verwendet;
Bereiche ohne Werte werden bei der Berechnung ausgelassen und die verbleibenden Prozentanteile neu normiert
(`GradingService.CalculateReportGrade()`). Notenentwicklung im Schülerdetail zeigt ein einfaches
Balken-Sparkline je Lerngruppe über alle Klausur- und Einzelnoten-Einträge chronologisch — **jeder
Balken ist ein einzelner `Grade`- bzw. Klausurergebnis-Eintrag, keine Mitarbeit-"Sitzung"**. Die
Herkunft (Kategorie wie "Mündlich"/"Mitarbeit" oder Klausurtitel) stand ursprünglich nur im
Tooltip und nicht sichtbar auf der Kachel — das führte zu Verwirrung, welche Zahl wofür steht,
und wurde ergänzt (`GradeHistoryPoint.Label` jetzt auch unter dem Balken sichtbar, nicht nur im
Tooltip), siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml).
### 2.6 Leistungsüberblick & Zielnoten-Rechner (Nutzer-Feedback)
Die Zielnoten-Frage ("was brauche ich noch für Note X?") kommt wiederkehrend auf, meist im
Zusammenhang mit einer frisch geschriebenen Klausur und Blick auf die Zeugnisnote — bisher ohne
eigenes Werkzeug, nur im Kopf überschlagen.
- [x] **2.6.1** Neuer Dialog `StudentPerformanceOverviewDialog`, erreichbar über den Button
"Überblick" in der Notenübersicht der Gruppe (2.1) bei ausgewähltem Schüler — bewusst pro
Kurs statt fächerübergreifend, da Gewichtungsschema und Notensystem am Kurs hängen. Zeigt
Klausuren (mit Datum, eigener Note), Mitarbeit- und sonstige Noten sowie die daraus mit
`GradingService.CalculateReportGrade` berechnete aktuelle Zeugnisnote, Zeitraum wählbar
(Gesamtjahr/H1/H2, gleiches Muster wie 2.4).
- [x] **2.6.2** Zielnoten-Rechner: neuer `ReportGradeTargetCalculator` (Core) kehrt die
Zeugnisnoten-Formel um — löst nach dem Durchschnitt auf, den ein gewählter Bereich
(Klausuren/Mitarbeit/Sonstige) noch erreichen muss, damit die gewichtete Gesamtnote ein
eingegebenes Ziel trifft; meldet ausdrücklich, wenn das Ziel rechnerisch nicht mehr
erreichbar ist (nötiger Durchschnitt außerhalb der gültigen Notenskala).
- [x] **2.6.3** Was-wäre-wenn-Rechner: eine hypothetische zusätzliche Klausurnote eintragen und
sofort die sich daraus ergebende Zeugnisnote sehen, ohne zu speichern — deckt den
"ich habe gerade eine Arbeit zurückbekommen, was bedeutet das"-Fall ab.
- [x] **2.6.4** Bewerter-/Schülermodus: ein Umschalt-Button in einem einzigen Fenster (kein
getrennter Einstieg) statt zweier Fenster, gedacht zum Bildschirm-Umdrehen im Gespräch.
Im Bewertermodus zusätzlich der Kursdurchschnitt je Klausur (Ausreißer nach oben/unten
erkennbar) und Bearbeitbarkeit der Mitarbeit-/Sonstige-Noten inkl. Speichern; im
Schülermodus ausschließlich die eigenen Werte, rein lesend. **Bewusst nur Mitarbeit-/
Sonstige-Noten direkt editierbar, keine Klausurnoten:** eine Korrektur dort bliebe
inkonsistent zu `ExamResult.Points`/`TotalPoints` — dafür weiterhin der bestehende
`ExamGradingDialog` im Klausuren-Tab.
**Bewusst zurückgestellt:** eine Übersicht über alle Fächer eines Schülers gleichzeitig
(Gesamt-Zeugnisvorschau) — unterschiedliche Notenschemata/Gewichtungen pro Kurs wären ein
eigener, größerer Umbau.
- [x] **2.6.5** Bugfix (Nutzer-Feedback): frei eingetragene Noten mit Tendenz ("3+", "2-") in den
editierbaren Mitarbeit-/Sonstige-Feldern (2.6.4) wurden von `GradingService.WeightedAverage`
nicht erkannt (`int.TryParse` schlägt bei einem Suffix fehl) — bestanden nur Tendenznoten in
einem Bereich, wurde er komplett auf 0 statt ausgelassen berechnet, was den Zielnoten-Rechner
und die Zeugnisnote verfälschte. Neue `GradingService.ParseGradeValue()` erkennt ein
abschließendes `+`/`-` und verschiebt um ein Drittel Richtung der besseren Note
(z.B. "3+" → 2,67, "2-" → 2,33, üblicher Drittelabstand deutscher Notensysteme) — betrifft
damit auch die "Gesamt"-Spalte der Notenübersicht (2.1.2) und `CalculateReportGrade`, die
beide auf `WeightedAverage` aufbauen.
---
## 3. Mündliche Mitarbeit — offene Punkte
Grundfunktion ist umgesetzt (Sitzungen, Raster, Schnelleingabe-Dialog).
Siehe [ParticipationViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs).
### 3.1 Aspekte konfigurieren
- [x] **3.1.1** UI zum Anlegen/Bearbeiten/Löschen von `ParticipationAspect` **pro Gruppe**
(Fach Chemie: z.B. "Experiment", "Protokoll") — `GroupId = null` bleibt global.
**Umsetzung:** neuer Button "Aspekte verwalten" im Mitarbeit-Tab öffnet
`ParticipationAspectsDialog`. Verwaltet bewusst nur die gruppenspezifischen Aspekte
(`GroupId` = diese Gruppe), nicht den globalen Standardkatalog (`GroupId = null`) — der wird
bislang nirgends befüllt (`DefaultParticipationAspects` ist reiner In-Memory-Fallback ohne
UI) und eine Änderung dort würde sofort alle Gruppen betreffen; das wäre ein eigener,
separat zu entscheidender Schritt. Label/Typ/Gewichtung/Aktiv-Status speichern sofort bei
Änderung (gleiches Muster wie die bestehende Gewichtungs-Bearbeitung in 3.2.1), mit
Rollback + Fehlermeldung an der Zeile, falls das Repository ablehnt (leerer Wert, doppelter
Schlüssel). "Schlüssel" ist bewusst nur beim Neuanlegen editierbar: er verknüpft
`AspectRating` mit dem Aspekt (per Key, nicht per Id) — ein nachträgliches Umbenennen würde
historische Bewertungen unauffindbar machen. `IParticipationAspectRepository.Save` prüft
jetzt zusätzlich Pflichtfelder und Schlüssel-Eindeutigkeit (gegen die globalen Standards UND
die eigenen Gruppen-Aspekte zusammen, da beide in der Bewertungsübersicht kombiniert
verwendet werden — siehe `ParticipationTabViewModel.LoadAspects`).
- [x] **3.1.2** Reihenfolge der Aspekte per Hoch/Runter-Buttons festlegen (bestimmt auch die
Q/W/E/R/T-Belegung im Schnelleingabe-Dialog) — gleiches Muster wie die Kompetenzbereiche in
8.1.1, `SortOrder` wird bei jeder Verschiebung sofort und lückenlos gespeichert.
**Drag & Drop nicht umgesetzt:** Hoch/Runter deckt die vollständige Bedienung bereits ab,
analog zur Drag-Entscheidung bei 4.3.3.
- [x] **3.1.3** Aspekt-Typen `Scale3`, `Binary`, `Points` in Raster und Dialog vollständig
unterstützen (bisher primär `Scale5`) — Nutzer-Nachtrag, damit das Bewertungsfeature mit
selbst angelegten Aspekt-Typen produktiv nutzbar wird.
**Umsetzung:** Neue statische Klasse `ParticipationRatingScale` (Core) ist die einzige
Quelle für Rohwert/Label-Stufen je Typ und für die Normierung zur Aggregation. Bewusste
Design-Entscheidung: `Scale3`/`Binary` bekommen **keine eigene Wertemenge**, sondern liegen
direkt auf derselben -2..+2-Achse wie `Scale5` (Scale3: -2/0/+2, Binary: -2/+2 für
Nein/Ja) — dadurch bleibt die bestehende Gewichtung/Mittelwertbildung zur Mitarbeitsnote
(3.2) unverändert kompatibel, ohne pro Bewertung erst umrechnen zu müssen. Nur `Points` ist
grundverschieden (ein echter Zählwert 0..`MaxPoints`, neues Feld auf `ParticipationAspect`)
und wird ausschließlich zum Aggregationszeitpunkt linear auf dieselbe Achse normiert
(`ParticipationRatingScale.Normalize`, 0 → -2, MaxPoints → +2) — dafür mussten die drei
Stellen, die bisher `r.Value * gewicht` direkt aufsummierten
(`ParticipationGradeDialogViewModel.Recompute`, `ParticipationWizardViewModels.WeightedRating`
und `.ComputeSuggestion`), auf die Normierung umgestellt werden; ohne diese Korrektur hätte
ein Punkte-Aspekt die Mitarbeitsnote grob verfälscht (z.B. "4 Punkte" als Rohwert weit
außerhalb der -2..+2-Skala).
Raster (`ParticipationTabView`): Zellen für `Points`-Aspekte sind jetzt ein
`NumericUpDown` (0..MaxPoints) statt der fünf Stufen-Buttons, die anderen Typen zeigen die
jeweils passende Buttonzahl (2/3/5) aus `ParticipationRatingScale.Steps`.
Schnelleingabe-Dialog: Zifferntasten und +/ sind jetzt typabhängig (Scale3: Tasten 13,
Binary: 1/2, Points: Tasten 09 als direkte Zahleneingabe bis `MaxPoints`, sonst Cycling
mit +/); die Tastenkürzel-Legende im Dialog zeigt dafür jetzt dynamisch die für den
aktuell gewählten Aspekt gültigen Tasten statt eines festen Textes.
Aspekt-Verwaltung (3.1.1) um ein "Max. Punkte"-Feld ergänzt, nur sichtbar bei Typ "Punkte".
**Bewusst nicht angefasst:** die spezialisierte Trendlinien-Visualisierung im
Mitarbeits-Assistenten (Kapitel 3.2-Erweiterung) ist weiterhin fest auf die drei
Standardaspekte (Qualität/Quantität/Arbeitsphase) zugeschnitten — eigene Trendlinien für
beliebige, selbst angelegte Aspekte wäre ein eigener, größerer Umbau dieser Ansicht.
- [x] **3.1.4** Aspekt deaktivieren statt löschen, damit alte Einträge gültig bleiben.
**Umsetzung:** Checkbox "Aktiv" je Zeile in der neuen Verwaltung (3.1.1); die bestehenden
Abfragen (`GetByGroup`/`GetDefaults`, für Bewertungsraster und -aggregation) filterten
inaktive Aspekte bereits vorher heraus, das war schon vor dieser Aufgabe so gebaut. Neu ist
nur `GetAllByGroup` (inkl. inaktiver), damit die Verwaltungsansicht deaktivierte Aspekte
weiterhin anzeigt und wieder aktivierbar macht. "Löschen" bleibt zusätzlich verfügbar (mit
Rückfrage über den bestehenden `ConfirmDialog`, Warnhinweis auf mögliche bereits erfasste
Bewertungen), aber "Deaktivieren" ist der empfohlene Weg — ein echter Verwendungs-Check vor
dem Löschen (durchsucht alle `ParticipationEntry.Ratings` nach dem Key) ist nicht umgesetzt,
da `AspectRating` nicht nach Aspekt-Key indiziert ist.
- [x] **3.1.5** Abwesende Schüler im Schnellbewerten-Dialog kenntlich machen (Nutzer-Feedback:
"es macht ja keinen Sinn Schülern eine Mitarbeitsnote zu erteilen, die gar nicht da waren").
`ParticipationStudentRow.IsAbsent` (gesetzte `Attendance`, aber nicht `Present`) treibt
`QuickInputViewModel.CurrentStudentIsAbsent`/`CurrentStudentContentOpacity`: die
Aspektliste wird gedimmt, der Schülername zeigt stattdessen den konkreten
Anwesenheitsstatus ("⚠ Abwesend — Krank, unentschuldigt" o.ä.) in Orange. Bewusst kein
Blockieren der Eingabe (manche Bewertungssysteme erwarten trotzdem einen expliziten
Eintrag) — nur ein visueller Hinweis.
- [x] **3.1.6** Bewertung wieder auf "nicht bewertet" zurücksetzen können (Nutzer-Feedback: eine
aus Versehen gesetzte Bewertung — z.B. Qualität, obwohl sich der Schüler nie gemeldet hat —
muss sich zurücknehmen lassen, ohne einen inhaltlich falschen Wert stehen lassen zu müssen).
Im Sitzplatz-Dialog (`SeatAssessmentDialog`) gab es das bereits über "Löschen"/Backspace je
Aspekt; in der session-weiten Schnelleingabe (`ParticipationQuickInputDialog`, Q/W/E/R/T +
Zifferntasten) fehlte ein entsprechender Weg — Backspace ist dort bereits für "vorheriger
Schüler" belegt. Neue Taste **Entf** ruft `QuickInputViewModel.ClearRating()` auf
(`ApplyRating` nimmt jetzt `int?` statt `int` an); bewusst eine eigene Aktion statt über 0
zu laufen, da 0 ("--") bei Quantität ein echter, beobachteter Wert ist, kein Platzhalter für
"nicht ermittelt".
### 3.2 Aggregation zur Mitarbeitsnote
- [x] **3.2.1** Gewichtung je Aspekt konfigurierbar (z.B. Qualität 50 %, Quantität 30 %, Experiment 20 %).
- [x] **3.2.2** Berechnung einer Mitarbeitsnote je Halbjahr aus allen Sitzungen,
Ausgabe als Note bzw. Punkte je nach `GradingSystem` der Gruppe.
- [x] **3.2.3** Umgang mit fehlenden Werten festlegen (nicht bewertet ≠ schlecht bewertet).
- [x] **3.2.4** Übernahme der berechneten Mitarbeitsnote als `Grade` mit
`Category = Participation` (Anbindung an 2.1).
- [x] **3.2.5** Trendanzeige pro Schüler (Entwicklung über die Sitzungen hinweg).
Umgesetzt über den neuen Dialog "Ø Mitarbeitsnote" im Mitarbeit-Tab
([ParticipationGradeViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ParticipationGradeViewModels.cs),
[ParticipationGradeDialog.axaml](LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml)):
Zeitraum wählbar (Gesamtjahr/H1/H2), Aspekt-Gewichtung live editierbar (`ParticipationAspect.Weight`,
da die eigentliche Aspekt-Verwaltung aus 3.1 noch fehlt), Mapping der Bewertungsskala (-2..+2) auf
Note/Punkte über `GradingService.ParticipationGrade()`, einfache Trendanzeige (↑/↓/→) durch Vergleich
der ersten mit der zweiten Hälfte der Sitzungen. "Übernehmen" aktualisiert eine bestehende
Mitarbeit-Note für denselben Zeitraum statt sie zu duplizieren (erkannt über den `Grade.Note`-Tag).
### 3.3 Sitzungen
- [x] **3.3.1** Sitzung automatisch aus einer geplanten `Lesson` erzeugen
(Datum + Thema übernehmen) — Abhängigkeit zu 4.2. Neuer Button "Sitzung erzeugen" in der
Stunden-Toolbar der Planung ([PlanningTabView.axaml](LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml));
`PlanningTabViewModel.CreateParticipationSessionCommand` legt eine `ParticipationSession`
mit `Date`/`Comment` aus der Stunde an und verknüpft sie über das bis dahin ungenutzte
`ParticipationSession.LessonId`-Feld. Ein zweiter Klick auf dieselbe Stunde legt keine
Dopplung an, sondern zeigt nur einen Hinweis (Prüfung über `LessonId` innerhalb der Gruppe).
- [x] **3.3.2** Anwesenheit in der Sitzung erfassen — als `ParticipationEntry.Attendance`
(krank: Entschuldigung offen/entschuldigt/unentschuldigt), nicht als "verspätet"; siehe unten.
Vorgriff auf 5.2, ersetzt dessen 5.2.1/5.2.4 aber nicht vollständig (kein Fehlzeitenmodul
über alle Stundentypen hinweg, nur innerhalb von Mitarbeit-Sitzungen).
- [x] **3.3.3** Sitzung bearbeiten/löschen inkl. Rückfrage bei vorhandenen Bewertungen.
**Nachtrag:** Das Bearbeiten (`EditSessionCommand`/`AddSessionDialog`) war bereits vor
diesem Durchgang ad-hoc umgesetzt (Commit „Edit Stammdaten Mitarbeitsessions“), ohne dass
diese Liste nachgezogen wurde — daher hier nur nachträglich dokumentiert, nicht neu gebaut.
Neu ergänzt wurde das Löschen: Button "🗑 Sitzung löschen" neben "✎ Sitzung bearbeiten"
([ParticipationTabView.axaml](LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml)),
`DeleteSessionCommand` fragt über den generischen `ConfirmDialog` nach (Muster aus
`ParticipationAspectsDialog`) und nennt die Anzahl betroffener `ParticipationEntry`-Datensätze,
damit vor dem endgültigen Löschen klar ist, ob bereits Bewertungen existieren. Das Löschen
selbst kaskadiert wie zuvor über `ParticipationSessionRepository.Delete`.
- [x] **3.3.4** Sitzungen mehrerer Gruppen im Kalenderüberblick. Erweitert den bestehenden
Dashboard-Monatskalender ([DashboardView.axaml](LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml))
um einen dritten `CalendarEventKind.ParticipationSession` neben `Lesson`/`Exam` — blauer
Punkt am Tag, eigener Legenden-Eintrag, Klick navigiert wie bei Lesson-Terminen in den Tab
"Mitarbeit". Bewusst keine eigene neue Ansicht: `DashboardViewModel` hatte `IParticipationSessionRepository`
und die Kalender-Aggregation (`DayAgg`/`CalendarDayCell`/`CalendarEventItem`) bereits, sodass
sich der dritte Termintyp am Exam-Vorbild (In-Memory-Filterung nach `GetByGroup`, keine neue
Repository-Methode) ergänzen ließ.
- [x] **3.3.5** Strichliste "gemeldet"/"gemeldet und drangekommen" direkt im Sitzplan
(Nutzer-Feedback, als Ergänzung zur "ohne Mitarbeitsbewertung"-Zählung aus dem
Aufrufgerechtigkeits-Gedanken): zwei neue Felder `ParticipationEntry.RaisedHandCount`/
`.CalledOnCount` — "drangekommen" ist immer auch eine Meldung und erhöht daher immer
beide Zähler zugleich, nie `CalledOnCount` allein. Zwei kompakte Zähl-Buttons auf jeder
belegten Sitzplatz-Kachel im Anzeigemodus (`ShowLessonOverview`, gleiche Sichtbarkeit wie
die Anwesenheits-/Hausaufgaben-Badges) erlauben das Mitzählen während der Stunde, ohne den
vollen Sitzplatz-Dialog öffnen zu müssen — bewusst nur Hochzählen, keine Korrektur direkt
auf der Kachel (die Rohwerte bleiben über den Sitzplatz-Dialog einsehbar). Tüpfelchen auf
dem i: die Strichliste schlägt im Sitzplatz-Dialog direkt eine Quantitätsbewertung vor
(neuer `ParticipationCountSuggestion.SuggestQuantity`, nur für den eingebauten
"quantity"-Aspekt mit Skalentyp `Scale5`) — als sichtbarer Vorschlag mit einem Klick zum
Übernehmen, nie automatisch gesetzt, damit eine bereits vorhandene bewusste Bewertung nie
stillschweigend überschrieben wird.
- [x] **3.3.6** Tagesflagge je Schüler/Sitzung (Nutzer-Feedback): "Spitzentag" (👑),
"Schlaftag" (😴) und "Schlechter Tag" (⚡) — herausragende Leistungen egal in welche
Richtung eindrücklich festhalten, unabhängig von der eigentlichen Aspektbewertung. Neues
Feld `ParticipationEntry.DayHighlight` (nullable `DayHighlightKind`, genau eine Flagge pro
Eintrag statt kombinierbarer Kennzeichnungen). Primär im Sitzplatz-Dialog
(`SeatAssessmentDialog`, ⇧1/2/3 zum Setzen, ⇧X zum Löschen, gleiches Muster wie
Anwesenheit/Hausaufgabe) sowie als Badge direkt auf der Sitzplatz-Kachel; zusätzlich als
Fallback in der session-weiten Schnelleingabe (`ParticipationQuickInputDialog`, ⇧1/2/3/0)
für Lerngruppen ohne Sitzplan. Rein deskriptiv — fließt nirgends in eine Berechnung
(Mitarbeitsnote 3.2, Aufrufgerechtigkeits-Quote) ein. **Bewusst noch ohne Diagramm-Anzeige:**
die bestehende Notenentwicklung (2.5) zeigt ausschließlich Klausur-/Einzelnoten-Einträge,
keine Mitarbeit-Sitzungen — eine visuelle Markierung "im Diagramm" ist erst mit der
geplanten Schüler-Überblicksansicht sinnvoll umsetzbar.
### Abschnittsnoten & Mitarbeits-Assistent (nicht aus dieser Liste, eigener Workflow-Bedarf)
Ergänzt den Workflow "regelmäßig (alle 47 Wochen) mündliche Noten zu einer Abschnittsnote
zusammenziehen, daraus die Halbjahresnote bilden":
- `ParticipationEntry.HomeworkMissing` (bool) und `.Attendance` (`AttendanceStatus?`:
`ExcusePending`/`Excused`/`Unexcused`) — editierbar direkt im Bewertungsraster
([ParticipationTabView.axaml](LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml), Spalten
"HA"/"Anwesenheit") und im neuen Mitarbeits-Assistenten. `ExcusePending` ist bewusst ein
Zwischenzustand, da die Entschuldigung meist erst später eintrifft.
- Neues Modell `ParticipationSection` (Abschnitt: Start/Ende/Bezeichnung je Gruppe) — nur
abgeschlossene Abschnitte werden gespeichert, der laufende Zeitraum ergibt sich aus dem Ende
des letzten Abschnitts bis heute.
- Neues Fenster **Mitarbeits-Assistent**
([ParticipationWizardViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/ParticipationWizardViewModels.cs),
[ParticipationWizardDialog.axaml](LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml)):
Zeitleiste je Schüler (Sitzungen als Bewertungs-Badges, Klausurtermine, Symbole für Hausaufgaben/
Bemerkung/Anwesenheit), gruppiert nach Abschnitten. "Abschnitt abschließen" berechnet für alle
Schüler einen Notenvorschlag aus den Sitzungen im Zeitraum und speichert ihn als `Grade`
(`Category = Participation`, `Note = "Abschnitt: <Bezeichnung>"`), individuell nachjustierbar.
"Halbjahresnote aus Abschnitten übernehmen" mittelt die Abschnittsnoten eines Zeitraums und
speichert sie über denselben `Grade.Note`-Tag wie der bestehende "Ø Mitarbeitsnote"-Dialog (3.2) —
beide Wege sind kompatibel, der Assistent baut auf 3.2 auf statt es zu ersetzen.
- Dashboard-Kachel "Offene Entschuldigungen": listet alle `ExcusePending`-Einträge der letzten
21 Tage gruppenübergreifend mit Direktauflösung; ältere Einträge werden ausgeblendet statt
automatisch entschieden (pädagogische Entscheidung bleibt bei der Lehrkraft).
- **Nachtrag (Nutzer-Feedback, nach erster produktiver Nutzung):** Vorgeschlagenes Abschnittsende
im Assistenten war immer "heute" (`NewSectionEndDateText`), obwohl die fachlich naheliegende
Zäsur oft schon feststeht, wenn die aktuelle Unterrichtseinheit (4.1) abgeschlossen ist. Neue
`SuggestSectionEndDate()` schlägt stattdessen das `Unit.EndDate` der zuletzt abgeschlossenen
Einheit (`UnitStatus.Completed`) vor, sofern deren Ende im laufenden, noch offenen Abschnitt
liegt (zwischen dem Ende des letzten Abschnitts und heute) — sonst bleibt es bei "heute". Der
Vorschlag ist nur eine Vorbelegung des Textfelds, weiterhin frei überschreibbar.
---
## 4. Unterrichtsplanung
Modelle `Unit` und `Lesson` existieren, `UnitRepository`/`LessonRepository` ebenfalls.
Navigationspunkt "Unterrichtsplanung" zeigt seit 4.3 den Stundenplan (`TimetableView`,
Standardtab "Heute" + Bearbeiten-Raster) statt eines `PlaceholderViewModel`. Eine
gruppenübergreifende Wochenansicht fehlt noch, siehe 4.4. 4.1/4.2 leben unabhängig davon im
gruppenspezifischen Tab "Planung" (`GroupDetailView`), siehe unten.
### 4.1 Unterrichtseinheiten (`Unit`)
- [x] **4.1.1** Listenansicht der Einheiten je Gruppe mit Status und Zeitraum
(ersetzt den Platzhalter im Tab "Planung").
- [x] **4.1.2** Dialog Einheit anlegen/bearbeiten: Titel, Fach, Zeitraum, Status, Notizen.
**Nachtrag (Nutzer-Feedback, im Zuge des 4.3.3-Bugfixes):** Bei einer Klasse, die in
mehreren Fächern unterrichtet wird (mehrere `LearningGroup`-Datensätze mit gleichem Namen,
z.B. zwei "10c"), zeigte der Dialog bisher nur das Fach ("Fach: Chemie"), nicht welche
Klasse gemeint ist — beides zusammen war nirgends auf einen Blick zu sehen. Jetzt zeigen
sowohl `UnitDialog` (Einheit) als auch `LessonDialog` (Stunde) direkt unter dem Dialogtitel
"Gruppe · Fach" (z.B. "10c · Chemie"), nicht editierbar, nur zur Einordnung.
- [x] **4.1.3** Kompetenzen aus dem Katalog (siehe 8) einer Einheit zuordnen — Mehrfachauswahl.
- [x] **4.1.4** Einheit als Vorlage speichern und in eine andere Gruppe kopieren
(inkl. Stunden, ohne Datumsbezug).
- [x] **4.1.5** Fortschrittsanzeige: gehaltene / geplante Stunden der Einheit.
### 4.2 Einzelstunden (`Lesson`)
- [x] **4.2.1** Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer.
- [x] **4.2.2** Stundeneditor als tabellarischer Verlaufsplan (Vorbild: vom Nutzer bereitgestelltes
Beispiel eines realen Stundenverlaufsplans) statt einzelnem Phase-Textfeld + Methoden-/
Materialien-Chips: mehrere Phasen-Zeilen (Einstieg, Erarbeitung 1, Sicherung 1, ...), je
Zeile Name, Dauer (Minuten), Tätigkeit, Material (mit Autovervollständigung aus bisherigen
Einträgen) und ein Kurzsymbol, das den Materialfluss kodiert (z.B. `Tb->SH` = Tafelbild wird
ins Schülerheft übertragen).
- [x] **4.2.3** Status `Planned → Conducted` setzen, Reflexionsfeld nach der Stunde.
- [x] **4.2.4** Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken.
- [x] **4.2.5** Stunden serienweise aus dem Stundenplan (4.3) erzeugen — neuer Button
"Serie erzeugen" in der Stunden-Toolbar der Planung
([GenerateLessonSeriesDialog.axaml](LehrerApp.Desktop/Views/Groups/GenerateLessonSeriesDialog.axaml),
`GenerateLessonSeriesDialogViewModel` in
[PlanningViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs)). Legt für
jeden Wochentag/Stunde, den die Gruppe laut `TimetableSlot` (4.3) hat, im gewählten Zeitraum
eine `Lesson` an; Zeitraum ist standardmäßig `Unit.StartDate``Unit.EndDate`, falls gesetzt.
Schulferien/Feiertage werden übersprungen (dieselbe Prüfung wie im Stundenplan-Wochenraster),
bereits vorhandene Termine (gleiches Datum + gleiche Stundennummer der Gruppe, unabhängig von
der Einheit) nicht doppelt angelegt. Ergebnis ("3 Stunde(n) angelegt, 1 durch Ferien/Feiertage
übersprungen, ...") kommt als Toast (erste tatsächliche Nutzung von
`NotificationService.ShowSuccess`, bis dahin nur `ShowError` im Einsatz). Neue Stunden haben
bewusst kein Thema (Platzhalter zum Ausfüllen) — die "Thema erforderlich"-Pflicht des
manuellen " Stunde"-Dialogs gilt hier nicht.
**Bugfix (Nutzer-Feedback):** Doppelstunden (zwei direkt aufeinanderfolgende Perioden
desselben Wochentags/derselben Gruppe im Stundenplan) erzeugten zunächst zwei `Lesson`s mit
gleichem Datum statt einer — dieselbe Konvention wie bei der Doppelstunden-Erkennung in
`LessonDialogViewModel.RecomputeTimeBudget` (Zeitbudget-Anzeige) und den Ferien-Badges in
`TimetableViewModel` fehlte hier. Jetzt bekommt nur die erste Periode eines zusammenhängenden
Blocks eine `Lesson`; Folgeperioden (deren Vorgängerperiode ebenfalls im Stundenplan steht)
werden übersprungen.
Umgesetzt über den neuen Tab "Planung" in
[GroupDetailView.axaml](LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml)
(ersetzt den bisherigen Platzhalter), analog zu Mitarbeit/Noten als eigenes UserControl
[PlanningTabView.axaml](LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml) mit eigenem
[PlanningTabViewModel](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs)
(Einheiten-Tabelle oben, Stunden der ausgewählten Einheit darunter). Modelle, Repositories und
DI-Registrierung existierten bereits; neu sind nur die UI-Schicht sowie zwei Verhaltensfragen:
- **Fach** wird wie bei `Exam` nicht auf `Unit` gespeichert, sondern read-only aus
`LearningGroup.SubjectId` anzeigt (jede Gruppe unterrichtet ein Fach).
- **Verschieben mit Nachrücken (4.2.4):** `MoveLessonDialog` fragt neues Datum + Checkbox
"Folgestunden automatisch verschieben" (Standard an) ab. Verschoben werden dabei nur noch
geplante (`Planned`) Stunden derselben Einheit, die ursprünglich nach der verschobenen Stunde
lagen; bereits durchgeführte (`Conducted`) Stunden bleiben unangetastet.
- **Vorlage-Kopie (4.1.4):** `CopyUnitDialog` fragt Zielgruppe + neues Startdatum ab. "Ohne
Datumsbezug" bedeutet konkret: die relativen Tages-Abstände der Stunden zueinander bleiben
erhalten, werden aber auf das neue Startdatum re-verankert statt die alten Kalendertage zu
übernehmen. Reflexion wird geleert, Status auf "Geplant" zurückgesetzt, `Lesson.GroupId` wird
auf jeder Kopie explizit auf die Zielgruppe gesetzt (siehe
[Datenmodell.md](docs/Datenmodell.md), Abschnitt "Bewusste Denormalisierung").
`LessonRepository.GetByUnit` sortiert jetzt zusätzlich nach `LessonNumber` bei gleichem Datum
(Doppelstunden am selben Tag).
**Nachtrag zu 4.2.2 (Verlaufsplan-Redesign):** `Lesson.Phase`/`Methods`/`Materials` wurden durch
`Lesson.Phases: List<LessonPhaseStep>` (Name/Dauer/Tätigkeit/Material/Kurzsymbol je Zeile) sowie
ein optionales `Lesson.StartTime` ersetzt — Modelle, Migration und Editor in
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
[LiteDbContext.cs](LehrerApp.Data/LiteDbContext.cs) (Schema-Version 3,
`MigrateLessonPhases()` fasst alte Lessons verlustfrei in eine synthetisierte Phasen-Zeile
zusammen) und [PlanningViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs).
Design-Entscheidungen:
- **Dauer ist die primäre Eingabe** je Phase (man plant in Zeitblöcken); die Uhrzeit je Phase wird
im Editor nur zur Anzeige daraus abgeleitet (`LessonDialogViewModel.RecomputeTimes()`), sofern
das optionale `StartTime`-Feld ("Beginn") gesetzt ist. Der Stundenplan (4.3) existiert noch
nicht — bis dahin wird `StartTime` manuell gepflegt, könnte später automatisch befüllt werden.
- **Kurzsymbol** (`LessonPhaseStep.Shorthand`) ist bewusst ein einzelnes Freitextfeld statt einer
erzwungenen Von/Nach-Struktur (Nutzer-Feedback: manchmal ein Materialfluss-Pfeil wie "AB001->S",
manchmal nur eine Sozialform ohne Pfeil wie "Plenum" oder "LDE"). Vorschläge kombinieren den in
den Einstellungen gepflegten Kürzel-Katalog (neues Modell `ShorthandCode`, CRUD-Muster 1:1 von
der Fach-Verwaltung übernommen; Startwerte `DefaultShorthandCodes` L/S/Tb/SH/AB/GA, solange der
Katalog leer ist — kein DB-Seed) mit bereits in anderen Stunden der Gruppe frei getippten Werten
(`PlanningTabViewModel.KnownShorthands`, analog zur Material-Autovervollständigung) — ein
Kurzsymbol muss also nicht vorab im Katalog stehen, um beim nächsten Mal wieder vorgeschlagen zu
werden.
- **Tabellenlayout statt Karten** (Nutzer-Feedback: gestapelte Bordered-Cards pro Phase wirkten
träge): der Verlaufsplan-Editor zeigt eine Kopfzeile mit Spaltentiteln und je Phase eine flache,
einzeilige Zeile mit denselben Spaltenbreiten (Name/Dauer/Zeit/Tätigkeit/Material/Kurzsymbol/
Auf-Ab-Entfernen) statt mehrerer intern gestapelter Unterzeilen — nur die Tätigkeit wächst bei
Bedarf mehrzeilig, alles andere bleibt einzeilig nebeneinander wie in der realen Vorlage.
- Die Migration liest die alte Struktur bewusst über rohe `BsonDocument`s statt über die
typisierte `Lessons`-Collection, da die alten Felder nach der Modelländerung beim typisierten
Deserialisieren sonst bereits verworfen wären, bevor sie gelesen werden können.
**Nachtrag zu 4.2.2 (Stundenraster + Zeitbedarf-Rückmeldung):** Der Verlaufsplan-Editor kannte bis
dahin nur die geplante Gesamtdauer, nicht wie viel Zeit die Stunde laut Stundenplan tatsächlich
hat. Neuer Tab "Stundenraster" in den Einstellungen pflegt Beginn/Ende je Stundennummer (1.10.
Stunde, `PeriodScheduleService` — JSON-Datei, gleiches Muster wie
`SchoolCalendarSettingsService`/`PrivacySettingsService`). Nicht jede Stunde muss eingetragen sein.
- **Doppelstunden-Erkennung:** Ausgehend von der eingetragenen Stundennummer wird so lange die
jeweils nächste Periode addiert, wie der Stundenplan (4.3, `TimetableSlot`) für dieselbe Gruppe
am selben Wochentag *dort ebenfalls* einen Slot hat — eine Lesson mit Stundennummer 3 bekommt
bei einer Doppelstunde 3./4. also automatisch 90 statt 45 Minuten als Vergleichsbasis, ohne dass
das irgendwo separat markiert werden muss. Gehört die Folgeperiode einer anderen Gruppe, wird sie
korrekt nicht mitgezählt.
- **Farbskala** (`LessonDialogViewModel.TimeBudgetColor`, Nutzer-Vorgabe): 9396 % Auslastung ist
der Zielbereich (grün) — ein kleiner Puffer, da 100 % laut Nutzer "meist schon knapp" ist. Von
dort Richtung 100 % wird es zunehmend orange, darüber (überplant) kräftiger rot. Für "deutlich zu
wenig geplant" (unter 70 %) hatte der Nutzer noch keine feste Vorstellung — hier bewusst ein
neutrales Blaugrau statt Rot gewählt (kein Fehler, nur "hier geht noch was"); Grenzwerte/Farben
sind über die switch-Ausdrücke leicht nachjustierbar.
- **Beginn wird beim Setzen der Stundennummer automatisch aus dem Stundenraster übernommen**,
sofern noch keiner eingetragen ist (überschreibt nie einen bereits vorhandenen Wert) — damit
entfällt die bisher manuelle Pflege des "Beginn"-Felds für Stunden, die im Stundenraster
hinterlegt sind, ganz von selbst.
- Ohne Stundennummer/gültiges Datum oder ohne im Stundenraster hinterlegte Zeiten für die
betroffene(n) Periode(n) bleibt die Rückmeldung schlicht ausgeblendet statt eine erfundene Dauer
vorzutäuschen.
**Ideensammlung "Live-Unterrichtsmodus" (noch nicht geplant, nicht Teil von 4.2):** beim
Besprechen des Verlaufsplan-Redesigns kamen weitergehende Wünsche auf, die bewusst zurückgestellt
wurden, da sie eigene Datenmodelle (Live-Session-Zustand, Phasen-Verschiebung zwischen Stunden)
brauchen:
- Fortschrittslinie/Zeitanzeige während des Haltens der Stunde, die zeigt, wo man gerade stehen
müsste.
- "Fertig"/"Überziehen" je Phase anklickbar, nachfolgende Phasenzeiten passen sich automatisch an;
Visualisierung, wie weit man dem Plan hinterherhängt.
- Live-Sprung zwischen Wegen während des Haltens der Stunde (per Klick direkt in den alternativen
Ablauf springen) — die Planungs-/Anzeigeseite (Kennzeichnen, gruppierte Darstellung) ist bereits
umgesetzt, siehe Nachtrag unten; nur die Live-Interaktion beim Halten fehlt noch.
- "Phase in nächste Stunde schieben"-Aktion, mit Hinweis-Symbol an der Folgestunde, dass dort noch
offene Phasen/Arbeitsaufträge der Vorstunde einzuplanen sind.
- Phasen-"Parkplatz": beim Planen Phasen ablegen können, ohne sie sofort einer Stunde zuzuordnen,
und später flexibel in eine beliebige Stunde einfügen — Werkzeug, um Einheiten während des
Schuljahres an die Realität anzupassen, wenn der Unterricht nicht wie geplant läuft.
**Nachtrag — schreibgeschützte Verlaufsplan-Ansicht:** Um den Verlaufsplan-Editor überhaupt im
Unterricht benutzen zu können, ohne versehentlich etwas zu verändern, gibt es jetzt
`LessonViewerDialog` (erreichbar über "Anzeigen" im Stunden-Toolbar, sichtbar bei ausgewählter
Stunde) — reine, größer geschriebene Anzeige des Verlaufsplans inkl. abgeleiteter Uhrzeit je
Phase, ohne jede Bearbeitungs- oder Verlängern-/Verschieben-Funktion. Das ist bewusst nur ein
Ausschnitt aus der "Fortschrittslinie"-Idee oben (rein statische Anzeige, keine Live-Aktualisierung
nach Uhrzeit, kein "fertig"/"überziehen") — der volle Live-Modus bleibt Ideensammlung.
**Nachtrag — Alternative Unterrichtsabläufe, zweite Iteration (echter Katalog statt Freitext):**
Die erste Umsetzung (`LessonPhaseStep.AlternativePath: string?`, freier Bezeichner, Zuweisung über
eine durchgehend sichtbare `AutoCompleteBox`-Spalte) wirkte im Alltag zu prominent für einen Fall,
der selten vorkommt. Nutzer-Feedback: eine unauffällige Checkbox reicht für den Normalfall, die
eigentliche Zuordnung gehört in einen separaten Dialog, und ein alternativer Ablauf sollte eine
Erklärung tragen können (wann/wofür er gedacht ist, z.B. "bei Zeitmangel").
Redesign:
- Neues Modell [`AlternativeLessonPath`](LehrerApp.Core/Models/Planning.cs) (Name + optionale
`Description`) als eigener, wiederverwendbarer Katalog — `LessonPhaseStep.AlternativePathId:
Guid?` referenziert einen Eintrag statt Freitext direkt zu speichern (`null` = Hauptweg).
`IAlternativeLessonPathRepository`/`AlternativeLessonPathRepository`, eindeutig auf `Name`,
kein eigenes Verwaltungs-UI in den Einstellungen — Einträge entstehen inline beim Zuweisen.
- **Editor**: Die "Pfad"-Spalte ist jetzt eine einzelne `CheckBox` (`PhaseStepEditItem.
HasAlternativePath`), deren `Content` bei Zuordnung den Namen des Ablaufs zeigt, `Foreground`
deterministisch aus dem Namen eingefärbt (kleines, farbig unterstütztes Label statt breiter
Eingabespalte). Ankreuzen öffnet `AlternativePathDialog` (Combobox über vorhandene Abläufe +
Formular zum Anlegen eines neuen inkl. Beschreibung); Abwählen entfernt die Zuordnung direkt
ohne Dialog. `LessonDialogViewModel.OnPickAlternativePath` (vom Code-Behind von `LessonDialog`
selbst verdrahtet, nicht vom übergeordneten `PlanningTabView` — der Dialog braucht das
`LessonDialog`-Fenster als Owner) kapselt diesen Ablauf.
- **Viewer**: `LessonViewerViewModel` löst `AlternativePathId` über den Katalog auf und zeigt die
hinterlegte Beschreibung unter der Gruppenüberschrift.
- **Migration v3→v4** (`MigrateLessonAlternativePaths`): alte `AlternativePath`-Freitextwerte
werden pro distinktem Namen zu einem `AlternativeLessonPath`-Katalogeintrag zusammengeführt
(gleicher Name über mehrere Stunden hinweg → derselbe Eintrag, wiederverwendet) und referenziert.
- Weiterhin bewusst **kein** Bedingungsfeld ("wenn an Stelle X nur noch weniger als Y Minuten") —
das setzt Live-Zeiterfassung während des Unterrichtens voraus (siehe Live-Unterrichtsmodus oben)
und war ausdrücklich als Idee, nicht als Anforderung formuliert. `AlternativeLessonPath.
Description` deckt die Erklärung als Freitext ab; eine echte Bedingung bliebe für später.
- Bewusst weiterhin **kein** Fork-Punkt-Modell: jede Gruppe berechnet ihre abgeleitete Uhrzeit im
Viewer unabhängig ab `Lesson.StartTime`, nicht ab einer gemeinsamen Verzweigungsstelle im
Hauptweg — deutlich einfacher und für den schnellen Überblick ausreichend.
**Nachtrag — Datum-Vorbelegung beim manuellen " Stunde"-Dialog:** Nutzer-Bug-Report: eine
manuell angelegte Doppelstunde zeigte immer 45 statt 90 Minuten Zeitbedarf. Ursache war kein
Fehler in der Doppelstunden-Erkennung (Nachtrag zu 4.2.2 oben) selbst, sondern dass `DateText`
im `LessonDialogViewModel` beim Neuanlegen immer auf "heute" vorbelegt war — traf dieses Datum
nicht auf den Wochentag der eingetragenen Stundennummer im Stundenplan, lief die
weekday-basierte Nachschlage-Logik für die Folgeperiode ins Leere und die Erkennung blieb
stumm bei 45 Minuten, ohne dass das im UI ersichtlich war. `LessonDialogViewModel.
SuggestNextLessonDate()` ersetzt jetzt den "heute"-Default beim Neuanlegen: sie ermittelt aus
`TimetableSlot` alle Wochentage, an denen die Gruppe laut Stundenplan Unterricht hat, nimmt das
späteste bereits existierende `Lesson.Date` der Einheit (oder heute, falls die Einheit noch
leer ist) und rollt von dort vorwärts auf den nächsten passenden Wochentag. Fehlt ein
Stundenplan-Eintrag für die Gruppe, bleibt "heute" als Fallback erhalten (keine Verhaltens-
änderung für Gruppen ohne Stundenplan).
**Nachtrag — Anhänge je Stunde (Nutzerwunsch, fachspezifisch):** Chemieunterricht plant mit
Experimenten, zu denen eine schriftliche Gefährdungsbeurteilung gehört; außerdem gab es bisher
gar keine Möglichkeit, ein Arbeitsblatt/Material als Datei an eine Stunde zu hängen (die
`Material`-Spalte im Verlaufsplan ist bewusst nur Freitext, siehe 4.2.2). Statt zweier separater
Felder ein einziges, generisches `Lesson.Attachments`
(`List<DocumentAttachment>`) — dieselbe Anhang-Infrastruktur, die `Documentation` (5.1) bereits
nutzt (`IAttachmentStorage`, `LiteAttachmentStorage`), deckt beides ab: Gefährdungsbeurteilungen
genauso wie normale Arbeitsblätter/Scans. Im Stunden-Dialog
([LessonDialog.axaml](LehrerApp.Desktop/Views/Groups/LessonDialog.axaml)) neuer Abschnitt
"Anhänge" nach demselben Muster wie im Dokumentationsdialog (Button " Datei" über den
Avalonia-Dateiauswahldialog, Liste mit Datei/Größe/"Speichern"/"×"). `LessonRepository.Delete`
räumt beim (harten) Löschen einer Stunde die zugehörigen Anhänge aus der Attachment-Ablage auf,
analog zu `LiteDbContext.CascadeHardDeleteDocumentation`. Neue Tests: `LessonDialogViewModelTests`
(Hochladen inkl. Größenlimit, Entfernen, Vorbelegung beim Bearbeiten, Übernahme ins
Speicherergebnis), `RepositoryTests.LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf`.
Eine KI-gestützte Vorformulierung der Gefährdungsbeurteilung (ebenfalls Nutzerwunsch) ist bewusst
zurückgestellt — dafür bräuchte es einen neuen `ai-backend`-Endpunkt mit eigenem Systemprompt
(analog zu `explain.php`, 4.5.21) und eine sorgfältig formulierte, gut sichtbare
Rechtssicherheits-Einschränkung im Dialog; als eigener, separat zu planender Schritt vorgesehen.
**Nachtrag — Gefährdungsbeurteilungs-Assistent (Umsetzung des oben zurückgestellten Schritts):**
Vor der Umsetzung stand eine Architekturfrage: eigene LiteDB-Entität mit Repository/Sync-Wiring,
oder etwas Leichtgewichtigeres? Entschieden für Letzteres — die Prüfung, ob Anhang-Dateibytes
überhaupt zwischen Geräten synchronisieren (siehe Nachtrag zu 10.1.8, dort im selben Zug behoben),
ergab, dass die eigentliche Lücke eine Ebene tiefer lag, nicht am Fehlen einer eigenen Entität.
**Format:** [`HazardAssessment`](LehrerApp.Core/Models/HazardAssessment.cs) ist ein reines,
JSON-serialisierbares Modell (Titel, Klassenstufe/Kurs, Datum, Art des Versuchs
[Lehrer-/Schüler-/Demonstrationsversuch], Durchführung, Gefahrstoffe mit GHS-Piktogrammen/H-/
P-Sätzen/Menge, Gefährdungen, Schutzmaßnahmen, Erste Hilfe, Entsorgung) — **keine** eigene
LiteDB-Collection/Repository. Wird als JSON serialisiert und über die bestehende
Anhang-Infrastruktur an die `Lesson` gehängt, Dateiname endet auf `.gbu.json`
(`AttachmentItem.IsHazardAssessment`/`HazardAssessmentSuffix`). Bewusst **kein** eingebauter
Katalog amtlicher H-/P-Satz-Texte im Code — Fehlerrisiko bei sicherheitsrelevanten Angaben, die
Lehrkraft trägt die Prüfung gegen das Sicherheitsdatenblatt.
**Wizard:** neuer mehrschrittiger Assistent
([HazardAssessmentWizardDialog.axaml](LehrerApp.Desktop/Views/Groups/HazardAssessmentWizardDialog.axaml),
`HazardAssessmentWizardViewModel`) mit 5 Schritten (Basisdaten, Gefahrstoffe, Gefährdungen &
Schutzmaßnahmen, Erste Hilfe & Entsorgung, Zusammenfassung). Persistiert nicht selbst — liefert
nur `Result`, der Aufrufer (`LessonDialog`) serialisiert zu JSON und hängt es über die bereits
vorhandenen `AddAttachment`/`RemoveAttachmentCommand` an. Im Stunden-Dialog neuer Button
"🧪 Gefährdungsbeurteilung" neben " Datei"; `.gbu.json`-Anhänge zeigen in der Anhangliste
"Öffnen" (Wizard im Bearbeitungsmodus, ersetzt den Anhang beim Speichern) und "PDF" statt des
generischen "Speichern".
**KI-Entwurf:** neuer Endpunkt `ai-backend/gbu.php` (gleicher Aufbau wie `explain.php`: Auth,
Guthabenprüfung, fester Systemprompt, `ai_backend_call_and_charge`) erkennt aus Thema und
Verlaufsplan der Stunde das Experiment und liefert einen strukturierten Entwurf. Systemprompt
weist die KI ausdrücklich an, unsichere H-/P-Sätze/Mengenangaben **nicht zu erfinden**, sondern
mit einem Prüfhinweis zu kennzeichnen, und bei der Einstufung Lehrer-/Schülerversuch im Zweifel
vorsichtig (Lehrerversuch) zu sein. `AiPlanningService.RequestHazardAssessmentDraftAsync` (neue
DTOs `AiHazardAssessmentRequest`/`AiHazardAssessmentResponse` in `AiPlanningDtos.cs`) baut den
Kontext lokal aus dem gerade im Dialog bearbeiteten (auch noch nicht gespeicherten) Stand, nicht
erst nach dem Speichern. Button "🤖 KI-Entwurf erstellen" im Wizard, direkt daneben ein **fest
sichtbarer** Rechtssicherheits-Hinweis (nicht erst nach Fehlern eingeblendet): "Kein
rechtssicheres Dokument — bitte jede Angabe eigenverantwortlich prüfen." `HazardAssessment.
IsAiAssisted` wird gesetzt und erscheint als deutlich hervorgehobener Warnhinweis im PDF-Export,
sobald KI-Anteile beteiligt waren.
Deployment-Hinweis: `ai-backend/` hat **kein** Auto-Deploy (anders als `LehrerApp.Api`, siehe
[docker/README.md](docker/README.md)) — nach diesem Änderungsdurchgang muss `gbu.php` manuell auf
den PHP-Server hochgeladen werden (siehe [ai-backend/README.md](ai-backend/README.md), Abschnitt
"Update für bereits deployte Installationen").
**PDF-Export:** `PdfExportService.BuildHazardAssessmentPdf` (neues `HazardAssessmentPrintData`)
druckt Durchführung, Gefahrstofftabelle, Gefährdungen/Schutzmaßnahmen als Liste, Erste
Hilfe/Entsorgung/Hinweise — bei `IsAiAssisted` zusätzlich ein auffälliges Warnbanner oben im
Dokument.
Neue Tests: [HazardAssessmentWizardViewModelTests.cs](LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs)
(Navigation, Validierung, Gefahrstoff-/Gefährdungs-/Schutzmaßnahmen-Verwaltung, Vorbelegung beim
Bearbeiten, `ExperimentKindDisplay`/`GhsPictogramDisplay`), zwei neue Fälle in
`PdfExportServiceTests.cs`. Die HTTP-Anfrage von `RequestHazardAssessmentDraftAsync` selbst ist
wie bei `RequestExplanationAsync`/`RequestPlanAsync` nicht Teil der automatisierten Tests (braucht
einen echten Endpunkt, siehe Kommentar in `AiPlanningServiceTests.cs`).
**Nachtrag — Chemikalien-Recherche (Nutzerwunsch):** pro Gefahrstoff-Zeile im Wizard ein Button
"🔍 Recherchieren", der Piktogramme, Signalwort, H-/P-Sätze, CAS-Nummer und Tätigkeitsbeschränkung
befüllt — `HazardSubstance` bekommt dafür drei neue Felder (`Cas`, `SignalWord`,
`ActivityRestriction`).
**Weg dorthin (drei verworfene Zwischenstände, der Reihe nach):**
1. Zuerst versucht: KI mit Anthropics Websuche-Tool, auf die GESTIS-Domain eingeschränkt.
Scheiterte durchgängig ("nicht sicher auffindbar"), auch für zweifelsfrei in GESTIS erfasste
Stoffe.
2. Ursachenanalyse ergab: `gestis.dguv.de` ist eine reine JavaScript-Anwendung ohne
serverseitig gerenderten Inhalt (nur ein leeres `<div id="app">`) — weder eine Websuche noch
ein einfacher HTTP-Abruf konnte dort je etwas finden, unabhängig von einer bekannten URL. Über
das öffentlich ausgelieferte JS-Bundle der Seite wurde GESTIS' eigene (inoffizielle)
Daten-API gefunden und direkt verifiziert (inkl. des ebenfalls öffentlich in
`gestis.dguv.de/env-config.js` ausgelieferten Zugangsschlüssels) — technisch funktionierte der
Direktabruf, aber GESTIS selbst hat keine strukturierte, schulspezifische
Tätigkeitsbeschränkung hinterlegt (nur eine allgemeine Jugendarbeitsschutzgesetz-Floskel plus
Verweis auf eine externe, nicht abrufbare Publikation) — genau die für den Unterricht
wichtigste Angabe fehlte also weiterhin.
3. Der Nutzer stellte stattdessen die offizielle **RiSU-Stoffliste** ("Richtlinie Sicheres
Experimentieren im Unterricht") als PDF bereit (123 Seiten, wkhtmltopdf-Export einer
Datenbanktabelle) — enthält Name, CAS-Nr., ZVG-Nr., GHS-Piktogramme, Signalwort, H-/P-Sätze
UND eine eigene Tätigkeitsbeschränkungs-Spalte (Freigabe/Verbot/Altersgrenzen/
Ersatzstoffprüfung/Lehrkraft-Vorbehalt) — exakt das gesuchte Datenmodell, gezielt für den
Schuleinsatz zusammengestellt statt eines allgemeinen Arbeitsschutz-Nachschlagewerks.
**Endgültige Architektur — reiner lokaler Datenbank-Lookup, keine KI mehr beteiligt**
(Nutzervorgabe: "wir brauchen die KI gar nicht mehr im Backend. Es reicht die reine Datenbank."):
- Das 123-seitige PDF wurde einmalig (per `pdfplumber`, Tabellen-Extraktion je Seite, robuste
CAS-Nummer-Rekonstruktion über zeilenweise wrappende Bindestrich-Enden) in
[data/stoffliste.json](../ai-backend/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB).
- Neuer Endpunkt [substance.php](../ai-backend/substance.php): prüft weiterhin das Bearer-Token
(`ai_backend_authenticate`), sucht den angefragten Namen aber nur noch exakt (normalisiert,
auch gegen CAS-Nummer/verwandte CAS-Nummern) in [stoffliste.php](../ai-backend/stoffliste.php)
— bewusst **kein** Fuzzy-/Teilstring-Match, ein falscher Treffer wäre hier schlimmer als gar
keiner. Kein KI-Aufruf, keine Kosten, keine Drittanbieter-Netzwerkabhängigkeit mehr; ohne
Treffer liefert der Endpunkt einen klaren 404 statt eines Rateversuchs.
- Die gesamte für dieses Feature eingeführte Tool-Use-/Websuche-Infrastruktur wurde vollständig
zurückgebaut, da nicht mehr gebraucht: `ProviderInterface`/`AnthropicProvider`/`FakeProvider`/
`ai_backend_call_and_charge` wieder auf ihren ursprünglichen (Vor-Feature-)Stand, `gestis.php`,
`cache.php`, `data/gestis-substances.json` und die Migration für
`transactions.web_search_requests` wieder entfernt.
- **Legende der Tätigkeitsbeschränkungs-Codes** (steht in keiner Form im PDF selbst; die erste
vom Nutzer erinnerte Fassung war falsch und wurde nach einem zweiten Blick korrigiert — u.a.
`S`/`L+` bedeuten das genaue GEGENTEIL der ersten Fassung, ein Verbot statt einer Freigabe):
`+` generelle Erlaubnis · `X` generelles Tätigkeitsverbot an Schulen · `L+` Tätigkeitsverbot für
Lehrkräfte (Ausnahme siehe RiSU I3.5) · `S` Tätigkeitsverbot für Schüler:innen · `S4K`
Tätigkeitsverbot bis einschließlich Jahrgangsstufe 4 · `S9K` bis einschließlich Jahrgangsstufe 9
· `W` Tätigkeitsverbot für werdende/stillende Mütter · `ESP` gesonderte Ersatzstoffprüfung
notwendig (unverändert). Aus diesem Anlass Architektur angepasst: `data/stoffliste.json`
speichert nur noch die rohen Codes, die Übersetzung passiert erst zur Abfragezeit in
`stoffliste.php` (`STOFFLISTE_RESTRICTION_LEGEND`) — eine künftige Korrektur braucht damit nur
noch eine Codeänderung statt einer kompletten Neu-Generierung der ~1800-Stoffe-Datenbank.
- **Datenqualität:** beim Regenerieren der Datenbank für obige Korrektur zusätzlich eine
Kopfzeilen-Dublette gefunden und behoben (Erkennung war zu strikt auf unverzerrten Text
angewiesen) sowie entdeckt, dass `pdfplumber`s Tabellenerkennung bei ca. 40 der ca. 1800 Stoffe
(~2 %) die Spalten nicht sauber trennen konnte (ungewöhnlich lange Zellinhalte lassen die
Spaltenerkennung für einzelne Zeilen kollabieren). Für diese Zeilen wurde eine Regex-basierte
Rekonstruktion versucht (CAS-/GHS-/H-/P-Code-Muster sind positionsunabhängig eindeutig,
Stoffname dagegen nicht) — nur bei einem eindeutig plausiblen Stoffnamen übernommen (11 von 51
gerettet), der Rest bewusst ausgelassen statt mit einem möglicherweise falschen Namen
aufgenommen zu werden (ein falscher Name bei sonst korrekten Gefahrendaten wäre gefährlicher als
ein fehlender Eintrag). Endgültige Größe: 1764 Stoffe.
- **Bekannte Lücke:** diese Stoffliste enthält keine Entsorgungshinweise (anders als der
verworfene GESTIS-Ansatz) — `Disposal` bleibt bei einem Datenbank-Treffer leer und muss
weiterhin von Hand ausgefüllt werden.
- Deployment: `substance.php`, `stoffliste.php` und `data/stoffliste.json` hochladen — kein
Migrations-Schritt, keine `config.php`-Änderung mehr nötig (siehe `ai-backend/README.md`).
**Feld umbenannt:** `HazardSubstance.AgeRestriction` → `ActivityRestriction` (Wizard-Feld
"Umgangsbeschränkung für Schüler (nach Alter)" → "Tätigkeitsbeschränkung"), da die tatsächlich
verfügbare Angabe breiter ist als reine Altersgrenzen (Freigabe/Verbot/Ersatzstoffprüfung/
Lehrkraft-Vorbehalt gehören genauso dazu) — der ursprüngliche Feldname war eine zum Zeitpunkt der
ersten Umsetzung noch zu enge Annahme.
**Auf dem Weg dorthin außerdem behoben (nicht mehr relevant für die Endarchitektur, aber
allgemein nützlich):** der Desktop-Client zeigte bei jedem Backend-Fehler ≠ 401/402 nur die
pauschale Meldung "Die Anfrage an den KI-Dienst ist fehlgeschlagen" statt der von
`ai_backend_fail` tatsächlich mitgesendeten Ursache (`{"error": "..."}`) — betraf alle KI-Aufrufe
(`RequestPlanAsync`/`RequestExplanationAsync`/`RequestHazardAssessmentDraftAsync`), nicht nur die
Chemikalien-Recherche. Neue gemeinsame `AiPlanningService.BuildRequestFailedExceptionAsync` liest
das `error`-Feld mit und hängt es an die Meldung an — bleibt auch nach diesem Umbau nützlich.
**PDF-Export:** Gefahrstoffe werden jetzt als eigener Block je Stoff statt als schmale
Tabellenzeile gedruckt (`PdfExportService.SubstanceBlocks`/`SubstanceBlock`) — bei den vielen
Datenpunkten je Stoff (Name, CAS, Menge, GHS, Signalwort, H-/P-Sätze, Tätigkeitsbeschränkung) wäre
eine Tabelle auf A4-Hochformat zu schmal für lesbare Texte geworden; die Tätigkeitsbeschränkung
wird farblich hervorgehoben, da sie die operativ wichtigste Angabe für die Lehrkraft ist.
Neue Tests: `HazardSubstanceEditItemTests` (Delegate-Aufruf, Feldübernahme, Fehlerbehandlung,
leerer Name), zwei neue `CanUseAi`/`CanResearch`-Wiring-Tests in
`HazardAssessmentWizardViewModelTests`. Die eigentliche HTTP-Anfrage von
`RequestSubstanceResearchAsync` ist nicht Teil der automatisierten Tests (kein PHP im
Implementierungsumfeld verfügbar); die PDF→JSON-Umwandlung und die Lookup-Logik wurden in Python
nachgebaut und gegen reale Stichproben aus dem PDF verifiziert. Für `ai-backend/` selbst existiert
weiterhin keine automatisierte Testsuite (kein PHP-Testframework im Projekt).
### 4.3 Stundenplan
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
[TimetableSlotRepository](LehrerApp.Data/Repositories/AllRepositories.cs). Wiederkehrendes
Muster (kein Datum) — für konkret gehaltene Stunden bleibt `Lesson` (4.2) zuständig. Pro
Wochentag/Stunde höchstens eine Gruppe (eindeutiger Index + freundliche Fehlermeldung im
Repository selbst, nicht erst über eine rohe `LiteException`): ein Lehrer kann nicht
gleichzeitig an zwei Orten unterrichten.
- [x] **4.3.2** Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe — neuer
Navigationspunkt "Unterrichtsplanung" (bisher `PlaceholderViewModel`) zeigt jetzt
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml). MoFr ×
1.10. Stunde, Farbe deterministisch aus dem Gruppennamen (gleiches Hash-in-Palette-Muster
wie bei den alternativen Unterrichtsabläufen in 4.2.2).
- [x] **4.3.3** Bearbeitung per Klick im Raster — Klick auf eine leere Zelle öffnet
`TimetableSlotDialog` zum Zuweisen (Gruppenauswahl + optionaler Raum), Klick auf eine
belegte Zelle öffnet denselben Dialog zum Ändern/Entfernen. **Drag bewusst nicht
umgesetzt:** Klick deckt die vollständige Bearbeitung (Zuweisen/Ändern/Entfernen) bereits
ab: Drag wäre nur eine schnellere Geste für "Zuordnung an eine andere Zelle verschieben",
kein zusätzlicher Funktionsumfang — bei Bedarf später ergänzbar.
**Nachtrag (Bugfix, aus echtem Nutzungsfall):** `TimetableSlotDialogViewModel` baute die
Gruppenauswahl für den Zuweisen-Dialog als `Dictionary<string, Guid>`, geschlüsselt nach
`LearningGroup.Name` — bei zwei Lerngruppen mit demselben Namen (dieselbe Klasse in zwei
Fächern unterrichtet, z.B. zwei Datensätze "10c") warf `ToDictionary` eine
`ArgumentException` ("An item with the same key has already been added"), der Dialog ließ
sich gar nicht mehr öffnen. Behoben durch eindeutige Anzeige-Labels statt des rohen Namens:
bei einem Namenskonflikt wird das Fach angehängt ("10c (Chemie)" vs. "10c (Mathematik)"),
mit einem nummerierten Fallback-Suffix für den theoretischen Restfall gleicher Name UND
gleiches Fach. Zwei Regressionstests in
[TimetableSlotDialogViewModelTests.cs](LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs)
bilden genau dieses Szenario nach.
- [x] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` — Seitenleiste "Abweichende
Wochenstunden" listet jede Gruppe der aktuellen Schuljahres, deren eingetragene
Slot-Anzahl nicht der hinterlegten Wochenstundenzahl entspricht.
- [x] **4.3.5** Schulferien und Feiertage — gesetzliche Feiertage werden je Bundesland berechnet
([PublicHolidayService.cs](LehrerApp.Core/Services/PublicHolidayService.cs), Gauß'sche
Osterformel + bundeslandspezifische Zusatzfeiertage, empirisch gegen bekannte
Ostersonntage 20232027 verifiziert) statt gespeichert — anders als Schulferien sind sie
algorithmisch herleitbar. Schulferien selbst sind **nicht** herleitbar (jährlich neu von
den Bundesländern festgelegt) und werden deshalb manuell gepflegt
(`SchoolHoliday`-Repository, CRUD in der Seitenleiste). Bundesland als persistente
Einstellung ([SchoolCalendarSettingsService.cs](LehrerApp.Core/Services/SchoolCalendarSettingsService.cs)).
"Aus der Planung ausnehmen" ist für dieses Kapitel als "nächste unterrichtsfreie Tage"
sichtbar (kombinierte, sortierte Liste aus beiden Quellen) — die eigentliche Ausnahme aus
generierten Stunden ist erst mit der (bewusst zurückgestellten) Serienerzeugung 4.2.5
relevant und verwendet dieselben zwei Datenquellen.
**Nachtrag Schulstandort, Wetter und Warnungen:** Im selben Einstellungs-Tab kann nun eine
Schuladresse hinterlegt werden. Der authentifizierte LehrerApp-Server geocodiert sie nur beim
expliziten Speichern über Nominatim/OpenStreetMap (1 Anfrage/s, identifizierender User-Agent,
persistentes Ergebnis) und speichert das Profil getrennt vom verschlüsselten Sync-Eventstrom.
Aus den Koordinaten bestimmt er die nächstgelegene Station im offiziellen
MOSMIX-Stationskatalog, parst deren kleine MOSMIX-L-KMZ und ordnet amtliche DWD-CAP-Warnungen
per Punkt-in-Polygon-Prüfung zu. Vorhersagen, Warnungsarchive und der letzte erfolgreiche
Nutzer-Snapshot werden gecacht; bei einem DWD-Ausfall liefert `/api/school/weather` den
letzten Stand als `IsStale`. Das Dashboard zeigt aktuelle Temperatur/Wind/Niederschlag und
hebt örtlich zutreffende Warnungen prominent hervor. Parser, Stationswahl, Persistenz,
Standortwechsel und UI-Validierung sind durch API-/Desktop-Tests abgedeckt.
**Nachtrag Wetter im Wochenstundenplan:** Die MOSMIX-Ausgabe des Servers umfasst nun die
vollständige Prognosespanne bis +240 Stunden statt nur 72 Stunden. Der schreibgeschützte
Wochenüberblick verdichtet die Stundenwerte von 0816 Uhr je Tag auf ein kleines Symbol im
Tageskopf (Priorität Gewitter → Sturm ab 62 km/h Böen → Regen → bewölkt → sonnig). Der
Tooltip nennt Temperaturspanne, die höchste stündliche Regenwahrscheinlichkeit (`R101`),
Niederschlagsmenge, aufsummierte Sonnenscheindauer (`SunD1`), maximale Böen und betroffene
Warnungen. Alle Werte stammen aus derselben bereits geladenen MOSMIX-L-Datei; ein UV-Index
wird bewusst nicht geschätzt, weil MOSMIX ihn nicht direkt enthält.
Amtliche DWD-Warnungen und Vorabinformationen erhalten zusätzlich ein Warnsymbol am Tag und
eine gut sichtbare Zusammenfassung oberhalb des Rasters; mehrtägige Warnungen werden jedem
überlappten Schultag zugeordnet. Vergangene Tage und Wochen außerhalb der verfügbaren
Prognose bleiben ohne Symbol, statt einen veralteten oder erfundenen Zustand anzuzeigen.
**Nachtrag Hover-Bugfix:** Wetter- und Warnsymbol liegen gemeinsam in einer transparenten,
gepolsterten Trefferfläche. Der Tooltip hängt nicht mehr direkt am je nach Plattform sehr
kleinen Emoji-`TextBlock` und wird dadurch im Wochenraster zuverlässig ausgelöst.
**Nachtrag zu 4.3 (Nutzer-Feedback nach Erstumsetzung):**
- **Ferien/Feiertage-Pflege verschoben:** Die Bundesland-Auswahl und das Schulferien-CRUD standen
ursprünglich in der Seitenleiste des Stundenplans selbst — das wirkte dort deplatziert, da es
eine einmalige Einstellung statt einer täglich genutzten Ansicht ist. Beides ist jetzt ein
eigener Tab "Ferien & Feiertage" in den Einstellungen
([SettingsViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs),
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)). Der Stundenplan
selbst zeigt Bundesland und nächste unterrichtsfreie Tage nur noch lesend an
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs)).
- **Badge "1"/"2" vor Ferien:** Die letzte bzw. vorletzte Stunde eines Wochentags vor den nächsten
Schulferien wird im Bearbeiten-Raster mit einem kleinen Badge markiert — Erinnerung, rechtzeitig
abzuschließen. Berechnet pro (Wochentag, Gruppe) statt pro einzelnem `TimetableSlot`: eine
Doppelstunde besteht aus zwei Slots mit demselben Wochentag/derselben Gruppe und bekommt dadurch
automatisch dasselbe Badge, ohne gesonderte Blockerkennung. Gesetzliche Feiertage werden beim
Abzählen der verbleibenden Termine übersprungen. Bewusst nur an `SchoolHoliday` geknüpft, nicht
an einzelne `PublicHoliday`-Tage — es geht um das Vorbereiten auf eine längere Unterbrechung,
nicht um einen einzelnen freien Tag.
- **"Heute" als neue Standardansicht:** Der Stundenplan öffnet jetzt auf einem
nicht-editierenden Tab "Heute" (heutige Stunden mit Gruppe, Raum, Thema aus `Lesson.Topic` falls
vorhanden, sowie ein Hinweis, wenn an dem Tag eine Klausur ansteht). Klick auf eine Stunde
navigiert in die Lerngruppe (Tab "Planung"). Das bisherige Bearbeiten-Raster aus 4.3.2/4.3.3
ist über einen Button ("Stundenplan bearbeiten") als zweiter Tab weiterhin erreichbar.
**Nachtrag zu 4.3, zweite Iteration (Wochenraster in der "Heute"-Ansicht):** Die Tagesliste allein
beantwortete nicht "was steht in der Woche noch an" bzw. "was macht der parallele Kurs" — beides
Fragen, die nur ein Blick über alle Gruppen hinweg beantwortet. Der Tab "Heute" zeigt deshalb jetzt
zusätzlich ein schreibgeschütztes Wochenraster (MoFr × 1.10. Stunde, wie das Bearbeiten-Raster,
aber ohne Klick-zum-Bearbeiten). Jede Kachel zeigt Fach (`Subject.ShortName`), Klasse, Raum und
falls für den Tag hinterlegt das Thema der Stunde, dazu Symbole für: Ferien-Badge ("1"/"2",
wie im Bearbeiten-Raster), Klausur an dem Tag (📝), letzte Stunde vor der nächsten Klausur dieser
Gruppe (⏰) und ein geplantes Experiment (🧪). Die Tagesliste ist per `DockPanel` unten an die
Seite angedockt, das Wochenraster füllt den verbleibenden Platz darüber
([TimetableViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs),
[TimetableView.axaml](LehrerApp.Desktop/Views/Planning/TimetableView.axaml)). Klick auf eine
Kachel navigiert wie in der Tagesliste zur Lerngruppe.
- **"Letzte Stunde vor Klausur"** wird wie das Ferien-Badge pro (Wochentag, Gruppe) berechnet,
aber am nächsten `Exam.Date` der jeweiligen Gruppe statt an einem gemeinsamen Ferientermin
ausgerichtet — beide Badges teilen sich denselben Zähl-Helfer (`CountOccurrences`).
- **"Experiment geplant"** hat kein eigenes Datenfeld — es ist eine Texterkennung über die
bereits im Verlaufsplan-Editor (4.2.2) gepflegten `LessonPhaseStep`-Felder (Name/Aktivität/
Material enthält "Experiment", ohne Groß-/Kleinschreibung). Bewusst kein neues Modellfeld:
die Information steckt in den meisten Fällen schon in der ohnehin gepflegten Phasenplanung.
- Das Wochenraster zeigt bewusst nur die laufende Kalenderwoche ohne Vor-/Zurück-Navigation —
für einen Blick auf zukünftige Wochen bleibt vorerst das Bearbeiten-Raster (zeigt das
wiederkehrende Muster unabhängig vom Datum).
**Nachtrag zu 4.3, dritte Iteration (Ferientage im Wochenraster ausgegraut statt eigener Liste):**
Die Box "Nächste unterrichtsfreie Tage" wirkte neben dem neuen Wochenraster redundant — welche
Tage frei sind, sieht man dort jetzt direkt an den betroffenen Stunden. Die Box (samt
`UpcomingFreeDayItem`, `UpcomingFreeDays`, `BuildUpcomingFreeDays`, `BundeslandLabel`) wurde
entfernt. Stattdessen werden Wochenkacheln, deren Datum in Schulferien fällt oder ein gesetzlicher
Feiertag ist, grau eingefärbt (`WeekCellItem.IsHoliday`, Prüfung gegen `SchoolHoliday`-Zeitraum und
die bereits für die Badges berechneten `PublicHoliday`-Daten) und zeigen statt der Klausur-/
Experiment-Symbole nur noch die Aufschrift "Ferien" — die Symbole wären an einem unterrichtsfreien
Tag ohnehin nicht sinnvoll interpretierbar.
**Nachtrag zu 4.3, vierte Iteration (Wochennavigation):** Schalter ""/"" plus "Diese Woche"
erlauben jetzt, im Wochenraster vor und zurück zu blättern (`TimetableViewModel.WeekOffset`,
`PreviousWeekCommand`/`NextWeekCommand`/`CurrentWeekCommand`). Die Ferien-/Klausur-Nähe-Badges
("1"/"2" vor Ferien, ⏰ vor Klausur) waren bis dahin an "heute" verankert und pro (Wochentag,
Gruppe) einmalig berechnet — das wäre beim Blättern falsch geworden (ein Badge hätte in jeder
angezeigten Woche geklebt, nicht nur in der einen Woche, zu der es gehört). Für das Wochenraster
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
**Nachtrag zu 4.3, fünfte Iteration (Aufsichten + Vertretung):** Nutzer-Feedback: zwischen manchen
Stunden ist auch Pausenaufsicht zu erledigen, und gelegentlich kommen Sonderfälle vor — eine
Vertretungsaufsicht für einen erkrankten Kollegen, oder eine Vertretungsstunde in einer eigenen
oder fremden Lerngruppe.
- **Neue Modelle** ([Planning.cs](LehrerApp.Core/Models/Planning.cs)): `SupervisionDuty`
(wiederkehrend, Wochentag + "Pause nach Stunde X" + Ort — `AfterPeriod = 0` heißt Frühaufsicht
vor der 1. Stunde) und `SubstitutionEntry` (einmalig, an einem konkreten Datum — entweder
`Kind = Supervision` oder `Kind = Lesson`). Bewusst getrennt von `TimetableSlot`/`Lesson`: die
wiederkehrende Aufsicht hat keinen Gruppenbezug, und die meisten Vertretungsstunden sind keine
durchgeplanten Einheiten-Stunden.
- **Wiederkehrende Aufsicht wird nur in den Einstellungen gepflegt** (neuer Tab "Aufsichten",
analog zum Kürzel-Katalog: Liste + Formular, kein Klick-Dialog im Stundenplan-Raster selbst —
anders als bei `TimetableSlot`, weil eine kleine, seltene Liste hier ergonomischer ist als ein
Klick durchs ganze Raster). Pro Wochentag/Pause höchstens eine Aufsicht (eindeutiger Index,
freundliche Fehlermeldung wie bei `TimetableSlot`).
- **Anzeige im Stundenplan:** Aufsicht-Zeilen werden zwischen den betroffenen Stundenzeilen sowohl
im Bearbeiten-Raster als auch im Wochenraster eingefügt (nur wenn für die jeweilige Pause
tatsächlich etwas hinterlegt ist, sonst bleibt die Zeile weg) — `TimetableCellItem`/`WeekCellItem`
bekamen dafür `IsSupervisionRow`/`IsSupervisionCell` als weitere, sich gegenseitig ausschließende
Zellenarten (gleiches Muster wie die bestehenden `IsHeader`/`IsPeriodLabel`/`IsSlotCell`), statt
eine zweite `ItemsControl` neben das bestehende `UniformGrid` zu setzen.
- **"Vertretung eintragen"**-Dialog (neuer Button neben "Stundenplan bearbeiten" im "Heute"-Tab,
[SubstitutionEntryDialogViewModel.cs](LehrerApp.Desktop/ViewModels/Planning/SubstitutionEntryDialogViewModel.cs))
deckt beide Sonderfälle ab. Eine Vertretungsaufsicht/-stunde für ein konkretes Datum überschreibt
in Wochenraster, Bearbeiten-Zeile bzw. Tagesliste die sonst dort angezeigte reguläre Information
für diese eine Stunde/Pause — sie beschreibt ja, was an dem Tag tatsächlich passiert.
Vertretungsstunden ohne passenden `TimetableSlot` (z.B. fremde Gruppe zu einer Zeit, zu der man
sonst frei hat) werden trotzdem ergänzt, nicht verworfen.
- **Entscheidung zur eigenen-Gruppe-Frage** (mit dem Nutzer abgestimmt): Standard bleibt der
einfache Weg — nur ein `SubstitutionEntry` mit Thema, wie bei einer fremden Gruppe. Nur wenn die
gewählte eigene Gruppe mindestens eine `Unit` hat, erscheint zusätzlich eine Checkbox "Direkt als
Stunde in der Einheit übernehmen" (mit Einheiten-Auswahl) — dann entsteht *zusätzlich* eine
echte `Lesson` in dieser Einheit. Der `SubstitutionEntry` bleibt in beiden Fällen bestehen (er
ist die Anzeige-Quelle für den Plan), die `Lesson` ist rein für die Fortschritts-/Reihenfolge-
Bilanz der Einheit gedacht und wird im Stundenplan nicht separat angezeigt.
**Nachtrag zu 4.3, sechste Iteration (Sondereinsätze):** Nutzer-Feedback: neben Vertretung gibt es
auch Sondereinsätze wie Ausflüge oder Berufsmessen, die einen Teil des Tages oder den ganzen Tag
blockieren, ohne dass jemand vertreten wird.
- `SubstitutionKind` um `SpecialAssignment` erweitert; `SubstitutionEntry` bekam `FromPeriod`/
`ToPeriod` (Stundenbereich) und `IsAllDay` (ganztägig statt Stundenbereich). Dieselbe dritte Art
im "Vertretung eintragen"-Dialog (`SubstitutionKindDisplay` jetzt mit drei statt zwei Optionen).
Bewusst **kein** "Als Stunde in der Einheit übernehmen" für Sondereinsätze — ein Ausflug ist
inhaltlich kein Verlaufsplan-Eintrag, anders als eine Vertretungsstunde.
- **Anzeige im Wochenraster:** ein Sondereinsatz überdeckt für seinen Wochentag jede Perioden-Zelle
im belegten Bereich (bei `IsAllDay` alle 10 Stunden) mit derselben Kachel (Farbe `#00838F`,
deutlich von Vertretung-Lila unterscheidbar) — dieselbe Überschreiben-Logik wie bei
Vertretungsstunden, nur über mehrere Perioden statt einer einzelnen.
- **Anzeige in der Tagesliste:** eigener Abschnitt `TodaySpecialAssignments` (wie bei den
Aufsichten) statt Wiederholung über mehrere Zeilen — ein ganztägiger Sondereinsatz würde sonst
zehnmal in der Liste auftauchen.
- **Mehrtägige Sondereinsätze (Klassenfahrt) bewusst nicht als Datumsbereich modelliert** — mit
dem Nutzer abgestimmt: `SubstitutionEntry.Date` bleibt ein einzelnes Datum. Klassenfahrten dauern
laut Nutzer höchstens ~5 Tage und kommen alle ein bis zwei Jahre vor — dafür lohnt sich kein
eigener Datumsbereich mit den nötigen Änderungen an `GetByDate`/Wochenraster-Abfragen; ein
Sondereinsatz über mehrere Tage wird einfach als mehrere Einzeleinträge (einer pro Tag) erfasst.
**Nachtrag zu 4.3, siebte Iteration (Stundenausfall):** Nutzer-Feedback: es kann auch sein, dass
eine Stunde schlicht ausfällt, ohne dass die eigene Abwesenheit der Grund ist — z.B. fällt der
NAT-Unterricht der 6a aus, weil die 6a selbst auf Klassenfahrt ist. Kein Vertretungsfall (niemand
übernimmt), kein Sondereinsatz (die eigene Zeit ist nicht belegt).
- `SubstitutionKind` um `Cancelled` erweitert, vierte Option im "Vertretung eintragen"-Dialog.
Braucht nur Datum + Stundennummer + optionalen Grund — Fach und Gruppe werden beim Anzeigen aus
dem an der Stelle regulär eingetragenen `TimetableSlot` abgeleitet, nicht beim Anlegen manuell
erfasst (die Information steht ja schon im Stundenplan).
Bewusst kein "Als Stunde übernehmen" — ein Ausfall ist per Definition keine gehaltene Stunde.
- **Anzeige:** ersetzt im Wochenraster und in der Tagesliste die normale Stunden-Kachel/-Zeile für
die betroffene Stunde (Grau `#757575`, Aufschrift "Ausfall") — dieselbe Überschreiben-Logik wie
bei Vertretungsstunden und Sondereinsätzen, nur dass hier nichts an die Stelle tritt.
**Nachtrag zu 4.3, achte Iteration (Button-Umbenennung + Zahnrad statt "bearbeiten"):**
Nutzer-Feedback: Der Button "Vertretung eintragen" deckt nach der fünften bis siebten Iteration
auch Sondereinsatz und Ausfall ab, der Name passt nicht mehr. Der Button "Stundenplan bearbeiten"
war zudem redundant (das Bearbeiten-Tab ist direkt anklickbar) und fühlte sich unzuverlässig an.
- "Vertretung eintragen" → "Ausnahme eintragen" (Button und Dialogtitel in
`SubstitutionEntryDialog.axaml`) als neutraler Oberbegriff für alle vier `SubstitutionKind`-Fälle.
- "Stundenplan bearbeiten" ersetzt durch einen kleinen Zahnrad-Button ("⚙️"), der in die
Einstellungen springt, direkt auf den Tab "Ferien & Feiertage" (Nachbar-Tabs "Stundenraster" und
"Aufsichten" sind von dort ein Klick entfernt) — dieselbe Navigate-mit-Zieltab-Konvention wie
`NavigateToGroupDetail`. Dafür `SettingsViewModel.ActiveTabIndex` (an `TabbedPage.SelectedIndex`
gebunden) sowie `MainWindowViewModel.NavigateToSettings(int initialTab = 0)` neu eingeführt.
- `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.
**Nachtrag zu 4.3, sechzehnte Iteration (Bugfix: falsche Vertretungen für kombinierte Gruppen
blieben trotz behobenem Vergleich stehen):** Nutzer-Feedback nach der fünfzehnten Iteration: "Es
klappt nicht. [...] Die falschen Vertretungen stehen noch bei den Kursen. Immer die Kurse mit
Lerngruppen, die aus mehreren Klassen zusammengesetzt sind." Zwei Ursachen, nacheinander gefunden:
- Der Fix aus der fünfzehnten Iteration entfernte Leerzeichen nur auf der `evt.Description`-Seite,
nicht auf `mapping.ClassToken` selbst — schlug also weiterhin fehl, sobald `ClassToken` ein
eingebettetes Leerzeichen trägt (z.B. wenn `ExtractClassTokens` die Klassen mangels bekanntem
Trennzeichen nicht aufsplitten konnte). Fix: `RemoveWhitespace` symmetrisch auf beide Seiten
angewendet; `ExtractClassTokens` akzeptiert jetzt zusätzlich `,` als Trennzeichen (nicht nur
`;`), da nie an einem echten kombinierten Termin verifiziert wurde, welches WebUntis tatsächlich
verwendet. Zur Eingrenzung ohne weitere Rateversuche schreibt eine abweichende Vertretung jetzt
außerdem den genauen fehlgeschlagenen Vergleich (roher Fach-/Klassen-Wert) in ihre eigene
Beschreibung (`UntisDiffService.DeviationReason`).
- Der eigentliche, tiefere Bug: der Nutzer bestätigte danach neu und der Vergleich lief
nachweislich mit aktuellem Code (die neue Diagnose-Beschreibung erschien auf einer anderen,
echten Abweichung), aber die falschen Vertretungen für Mathe-E-Kurs/Chemie G blieben ohne die
neue Diagnose-Beschreibung stehen — sie waren Karteileichen aus einem Poll VOR dem Fix.
`UntisDiffService.Diff` schrieb bislang ausschließlich neu erkannte/weiterhin bestehende
Abweichungen; es gab keinen Pfad, der eine zuvor automatisch erzeugte `SubstitutionEntry`
wieder entfernt, sobald ein späterer Poll dieselbe Zeile nicht mehr als Abweichung einstuft.
Fix: `UntisDiffResult` bekommt `SubstitutionExternalIdsToDelete` — befüllt für (a) eine
Unterrichtsstunde, die nicht mehr abweicht, (b) eine Aufsicht, die jetzt einer regulären
`SupervisionDuty` entspricht, und (c) eine zuvor als fehlend gemeldete Stunde, die im Feed
wieder auftaucht. `UntisSyncService.ProcessIcsText` löscht dafür den vorhandenen Eintrag über
`ISubstitutionEntryRepository.GetByExternalId`/`Delete` (no-op, falls keiner existiert).
- Damit räumen sich einmal fälschlich erzeugte automatische Vertretungen künftig von selbst auf,
sobald der zugrundeliegende Vergleich beim nächsten Poll keine Abweichung mehr findet — nicht
nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch
den Nutzer selbst.
**Nachtrag zu 4.3, siebzehnte Iteration (schulweiter ClassyPlan-Jahresplan):** Zusätzlich zum
persönlichen WebUntis-Stundenplan existiert ein schulweiter Jahresplan mit Konferenzen,
Fortbildungen, Abschlussprüfungen, Fahrten und weiteren Terminen. Vor der Implementierung wurden
CSV- und iCal-Export desselben Stands vollständig verglichen: jeweils 562 eindeutige Termine,
keine fehlenden IDs und vollständige Übereinstimmung von Titel, Beschreibung, Ort und Zeit. iCal
ist die Primärquelle, weil es darüber hinaus stabile UIDs, Änderungsmetadaten und für 264 in der
CSV gruppenlose Zeilen die Information `X-GROUPNAME:Öffentlich` liefert. 186 der 562 Einträge sind
echte DATE-Ganztags-/Mehrtagstermine.
- **Strikte fachliche Trennung:** `AnnualPlanEvent` ist reine Kalenderinformation und besitzt
weder `SubstitutionKind` noch Stunden-/Pausennummer oder Lerngruppen-Mapping. Der neue
`AnnualPlanSyncService` greift auf kein Stundenplan-/Vertretungsrepository zu. Erst die
Anzeigeprojektion im Dashboard führt Jahresplan, Unterricht, Klausuren und Sitzungen zusammen.
- **Eigener Parser:** `AnnualPlanIcsParser` unterstützt DATE-Werte mit exklusivem RFC-5545-DTEND,
mehrtägige Termine, UTC→Europe/Berlin, Escaping und Line-Folding. Anders als der fail-soft
WebUntis-Parser bricht er bei jedem ungültigen VEVENT den gesamten Import ab; dadurch kann ein
Teilabruf niemals den übrigen lokalen Jahresplan als "verschwunden" löschen.
- **Lokaler Vollabgleich:** `AnnualPlanEventRepository` wird idempotent über iCal-UID aktualisiert;
erst nach erfolgreichem Vollparse werden nicht mehr enthaltene Termine entfernt. Wie die
Untis-Snapshots feuert der wiederherstellbare externe Cache bewusst kein `db.OnChange` und
erzeugt damit keine hunderte App-Sync-Ereignisse je Abruf. Automatischer Abruf alle sechs
Stunden plus initialer Abruf beim Aufbau des Dashboards.
- **Einstellungen/Anzeige:** Der gemeinsame Tab "Untis-Einbettung" bündelt zwei klar getrennte
Bereiche für persönlichen Stundenplan-Abgleich und schulweiten Jahresplan, damit die
Einstellungsnavigation kompakt bleibt. Der Jahresplanbereich bietet verschlüsselt gespeicherte
iCal-URL, Status, manuellen Abruf und Entfernen inklusive Cache-Bereinigung. Im Dashboard orange
markiert; Mehrtagstermine erscheinen an jedem betroffenen Tag, Detailzeilen zeigen Uhrzeit,
Gruppe, Ort und Beschreibung. Jahresplantermine laufen parallel zum Unterricht und ersetzen
ihn nie.
- Tests decken Parservarianten, strikten Teilimport-Schutz, Snapshot-Idempotenz/-Bereinigung,
verschlüsselte Einstellungen, Bereichsabfragen und die parallele Dashboard-Anzeige ab.
**Nachtrag zu 4.3, WebUntis-API-Integration (August 2026):** Zusätzlich zu den unverändert
weiterlaufenden iCal-Quellen kann ein persönlicher WebUntis-Zugang im selben Einstellungsreiter
hinterlegt werden. Schule, Host, Benutzername und Passwort liegen auf dem Desktop AES-256-GCM-
verschlüsselt. Der Desktop verbindet sich direkt mit WebUntis und hält dessen Sitzung nach einem
Abruf zehn Minuten offen. Der LehrerApp-Server besitzt bewusst weder WebUntis-Endpunkte noch
Zugangsdaten; Schülerdaten, Fehlzeiten und der unverschlüsselte CSV-Report passieren ihn nie.
- Der Bearbeiten-Tab des Stundenplans lädt eine wählbare Lehrer-/Kalenderwoche, ordnet die
WebUntis-Zeitraster auf lokale Stundennummern ab und zeigt vor dem Speichern jede Zuordnung.
Vorhandene Gruppen werden vorgeschlagen; eine neue Lerngruppe kann direkt aus einer Untis-Zeile
angelegt werden. Vertretungen bleiben bewusst beim etablierten iCal-Diff.
- Die Kursansicht kann eine WebUntis-Klasse statt einer CSV-Datei auswählen. Der abgerufene
Schülerreport läuft anschließend durch exakt dieselbe Dubletten-, Ergänzungs- und
Mitgliedschaftsvorschau wie der manuelle Stammdatenimport; `externKey` bleibt als stabile
WebUntis-Kennung erhalten. Eine echte Unterrichts-/Schülergruppen-Mitgliederliste ist mit dem
hier verwendeten kennwortbasierten JSON-RPC-Zugang nicht verlässlich verfügbar und bleibt ein
späterer Ausbau über die freigabepflichtige offizielle Platform-/OneRoster-API.
- Der Fehlzeitenabgleich lädt die in WebUntis bekannten Fehlzeiten der Kursmitglieder und stellt
Untis- und lokalen Status gegenüber. Nur explizit markierte Einträge, für deren Datum bereits
eine lokale `ParticipationSession` dieses Kurses existiert, können als offen, entschuldigt oder
unentschuldigt übernommen werden; fremde/ganz­tägige Abwesenheiten erzeugen keine lokale Stunde.
**Nachtrag zu 4.3, Fehlzeiten je Unterricht (August 2026):** Der ursprüngliche Fehlzeitenabgleich
rief `getTimetableWithAbsences` ohne Element auf und bekam damit den kompletten Lehrer-Stundenplan
zurück (einmal pro Kursmitglied, siehe damalige Ineffizienz-Korrektur) — das erfordert mehr
WebUntis-Rechte, als ein Lehrkraft-Konto standardmäßig hat, und schlug deshalb in der Praxis fehl.
Ersetzt durch den undokumentierten internen Bericht "Fehlzeiten pro Unterricht pro Schüler\*in"
(`reports.do?name=AbsencePerLesson`, per Netzwerk-Mitschnitt aus der WebUntis-WebApp
reverse-engineered, da die offizielle Reports-API eine Partner-Freigabe braucht), der nur den
eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funktioniert.
- `LearningGroup.WebUntisLessonId` (die WebUntis-interne Unterrichtsnummer/lsid) muss dafür pro
Lerngruppe von Hand hinterlegt werden — sie wird von WebUntis pro Schuljahr neu vergeben (kein
Auslesen über JSON-RPC möglich) und wird deshalb beim Schuljahreswechsel (`GroupRolloverService`)
bewusst nicht in die Folgegruppe übernommen.
- Die Lehrkraft-Kennung (`teacherId`) kommt aus `personId` der `authenticate`-Antwort und muss nicht
gepflegt werden — der Bericht zeigt ohnehin nur eigenen Unterricht, auch bei Doppelbesetzung.
- Der Bericht liefert keinen Klartext-Entschuldigungsstatus, nur Minutenwerte, ein
Bearbeitet-Datum und die externe Schülerkennung. Statuszuordnung (Heuristik, an echten Daten der
Schule korrigiert, nicht durch WebUntis-Dokumentation bestätigt): Abwesenheitsgrund enthält
"entlassen" → vorzeitig entlassen, unabhängig von der Dauer; sonst Fehlminuten unter 45 (eine volle
Stunde) → Verspätung/Teilverlust; sonst ohne Bearbeitet-Datum → ausstehend; sonst externe
Schülerkennung in Klammern → unentschuldigt, ohne Klammern → entschuldigt (das Vorzeichen der
Kennung war die ursprüngliche, falsche Annahme — es gibt kein Minuszeichen in den echten Daten).
- Der alte, jetzt entfernte Fehlzeitenabgleich (`WebUntisAbsenceComparisonViewModel`) sowie der
zugehörige Client-Aufruf `getTimetableWithAbsences` wurden ersatzlos gestrichen statt behoben,
da der neue Bericht denselben Zweck ohne die Rechteproblematik erfüllt.
- Namensabgleich als Fallback, wenn ein Schüler keine externe WebUntis-Kennung hat: WebUntis liefert
Namen teils als "Nachname Vorname", teils uneinheitlich — deshalb werden lokal beide Reihenfolgen
registriert, aber nur falls eindeutig innerhalb der Kandidaten; sonst manuelle Zuordnung per Auswahl
in der Vergleichszeile, statt zu raten.
- Zweiter, undokumentierter Bericht `reports.do?name=ClassregEventPerStudent` liefert Klassenbuch-
einträge (Klasse, Datum, Fach, Name, Benutzer, Eintragskategorie, Kategoriegruppe, Text) im
"-alle-"-Bereich, da die Oberfläche sonst nur "eigene Klasse" oder "-alle-" anbietet
(`WebUntisClient.GetClassRegisterEventsReportAsync`, `WebUntisClassRegisterEventReportParser`).
Auch hier keine externe Schülerkennung, nur der Name — Namensabgleich soll deshalb zusätzlich auf
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
- [x] **"Klassenlehrer"-Feature — Datenschicht (August 2026):** Zwei WebUntis-Berichte liefern, was
für einen Klassenlehrer-Überblick fehlte. `WebUntisIntegrationService.GetForeignClassRegisterEventsAsync`
ist das Spiegelbild von `GetOwnClassRegisterEventsAsync`: derselbe "-alle-"-Rohbericht
(`ClassregEventPerStudent`), aber Einträge anderer Lehrkräfte (`Benutzer` ≠ eigener Login) statt
der eigenen, auf eine Klasse eingeschränkt — kein neuer Bericht nötig.
`GetClassAbsencesAsync`/`reports.do?name=AbsencePerStudent&klasseOrStudentgroupId=KL<id>&studentId=-1`
(per Netzwerk-Mitschnitt gefunden) liefert Fehlzeiten der ganzen Klasse über alle Fächer, nicht nur
eigenen Unterricht. `klasseOrStudentgroupId` ist `"KL"` + die `getKlassen`-interne Klassen-ID
(dasselbe Element-Kürzel-Schema wie `kl`/`te`/`su`/`ro` bei anderen Berichten) —
`WebUntisIntegrationService.ResolveClassIdAsync` löst sie pro Aufruf über
`getSchoolyears`+`getKlassen` und den Klassennamen auf. Das CSV-Format ist **nicht** identisch mit
`AbsencePerLesson` (ursprüngliche Vermutung anhand echter Daten widerlegt) — eigenes Spaltenschema,
eine Zeile pro Fehlstunde, eigener Parser `WebUntisClassAbsenceParser`/Modell
`UntisClassAbsenceEntry`. Anders als `AbsencePerLesson` liefert `Status` hier einen Klartext-Wert
direkt (im Sample nur `"nicht entsch."` beobachtet, andere Werte nicht bestätigt), es braucht also
keine Status-Heuristik. Der Header `Text` kommt zweimal vor (eine immer-leere Spalte, dann die
eigentliche Tagesnotiz) — der Dictionary-per-Header-Parse-Stil überschreibt das zufällig richtig,
im Parser kommentiert statt stillschweigend vorausgesetzt. `Fehltage` (0/1, `CountsAsFullDay`) ist
laut Nutzer-Angabe der auf eine ganze Zahl abgerundete Fehlzeit-Tagesanteil: bleibt 0, bis auch die
letzte Unterrichtsstunde des Tages als versäumt gilt, erst deren Zeile trägt dann den Wert 1.
`ClassAbsenceDaySummaryRow.GroupByStudentAndDay` fasst die Fehlstunden pro Schüler*in/Tag zusammen
(Fächer/Status/Gründe als distinkte Listen) statt eine Zeile pro Fehlstunde zu zeigen.
- [x] **"Klassenlehrer"-Feature — Neuausrichtung (August 2026):** Die erste Umsetzung hing das
Feature an ein `LearningGroup.IsClassTeacher`-Flag mit Tab in der Gruppendetailansicht.
Nutzer-Feedback: konzeptionell falsch — Klassenlehrer ist man für eine ganze Klasse, unabhängig
vom eigenen Unterricht (die Checkbox saß z. B. auch an "SOL"/"Klassenrat"), und die Übersicht wird
gerade an Tagen gebraucht, an denen man selbst keinen Unterricht mit der Klasse hat.
`LearningGroup.IsClassTeacher` wieder entfernt. Stattdessen:
- **Klassenauswahl in den Einstellungen** statt an einer Gruppe:
`WebUntisSettingsService.HomeroomClassUntisId`/`HomeroomClassName` (unverschlüsselt, gleiches
Muster wie `TeacherUntisId`), Auswahl im "Untis-Einbettung"-Tab über den bestehenden
`WebUntisClassSelectionViewModel`/`WebUntisClassSelectionDialog` (bisher nur für den
WebUntis-Klassenimport in der Kursansicht genutzt).
- **Eigener Top-Level-Bereich** "Klassenlehrer" in der Seitenleiste (`NavItem.ClassTeacher`,
`ClassTeacherOverviewViewModel`) statt Tab in einer `LearningGroup`-Detailansicht — sichtbar und
ladbar unabhängig davon, ob heute eigener Unterricht mit der Klasse stattfindet. Zeigt bei
fehlender Klassenauswahl einen Hinweis mit Sprung in die Einstellungen.
- **Zwei Ebenen** (Nutzer-Feedback: "auf die Schnelle" statt Rohdaten-Listen als Erstes):
"Übersicht" — kompaktes Roster (`ClassTeacherRosterRow.Build`, Zuordnung primär über `ExternKey`
für die Fehlzeiten, Namens-Fallback für den Klassenbuch-Bericht ohne `ExternKey`) mit
Ampel-Symbol "keine gemeldete Fehlzeit heute laut WebUntis" (unabhängig von eigener
Anwesenheitserfassung) und Badge bei Klassenbucheinträgen anderer Lehrkräfte in den letzten 7
Tagen; "Details" — die bisherigen Rohdaten-Listen (jetzt `ClassTeacherDetailsViewModel`/
`ClassTeacherDetailsView`, aus `ViewModels/Groups`/`Views/Groups` nach
`ViewModels/ClassTeacher`/`Views/ClassTeacher` verschoben, `Initialize(string className)` statt
`Initialize(Guid groupId)`), aus der Übersicht per Klick auf eine Zeile gefiltert auf
eine/n Schüler*in anspringbar (`ClassTeacherDetailsViewModel.StudentFilter`).
**Zurückgestellte Ideen:** Verknüpfung Roster-Zeile ↔ lokaler `Student`/Navigation zum
Schülerdetail; ein Detail-Drill-down pro Schüler*in (Heatmap gefehlter Einzelstunden,
Verspätung/vorzeitiges Verlassen je Woche); eine KI-gestützte Zusammenfassung/Bewertung der
Einträge (pro Schüler*in oder gesamt) mit Namens-Anonymisierung/Synonymen vor dem Versand.
**Echter Fehler gefunden und behoben:** Die Übersicht rief für das Roster
`WebUntisIntegrationService.GetStudentsAsync` auf (den bestehenden, schon länger genutzten
Schülerlisten-Bericht) — `WebUntisStudentReportParser` behandelte die Spalte `externKey` bisher als
Pflichtfeld (`RequiredInt`) und ließ den kompletten Abruf mit "Ungültige Zahl in Spalte
\"externKey\"" scheitern, sobald auch nur eine/einer der Schüler*innen der Klasse keinen
gepflegten externen Schlüssel hat (in der Praxis vorkommend, z. B. frisch angelegt). `externKey`
ist jetzt optional (`UntisStudent.ExternKey`/`UntisStudentDto.ExternKey` → `int?`) — betraf auch
den bestehenden WebUntis-Klassenimport in der Kursansicht, der das aber schon immer über
`FirstNotEmpty` beim Weiterimport toleriert hatte und deshalb nicht auffiel.
- [x] **"Klassenlehrer"-Feature — lokaler Cache für Fehlzeiten & Klassenbucheinträge (August 2026):**
Nutzer-Feedback: die Berichtszeilen sind starr genug für ein eigenes Datenmodell, Warnungen
sollen sofort da sein statt bei jedem Öffnen neu abgerufen zu werden, und — wichtigstes Motiv —
derselbe Bericht darf nicht mehrfach pro Stunde abgerufen werden, nur weil die Ansicht mehrfach
geöffnet wird (Sorge, bei WebUntis aufzufallen). Neue Modelle `UntisAbsenceCacheEntry`/
`UntisClassRegisterCacheEntry` (1:1-Abbildung der bestehenden DTOs, `Date` als `int`/yyyyMMdd wie
dort, nicht `DateOnly`, um die LiteDB-`DateTime`-Kind-Tücke zu vermeiden) plus
`UntisCacheFetchState` (ein Datensatz je Klasse+Berichtsart: wann das "heiße" Fenster zuletzt
aufgefrischt wurde, wie weit die "kalte" Historie lückenlos zurückreicht).
**Bewusst nicht synchronisiert** — `UntisAbsenceCacheRepository`/`UntisClassRegisterCacheRepository`/
`UntisCacheFetchStateRepository` rufen `db.OnChange` nie auf (Sync ist opt-in pro Repository,
siehe `UntisSnapshotRepository`/`AnnualPlanEventRepository` für dasselbe Muster) — jedes Gerät
ruft WebUntis ohnehin selbst ab, und die Zeilenzahl wächst übers Schuljahr absichtlich an
(gewollte Historie, kein Pruning).
Neuer Service `UntisReportCacheService`: festes heißes Fenster `[heute14, heute]`
(unabhängig vom angefragten Bereich), höchstens stündlich automatisch aufgefrischt (dort kann
sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt"); alles davor gilt als endgültig
und wird, einmal abgerufen, dauerhaft aus dem Cache bedient. Die eigentliche Entscheidungslogik
(`UntisReportCacheService.Plan`) ist bewusst als reine, ohne Repositories/HTTP testbare Funktion
ausgelagert. `ClassTeacherOverviewViewModel`/`ClassTeacherDetailsViewModel` nutzen den Cache-Service
statt direkt `WebUntisIntegrationService`; das "Laden" in den Details respektiert die
Stunden-Sperre, ein zusätzlicher Button "Jetzt wirklich neu abrufen" umgeht sie bewusst
(`forceRefresh: true`) für den Fall, dass man sicher weiß, dass sich etwas geändert hat.
**Zurückgestellt:** Muster-Erkennung/Heuristiken über die gecachten Daten (vom Nutzer als Motiv
für den Cache genannt) — erst sinnvoll, wenn genug Historie im Cache liegt.
- [x] **Zwei echte Fehler gefunden und behoben (August 2026):**
- Gefilterte Listen im Klassenlehrer-Bereich blieben nach Klick auf eine Roster-Zeile leer,
obwohl Einträge vorhanden sein sollten. Ursache: WebUntis liefert Schülernamen je nach Bericht
in unterschiedlicher Reihenfolge — der Schülerreport baut "Vorname Nachname"
(`UntisStudentDto.DisplayName`), Fehlzeiten-/Klassenbuchberichte liefern typischerweise
"Nachname Vorname". Der exakte String-Vergleich beim Filtern (`ClassTeacherDetailsViewModel`)
schlug deshalb praktisch immer fehl — derselbe Fehler steckte unbemerkt auch schon im
Roster-Namensabgleich der Übersicht (`ClassTeacherRosterRow.Build`, Fallback wenn kein
`ExternKey` vorliegt), dort blieben Ampel-Symbol und Klassenbuch-Badge also vermutlich fast
immer falsch. Neuer reihenfolge-unabhängiger Namensvergleich
(`ClassTeacher/UntisNameMatching.NamesMatch`: Namen in Wörter zerlegt, als sortierte Menge
verglichen), an beiden Stellen eingesetzt.
- Nach dem ersten Klick auf eine Roster-Zeile (Wechsel in die Details) blieb ein zweiter Klick
wirkungslos, sobald man zwischendurch manuell zur Übersicht zurückgeklickt hatte — man blieb
auf der Übersicht hängen. Ursache: `TabbedPage.SelectedIndex="{Binding ActiveTabIndex}"` ohne
`Mode=TwoWay` ist effektiv nur VM→View gebunden; ein manueller Tab-Klick des Nutzers schreibt
`ActiveTabIndex` im ViewModel nicht zurück auf 0, wodurch ein erneutes `ActiveTabIndex = 1` aus
`ShowDetailsForStudent` denselben Wert setzt und mangels Änderung kein `PropertyChanged`
auslöst. Fix: `Mode=TwoWay` explizit ergänzt (`ClassTeacherOverviewView.axaml`). Dasselbe
Bindungsmuster ohne `Mode=TwoWay` existiert auch in `GroupDetailView.axaml`/`WorkloadView.axaml`
(dortige `OnActiveTabIndexChanged`-Refreshs beim Zurückkehren zu einem Tab greifen bei einem
reinen Nutzerklick möglicherweise ebenfalls nicht) — als Hintergrundaufgabe zur Prüfung
ausgelagert, nicht Teil dieser Änderung.
- Die Klassenlehrer-Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten und
Klassenbuch schon gecacht waren (Nutzer-Feedback: "fühlt sich an wie ein Live-Pull mit
CSV-Parsing"). Ursache: genau das passierte tatsächlich — der Schülerreport
(`GetStudentsAsync`, die Namen fürs Roster) lief komplett am Cache vorbei live gegen WebUntis,
bei jedem einzelnen Öffnen. Neues `UntisStudentRosterCacheEntry`/
`UntisStudentRosterCacheRepository` (gleiches "kein db.OnChange"-Prinzip wie die anderen beiden
Caches) plus `UntisReportCacheService.GetStudentRosterAsync` — anders als Fehlzeiten/Klassenbuch
ohne heißes/kaltes Fenster, da eine Klassenliste keine Historie hat, sondern nur "gilt der
letzte Abruf noch als frisch" (dieselbe Stundenschwelle). `ClassTeacherRosterRow.Build` nimmt
jetzt `UntisStudentRosterCacheEntry` statt des vollen `UntisStudentDto` entgegen (nur
`ExternKey`/`DisplayName` wurden je gebraucht).
- [x] **"Klassenlehrer"-Feature — Trend & Fehlquote (August 2026):** Zwei Nutzer-Feedback-Punkte
auf einmal umgesetzt, weil sie sich denselben Datenabruf teilen.
- **Trend als gestapelter Balken statt drei unabhängig skalierten:** `ClassTeacherTrendDay`
lieferte bisher `AlertBarWidth`/`UnexcusedBarWidth`/`LateBarWidth`, jede für sich auf dasselbe
Maximum normiert — das suggerierte drei unabhängige Größen, obwohl Unentschuldigt und
Verspätet Teilmengen von Alerts sind. Jetzt drei überschneidungsfreie Segmente (Unentschuldigt
hat Vorrang vor Verspätet, gleiche Priorisierung wie `ClassTeacherRosterRow.AttentionRank`);
in XAML als `Border ClipToBounds="True"` mit `CornerRadius` um eine horizontale `StackPanel`
aus drei Segmenten gerendert, damit nur die äußeren Kanten abgerundet sind. Wie die
Segmentbreiten selbst berechnet werden, hat sich im Nachtrag unten noch einmal geändert —
siehe dort.
- **Trend über die letzten 7 Schultage statt Kalendertage:** an zwei von sieben Tagen war der
Balken bislang systematisch leer, weil `start.AddDays(offset)` Wochenenden mitzählte. Neues
`ClassTeacherOverviewViewModel.LastSchoolDays` überspringt Samstag/Sonntag — bewusst ohne
Ferienkalender, die App kennt keine Schulferien (gilt auch für die Fehlquote unten).
- **Kumulierte Fehlquote seit Schuljahresbeginn je Schüler*in:** der bisherige Heute-Snapshot
sagt für Zeugnis/Attestpflicht wenig aus. `ClassTeacherRosterRow` bekommt
`YearAbsenceDayCount`/`YearUnexcusedDayCount`/`YearAbsenceRatePercent`
(`SchoolDaysElapsed` als Nenner, wieder nur Werktage ohne Ferienkalender) und zeigt sie als
dezente zweite Zeile unter dem Tagesstatus, Details im Tooltip. Dafür holt `Load()` die
Fehlzeiten jetzt für `[Schuljahresbeginn, heute]` statt nur der letzten 7 Tage — teuer ist das
nur beim ersten Öffnen pro Schuljahr, `UntisReportCacheService` cached den "kalten" Bereich
danach dauerhaft (siehe Cache-Eintrag oben). Klassenbuch-Abruf bleibt bewusst bei 7 Tagen
(für "Eintrag diese Woche"/`RecentClassRegisterCount` reicht das, ein Jahr an fremden
Klassenbucheinträgen zu cachen wäre unnötiges Datenvolumen).
Neue `SchoolYearService`-Abhängigkeit in `ClassTeacherOverviewViewModel` (Konstruktor-Injection,
Service war bereits als Singleton registriert).
- [x] **"Klassenlehrer"-Feature — restliche Quick-Wins (August 2026):** Die vier verbliebenen
Punkte aus derselben Vorschlagsliste wie oben.
- **Balkenbreiten responsiv statt fester Pixelkonstante:** `BuildTrend` rechnete bisher mit
einer im ViewModel hart codierten Pixelbreite (`112d`), die mit der tatsächlichen Breite der
Seitenspalte in XAML synchron gehalten werden musste. Erster Versuch — `Grid.ColumnDefinitions`
per `{Binding}` auf einen "N*,N*,…"-String setzen, wie es die eingebaute XAML-Stringsyntax
erlaubt — scheiterte an `AVLN3000` ("Unable to find suitable setter"): `ColumnDefinitions` hat
bei kompilierten Bindings (`x:DataType`) keinen normalen Property-Setter, auch nicht über einen
`IValueConverter`. Stattdessen liefert `ClassTeacherTrendDay`/`ClassTeacherOverviewViewModel`
jetzt Anteile 0…1 (`UnexcusedFraction`/`LateExcusedFraction`/`ExcusedFraction` bzw.
`Present-/Late-/ExcusedAbsence-/UnexcusedAbsenceFraction`), und die View multipliziert sie per
`MultiBinding` mit der tatsächlich gerenderten Breite einer benannten, unsichtbaren
Track-`Border` (`ElementName=…, Path=Bounds.Width`) — neuer
[FractionWidthConverter.cs](LehrerApp.Desktop/Converters/FractionWidthConverter.cs)
(`IMultiValueConverter`). Bleibt dadurch korrekt, unabhängig davon, wie breit die Seitenspalte
gerade ist, ohne dass View und ViewModel einen Pixelwert teilen müssten.
- **Tagesüberblick als gestapelter 100-%-Balken statt vier Einzelzeilen:** neue `Grid` mit
demselben Track-`Border`/`MultiBinding`/`FractionWidthConverter`-Muster wie beim Trend, direkt
über der (weiterhin vorhandenen, jetzt kompakteren) Legende. Die vier Kategorien sind exklusiv
und summieren sich exakt zu `StudentCount`, brauchen also anders als beim Trend keine
Rest-Kategorie für den Sonderfall "unter dem Maximum".
- **`AbsenceTooltip` und `TodayUnexcusedPercent` gebunden:** beide wurden berechnet, aber nie
angezeigt. `AbsenceTooltip` hängt jetzt als `ToolTip.Tip` am Avatar-Kreis jeder Roster-Zeile;
`TodayUnexcusedPercent` steckt in einem neuen `TodayUnexcusedSummaryLabel` unter der
"unentschuldigt"-Kennzahlkarte oben. Die vier vormals ungenutzten Prozent-Properties
(`Present-/Late-/ExcusedAbsence-/UnexcusedAbsencePercent`) sind jetzt ebenfalls in
Summary-Labels der neuen Tagesüberblick-Legende eingebettet, statt einer eigenen
rechtsbündigen Spalte wie zuvor.
- **Symbol zusätzlich zur Farbe (Barrierefreiheit):** der Status einer Roster-Zeile hing zuvor
an der Farbe der linken 4-px-Leiste plus reinem Text ohne visuellen Vorrang. Neues
`ClassTeacherRosterRow.StatusTextWithGlyph` stellt "✕ " (Gefahr) bzw. "△ " (Warnung) voran —
dieselben Symbole wie in den Kennzahlkarten oben, damit sich die Bedeutung nicht neu erlernen
lässt. `StatusText` selbst bleibt unverändert (bestehende Tests/Bindings), das Symbol ist eine
zusätzliche, separat getestete Property.
- [x] **"Klassenlehrer"-Feature — Warnung/Gefahr farblich kaum unterscheidbar (August 2026,
Nutzer-Feedback):** Amber (`#F59E0B`) und Korallrot (`#FF5A67`) lagen zu nah beieinander,
besonders bei Rot-Grün-Sehschwäche. Statt erneut freihändig zu wählen, per
`scripts/validate_palette.js` der dataviz-Skill gegen die tatsächlichen Flächen
(`AppTrackBackgroundBrush`/`AppCardBackgroundBrush`) und in der Reihenfolge geprüft, in der die
Segmente in den gestapelten Balken wirklich aneinanderstoßen (nicht `--pairs all`, das prüft
auch Paare, die im Chart nie benachbart sind). Befund bestätigte das Nutzer-Feedback:
Warnung/Gefahr lagen im Hellmodus bei ΔE 2,5 (deutan) und sogar nur 11,4 bei normaler
Farbwahrnehmung — unter der Zielschwelle von 15, also unabhängig von einer Sehschwäche schwer
zu unterscheiden. Neue Werte in
[SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) —
`good=#008300`, `info=#2A78D6`/`#3987E5`, `warning=#EDA100`/`#C98500`,
`critical=#D03B3B` (hell/dunkel) — dieselben Stufen wie die validierte Kategorial-/Statuspalette
der dataviz-Skill, bewusst nicht wieder frei gewählt. Jetzt ΔE 10,224,4 in allen geprüften
Paarungen/Themes. Der Tagesüberblick-Balken musste dafür zusätzlich die Segmentreihenfolge von
Ok→Warnung→Info→Gefahr auf **Ok→Info→Warnung→Gefahr** tauschen (Grün und das validierte
Dunkel-Gelb liegen unter Protanopie mit ΔE 3,0 selbst zu nah beieinander) — passt nebenbei
besser zur tatsächlichen Dringlichkeitsreihenfolge (`ClassTeacherRosterRow.AttentionRank`:
Gefahr > Warnung > Info > Ok). Da die Farben über die geteilten `AppStatus*Brush`-Ressourcen
laufen, wirkt die Korrektur automatisch überall im Klassenlehrer-Bereich (Kennzahlkarten,
Roster-Status, Chips in Fehlzeiten-/Klassenbuchansicht), nicht nur in den beiden Charts.
- [x] **"Klassenlehrer"-Feature — vier Feature-Ideen umgesetzt (August 2026):** Aus derselben
Vorschlagsliste wie die Quick-Wins oben, auf Nutzerwunsch alle außer dem Elterngesprächs-PDF
(dafür fehlt noch ein geschärftes Konzept).
- **Offene Entschuldigungen mit Frist:** neue Karte in der Seitenspalte, listet aktuell
unentschuldigte Fehltage über den gesamten geladenen Zeitraum (nicht nur heute), sortiert nach
Tagen offen absteigend, auf 6 Zeilen gedeckelt ("+N weitere"). `ClassTeacherOpenExcuseRow`
markiert Einträge über `DeadlineDays = 3` als überfällig (Richtwert für die häufige
"innerhalb von drei Tagen"-Regelung, z.B. § 43 Abs. 2 SchulG NRW — keine rechtlich geprüfte,
schulspezifische Frist, bei Bedarf anpassen). Läuft über die ohnehin für die Jahresfehlquote
geladenen Daten, kein zusätzlicher Abruf.
- **Aggregierte Klassenbuch-Kategorien:** neue Chip-Reihe über der Tabelle im Klassenbuch-Tab
("Hausaufgaben fehlen: 12× — 5× Ben Schmidt, 3× Ada Müller" statt nur einer Gesamtzahl).
`ClassTeacherCategoryAggregateRow.Build` gruppiert die bereits geladenen `Entries` nach
Kategorie, rein clientseitig.
- **Erweiterte Mustererkennung — Wochentags-Häufung:** dritte Regel neben den bestehenden
(≥2 unentschuldigt/verspätet in 7 Tagen): wenn ≥60 % der Fehltage eines/einer Schülers/-in
seit Schuljahresbeginn (Mindeststichprobe 3) auf denselben Wochentag fallen, ein Hinweis
("3 von 4 Fehltagen an einem Montag"). Wer schon eine Notiz aus der bestehenden Regel hat,
wird ausgelassen, damit niemand doppelt auftaucht. Kernlogik als
`ClassTeacherOverviewViewModel.DetectWeekdayPatterns` bewusst statisch/ohne ViewModel-Zustand
(gleiches Muster wie `ClassTeacherRosterRow.Build`) und dadurch direkt testbar.
- **Aufgabe/Wiedervorlage direkt aus der Zeile:** neuer kleiner "+"-Button neben jeder
Roster-Zeile und jeder Zeile in "Offene Entschuldigungen" legt sofort eine `WorkTask`
(`Kind = Reminder`, wie 6.1.1, bewusst ohne `GroupId` — Klassenlehrer ist man für die ganze
Klasse) mit vorbefülltem Titel "Eltern kontaktieren {Name}" an, ohne Dialog. **Wichtiger
XAML-Fallstrick dabei:** ein `ContextMenu` auf dem Zeilen-`Button` war der naheliegendere
erste Ansatz, scheitert aber an `$parent`-Vorfahren-Bindings — das `ContextMenu`-Popup hängt
nicht im normalen visuellen Baum unter dem `ItemsControl`, `$parent[ItemsControl]` findet das
ViewModel dort nicht. Stattdessen zwei Geschwister-`Button` in einem `Grid` (der bestehende
Zeilen-Button bleibt unverändert, der neue "+"-Button daneben hat ganz regulären
Vorfahren-Zugriff).
- **Verknüpfung mit Noten/Mitarbeit:** bewusst als dritte Regel im "Muster erkannt"-Panel statt
als Dauerzusatz in jeder Zeile (Overload vermeiden) — nur bei bereits spürbar erhöhter
Jahresfehlquote (≥15 %) UND eindeutig fallendem Mitarbeit-Trend über die letzten bis zu 8
Bewertungen (gleiche einfache Erste-Hälfte/Zweite-Hälfte-Heuristik wie
`ParticipationGradeRow.ComputeTrend`). Namensabgleich WebUntis→internem `Student` über
`ClassTeacherOverviewViewModel.MatchStudent` (öffentlich statt `internal`, wie der Rest der
Codebasis es hält, siehe `UntisSyncService`-Kommentar — kein `InternalsVisibleTo`
konfiguriert). **Wichtiger Fallstrick:** `Student.FullName` liefert "Nachname, Vorname" mit
Komma — ein Abgleich darüber hätte den leerzeichenbasierten Wortvergleich in
`UntisNameMatching` verfälscht ("Müller," bliebe eigenes Wort). `MatchStudent` baut den
Vergleichsnamen deshalb explizit aus `FirstName`/`LastName` zusammen, nicht aus `FullName`.
Mitarbeit-Rohwerte werden bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in
`ParticipationGradeViewModels`) — die Aspekt-Konfiguration ist je Lerngruppe, der
Klassenlehrer-Bereich kennt aber keine einzelne Gruppe; ein einfacher Rohwert-Durchschnitt ist
hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
Neue Abhängigkeiten in `ClassTeacherOverviewViewModel`: `IWorkTaskRepository`,
`IStudentRepository`, `IParticipationRepository`, `IParticipationSessionRepository` (alle schon
als Singleton registriert, nur Konstruktor-Injection ergänzt).
**Bewusst zurückgestellt:** Elterngesprächs-Blatt als PDF — Konzept noch nicht geschärft genug.
### 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.
Vor-/Zurück-Navigation zwischen Kalenderwochen seit der vierten Iteration).
**Nicht enthalten:** ein eigenständiger Kalendermonat-Überblick — bei Bedarf später
ergänzbar.
- [x] **4.4.2** Sprung von einer Stunde direkt in die Lerngruppe — siehe Nachtrag zu 4.3
("Heute"-Tab, Klick navigiert zur Planung der Gruppe statt direkt in die
Mitarbeitserfassung, da das für den Vorbereitungs-Kontext des Tages passender ist).
- [x] **4.4.3** Anzeige anstehender Klausurtermine im Kalender — neue Box "ANSTEHENDE KLAUSUREN"
im "Heute"-Tab, gruppenübergreifend, nächste 21 Tage (max. 10 Einträge), unabhängig vom
`WeekOffset` des Wochenrasters. `IExamRepository` brauchte dafür ein `GetAll()` (bisher nur
`GetByGroup`). **Abgabefristen bewusst nicht enthalten:** es gibt im Datenmodell noch kein
Frist-/Deadline-Konzept (nur `Exam.Date`) — das bräuchte ein neues Modell, siehe auch 9.8
("ggf. einem neuen Termine-Modell"). Kein Aufschub aus Unklarheit, sondern weil das über den
angefragten Umfang hinausginge.
### 4.5 Vernetzung Stundenplan ↔ Unterrichtsplanung (zurückgestellt, 4.5.14.5.3 auf Nutzerwunsch vorgezogen)
Nutzer-Feedback nach der 4.3-Iterationen: Stundenplan (4.3, wiederkehrendes Muster) und
Unterrichtsplanung (4.1/4.2, `Unit`/`Lesson` je Gruppe) laufen bisher zu getrennt nebeneinander.
Ausdrücklich als Idee für später festgehalten, **nicht** jetzt umsetzen — mit Ausnahme von
4.5.14.5.3, die auf ausdrücklichen Nutzerwunsch vorgezogen wurden (Rest von 4.5 bleibt
zurückgestellt):
- [x] **4.5.1** Terminvorschlag beim Anlegen einer `Lesson`: Tag/Stunde anhand des Stundenplans
vorschlagen, an dem die betroffene Gruppe laut `TimetableSlot` tatsächlich Unterricht hat,
statt Datum/Stunde komplett frei einzutragen. **Vorgefunden (Nebeneffekt der
Doppelstunden-Erkennung, 4.2.2-Nachtrag):** `LessonDialogViewModel` schlug für eine neue
Stunde bereits den nächsten passenden Wochentag vor (`SuggestNextLessonDate`) — nötig, damit
die Zeitbudget-Doppelstunden-Erkennung nicht auf "heute zufällig kein Unterrichtstag" ins
Leere läuft. Die Stundennummer selbst blieb dabei aber leer. **Ergänzt:** `SuggestNextLesson`
schlägt jetzt zusätzlich die früheste `TimetableSlot`-Periode dieses Wochentags vor (bei
einer Doppelstunde also die erste Periode, dieselbe Ankerkonvention wie bei 4.2.5) — über
den bereits bestehenden `OnLessonNumberChanged`-Hook wird dadurch automatisch auch der
Stundenbeginn aus dem Stundenraster übernommen, ganz ohne zusätzlichen Code.
- [x] **4.5.2** Vom Stundenplan (Wochenraster oder Tagesliste) direkt in einen
"Viewer" der zugehörigen `Lesson` springen können — und von dort auch eine neue `Lesson`
anlegen können.
**Teilweise umgesetzt:** Der erste Teil ("direkt in den Viewer springen") ist jetzt fertig,
sofern für den angeklickten Slot schon eine `Lesson` existiert — genau der unstrittige Teil
ohne die offene Unit-Frage. Sowohl die "Heute"-Tagesliste als auch das schreibgeschützte
Wochenraster im Stundenplan-Tab "Heute" (bewusst NICHT das Bearbeiten-Raster aus 4.3.2/4.3.3
— dessen Klick öffnet weiterhin `TimetableSlotDialog` zur Slot-Zuweisung, das ist ein anderer,
etablierter Zweck) öffnen bei vorhandener Lesson jetzt direkt `LessonViewerDialog` statt nur
grob zum Planung-Tab der Gruppe zu springen (`TimetableViewModel.OnOpenLessonViewer`,
`TodayLessonItem`/`WeekCellItem.Lesson`). **Jetzt vollständig umgesetzt:** Ohne vorhandene
Lesson startet der Klick die Direktanlage mit exakt vorbelegtem Datum und exakter Periode. Ein
vorgeschalteter Dialog sortiert laufende Einheiten zuerst, lässt aber auch direkt eine neue
Einheit anlegen; damit ist die frühere offene Unit-Frage ohne stillschweigende Zuordnung gelöst.
Neue Direktanlagen starten als `Entwurf`.
- [x] **4.5.3** Aus diesem Lesson-Viewer heraus weiter verzweigen können: in die Zeugnisnote/
Bewertung der Gruppe, und in die Schnelldialoge für Mitarbeit sowie Anwesenheit/Hausaufgaben.
**Umsetzung:** Zwei neue Buttons im Footer von `LessonViewerDialog` ("Zur Mitarbeit"/
"Zu den Noten", `LessonViewerViewModel.NavigateToParticipationCommand`/`NavigateToGradesCommand`)
schließen den Viewer und springen in den jeweiligen Tab der Gruppe (3 Mitarbeit, 5 Noten) —
dieselbe `MainWindowViewModel`-Singleton-Navigation wie überall sonst im Code-Behind. Bewusst
**kein** automatisches Öffnen der Schnelldialoge ("Schnell Mitarbeit"/"Anw.-HA") selbst: die
brauchen eine ausgewählte Mitarbeitssitzung, die für das Datum dieser Lesson noch gar nicht
existieren muss — automatisch eine anzulegen hätte dieselbe Geistereintrag-Falle wie der
kürzlich behobene Sitzplan-Bug (14.12/7.1.5-Nachtrag) reproduziert. Im Mitarbeit-Tab
angekommen ist "Sitzung erzeugen" (3.3.1, aus der Planung) bzw. der bestehende
" Sitzung"-Button ohnehin nur einen Klick entfernt.
- [x] **4.5.4** Badges am Stundenplan, die anzeigen, ob eine Hausaufgabe aus der letzten Stunde
noch nicht kontrolliert ist.
**Umsetzung:** `Lesson` bekam zwei neue Felder, `HomeworkChecked`/`HomeworkCheckDismissed`
(LiteDB füllt sie für bestehende Datensätze automatisch mit `false`, keine Migration nötig).
`TimetableViewModel.HasUnhandledHomework(groupId, date)` sucht die letzte vor `date`
liegende Lesson dieser Gruppe (`ILessonRepository.GetByGroupAndRange`, 120-Tage-Fenster
wegen möglicher Ferienlücken) und meldet `true`, wenn diese eine nicht-leere `Homework`
hat und weder `HomeworkChecked` noch `HomeworkCheckDismissed` gesetzt ist. Bewusst nur die
unmittelbar vorherige Lesson, nicht die gesamte Historie — sobald eine neuere Stunde
stattfindet, ist eine noch ältere offene Hausaufgabe nicht mehr das, worauf sich "letzte
Stunde" bezieht. 📓-Badge dafür sowohl in der Tagesliste als auch im Wochenraster des
"Heute"-Tabs (`TodayLessonItem.HasUnhandledHomework`/`WeekCellItem.HasUnhandledHomework`).
**Abweichung vom ursprünglichen Vorschlag** ("Klick-Toggle direkt am Badge"): jede
Wochenraster-Zelle ist bereits ein klickbarer `Button` (Navigation zur Lerngruppe) —
ein zweites klickbares Element im selben Badge hätte in Avalonia einen verschachtelten
Button-in-Button ergeben, mit unsicherem Klick-Routing. Stattdessen leben die beiden
Checkboxen "Kontrolliert"/"Nicht kontrollieren" (gegenseitig exklusiv,
`LessonDialogViewModel.HomeworkChecked`/`HomeworkCheckDismissed`) direkt im Verlaufsplan-
Editor neben dem Hausaufgabenfeld — genau dort, wo `Homework` ohnehin schon gepflegt wird.
Klick auf die Stunde im Stundenplan navigiert wie gehabt zur Lerngruppe/Planung, von dort
ist die betroffene (vorherige) Stunde ein Klick entfernt.
- [x] **4.5.5** Stunden aus dieser Ansicht heraus verschieben können, wenn kurzfristig etwas
dazwischenkommt. **Umsetzung:** Das Kachelmenü bietet Datum und Zielperiode an; belegte
Zieltermine werden abgewiesen. Optional rücken alle späteren, noch nicht durchgeführten
Stunden der Einheit um dasselbe Datumsdelta nach.
- [x] **4.5.6** Bei Doppelstunden (90-Minuten-Planung laut Verlaufsplan, 4.2.2) den Inhalt
sinnvoll auf die beiden Kacheln/Perioden aufteilen, damit z.B. gezielt nur die zweite Stunde
eines Blocks verschoben werden kann, ohne den ganzen Block anzufassen. **Umsetzung:** Der
Stundenplan erkennt eine Doppelstunde aus aufeinanderfolgenden Slots, Stundenraster und
Phasendauer. Beim Verschieben der zweiten Kachel ist "nur zweiten Teil" vorausgewählt; die
Phasen werden exakt an der Dauer der ersten Periode geteilt (auch mitten in einer Phase),
Hausaufgabe und Reflexion wandern in die neu erzeugte Fortsetzungsstunde.
**Architekturentscheidung (Nachtrag, Konzeptgespräch):** Diskutiert wurde, ob eine eigene
Multiplattform-App für die Einheiten-/Stundenplanung sinnvoll ist — analog zum separaten
Tafelbilder-Vorhaben (eigene App, nur eine Schnittstelle zur LehrerApp, weil dort die
Interaktionsform — Zeichnen/Präsentationsmodus — grundsätzlich anders ist als CRUD). Für die
Unterrichtsplanung gilt das **nicht**: Planung findet zu 99,9 % am Desktop-PC oder MacBook statt
(Avalonia läuft dort bereits nativ), es besteht also kein Plattformzwang für eine separate App.
Entscheidung: **kein** eigenes Domainmodell/eigene App für Einheiten-/Stundenplanung — die
folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
- [ ] **4.5.7** Graph über Aktivitätsphasen und Anspruchsniveau im zeitlichen Verlauf einer
Lesson/Einheit (baut auf `LessonPhaseStep`/`Niveau` auf), um die Stundenverteilung besser
einschätzen zu können.
- [ ] **4.5.8** Kompetenzen je Aufgabe/Phase verknüpfen — nutzt den bestehenden
`CompetencyDomain`/`CompetencyItem`-Katalog (siehe Kompetenzen-Tab in den Einstellungen),
bisher nur für Klausuraufgaben (`ExamTask.CompetencyCodes`) verknüpft, nicht für
Verlaufsplan-Phasen.
- [x] **4.5.9** KI-gestützte Planungsunterstützung über eine Schnittstelle zu einer LLM-API, um
Einheiten/Stunden mit Hilfe vorzuschlagen und weiterzuentwickeln. Bedarf eines abgesicherten
Zwischenelements auf dem eigenen Server (Ablösung/Verbesserung des bisherigen
PHP-Zwischenelements für Elternbriefe) mit interner Abrechnung/Nutzungskontrolle, damit der
API-Schlüssel nicht im Client landet.
**Umsetzung:** Neuer Button "🤖 KI-Unterstützung" im Planungs-Tab (nur bei ausgewählter
Einheit und aktivierter KI-Unterstützung) öffnet `AiAssistDialog`: Freitext-Anweisung an die
KI, Antwort wird als Liste vorgeschlagener Stunden mit Checkbox zur Übernahme angezeigt
(nichts wird automatisch übernommen). Export/Import-Schema als eigene DTOs in
`LehrerApp.Core/AiPlanning/AiPlanningDtos.cs`, bewusst getrennt von den internen
Domänenmodellen — ein Wire-Vertrag mit einem externen System muss unabhängig von internen
Refactors abwärtskompatibel bleiben. Zentrale Absicherung in
`AiPlanningService.ApplyResponse`: eine von der KI zurückgegebene Lesson-Id, die zu keiner
tatsächlich zur Einheit gehörenden Lesson passt, wird NIE als Update interpretiert (sonst
könnte eine halluzinierte Id im schlimmsten Fall eine fremde Lesson überschreiben), sondern
immer als neue Lesson mit frischer Id behandelt — mit dediziertem Test abgesichert.
Neue Einstellungen-Tab "KI-Unterstützung": Aktivieren-Schalter, Benutzername/Passwort →
Anmelden tauscht das Passwort gegen ein Bearer-Token (nie das Passwort selbst persistiert).
Das Token liegt lokal AES-256-verschlüsselt über das bestehende `SyncCrypto` (gleiches
Verfahren wie beim Sync-Schlüssel) — besser als die bisherige Klartext-Ablage der
Sync-Server-URL, aber mangels Betriebssystem-Schlüsselbund kein vollständiger Schutz gegen
jemanden mit Zugriff auf den App-Datenordner; bewusste Abwägung, kein Aufschub aus Unklarheit.
Neues PHP-Backend in `ai-backend/` (im Repo, aber nicht Teil von `LehrerApp.sln` — ein
separat deploytes System, dessen Schema aber mit den DTOs synchron bleiben soll). MySQL-Schema
mit `users`/`tokens`/`transactions`, mehrnutzerfähig von Anfang an. Abrechnung nach echten
Token-Kosten (Input-/Output-Token × Preistabelle) statt Pauschalpreis, mit `SELECT ... FOR
UPDATE` gegen Race Conditions beim Guthabenabzug. Provider-Schnittstelle vorbereitet für
mehrere LLM-Anbieter, in dieser Runde aber **nur Anthropic konkret implementiert** — OpenAI
bewusst zurückgestellt, da aktuelle Preise/API-Version zum Zeitpunkt der Implementierung
nicht verifiziert werden konnten (kein Aufschub aus Unklarheit über den Bedarf, sondern um
keine falsch berechneten Kosten zu riskieren). Kein Admin-UI für Guthaben-Aufladung — bei der
aktuellen Nutzerzahl reicht ein dokumentierter manueller SQL-Befehl (`ai-backend/README.md`).
Nutzeranlage über `scripts/create-user.php` setzt PHP-CLI/SSH voraus — auf typischem Shared
Hosting ohne CLI-Zugriff gibt es mit `ai-backend/setup-user.php` eine web-erreichbare
Alternative (durch ein Geheimnis in `config.php` geschützt, nach Gebrauch vom Server zu
löschen).
**Nicht ohne echtes Deployment + echten API-Key verifizierbar** (siehe `ai-backend/README.md`):
ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert, ob die berechneten
Kosten exakt mit der echten Abrechnung übereinstimmen, sowie die komplette Kette
Desktop → Backend → Anthropic unter echten Netzwerkbedingungen.
**Nachtrag (Bugfix, aus echtem Live-Test):** Erste echte Planungsanfrage lieferte beim
Verarbeiten der Antwort eine `JsonException`. Ursache: `AiPlanningService.JsonOptions` hatte
keinen Converter für `DateOnly`/`TimeOnly` registriert, sodass .NETs Standardformat (ISO 8601,
z.B. "2026-12-24") verwendet wurde — der Systemprompt in `plan.php` dokumentiert aber
durchgängig deutsches Format ("TT.MM.JJJJ"/"HH:mm"). Das betraf beide Richtungen: die
ausgehende Anfrage wurde entgegen der Prompt-Dokumentation im ISO-Format gesendet, und eine
dem Prompt folgende KI-Antwort im deutschen Format scheiterte beim Deserialisieren. Behoben
durch `GermanDateOnlyJsonConverter`/`GermanTimeOnlyJsonConverter` (in `AiPlanningService.cs`),
die deutsches Format schreiben und beim Lesen zuerst deutsches Format, dann defensiv
allgemeines Parsen (u.a. ISO) versuchen — LLMs weichen erfahrungsgemäß gelegentlich vom
dokumentierten Format ab, ein harter Fehlschlag bei kleinen Abweichungen wäre unnötig
fragil. Abgesichert durch `GermanDateTimeJsonConverterTests.cs`, u.a. mit dem konkreten
nullable-Feld-Fall (`AiLesson.Date`/`StartTime`), der den ursprünglichen Bug ausgelöst hat.
- [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine
Stunde als "Umplanung nötig" flaggen. Kein Editor für Einheiten/Kompetenzen/KI-Planung dort.
Technisch schon vorbereitet: `LehrerApp.Sync`/`LehrerApp.Api` haben mit `PlainSyncEvent` und
`DeviceType.Companion` bereits eine Klartext-Sync-Schiene für genau diese Art von leichtem,
nicht-Desktop-Client (siehe `PlainEventStore`, `MapPlainSyncEndpoints`) — ein "Umplanung
nötig"-Flag käme darüber rein und würde nach dem Sync als Hinweis/Badge an der betroffenen
Stunde bzw. im Dashboard erscheinen, bis es am Desktop bearbeitet oder bewusst abgehakt wird.
- [ ] **4.5.11** KI-Unterstützung (4.5.9) auch für die Stundenplanung (Kapitel 4.3, `TimetableSlot`)
anbieten, nicht nur für Einheiten/Stunden im Verlaufsplan — z.B. Vorschläge beim Aufbau eines
neuen Stundenplans oder beim Ausgleich nach Änderungen. Gleiches Backend/gleiche Abrechnung
wie 4.5.9, aber eigenes Export/Import-Schema für die Stundenplan-Daten.
- [x] **4.5.12** Offene Frage: Kann ein Agent/Project bei console.claude.ai eingerichtet werden,
um Standardinformationen (JSON-Schema, grundlegender Auftragskontext) dort dauerhaft zu
hinterlegen, statt sie bei jeder Anfrage über das eigene PHP-Backend mitschicken zu müssen?
Würde die Nutzer-Anweisung auf das eigentlich Fachliche reduzieren.
**Antwort: nein, technisch nicht wie gedacht umsetzbar.** Projects/Agents bei console.claude.ai
sind eine Funktion der claude.ai-Chat-Oberfläche, nicht der Messages-API, die
`ai-backend/plan.php` serverseitig aufruft — es gibt keine Möglichkeit, von dort
programmatisch auf einen dort hinterlegten Kontext zuzugreifen (nutzbar wäre das nur bei
manueller Bedienung im Browser, ohne App/Abrechnung). Stattdessen wie in 4.5.13 umgesetzt.
- [x] **4.5.13** Alternative zu 4.5.12: statt eines extern gepflegten Agents ein app-interner
Standard-Prompt neben dem freien Nutzer-Prompt, der den grundlegenden Kontext (Schema,
Auftragsbeschreibung) automatisch mitliefert — der Nutzer muss ihn dann nicht jedes Mal
selbst formulieren. **Umsetzung:** `$systemPrompt` in `ai-backend/plan.php` zeigt der KI
jetzt zuerst das vollständige, literale Eingabeschema (nicht nur Fließtext-Beschreibungen
der Felder) und stellt explizit klar, dass die Einheiten-Ebene reiner Lesekontext ist —
nur `lessons[]` wird vorgeschlagen/verändert, für die Einheit selbst gibt es in der
Antwort kein Feld (Nutzer-Nachtrag, nachdem die erste Fassung Felder nur prosaisch statt
strukturell beschrieb). Danach folgen Feldbedeutungen (Fach/Stufe/Gruppe,
Kompetenz-/Alternativpfad-Katalog), deutsche Verlaufsplan-Konventionen (typische
Phasennamen, übliche Sozialform-Kürzel wie "EA"/"GA"/"UG") und Zeitrichtwerte
(≈45 Min./Einzelstunde) — die freie Nutzer-Anweisung muss dadurch nur noch das fachlich
Konkrete enthalten. Zusätzlich die Anweisung, bei unklarer Nutzer-Anweisung eine
begründete Annahme zu treffen statt zu blockieren (Rückfragen sind über diese Schnittstelle
nicht möglich), mit kurzer Begründung im `summary`-Feld. Bewusst nicht angefasst in dieser
Runde: der Kürzel-Katalog (`IShorthandCodeRepository`) selbst wird der KI noch nicht als
Kontext mitgegeben (nur die Konvention allgemein erklärt) — wäre der nächste sinnvolle Schritt,
analog zu `CompetencyCatalog`/`AlternativePathCatalog`.
- [x] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene
vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie bisher im
`AiAssistDialog`. **Umsetzung (erster, bewusst begrenzter Schritt — Teil 2/3 der
ursprünglichen Idee zurückgestellt, siehe 4.5.19):** `AiPlanningService.DescribeChanges`
vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id und liefert nur die
tatsächlich unterschiedlichen Felder als kurze deutsche Sätze (Thema, Datum, Beginn,
Stundennummer, "Hausaufgabe/Reflexion geändert", Verlaufsplan-Änderung inkl. Phasenanzahl
bei unterschiedlicher Phasenzahl). `AiAssistDialogViewModel.Send` ruft das je geänderter
Lesson auf und reicht das Ergebnis an `AiLessonReviewItem.DiffText` durch, im Dialog als
kleine Aufzählung unter der jeweiligen Checkbox angezeigt. Rein/ohne I/O, mit dedizierten
Tests (u.a. dass identische Lessons keine Diffs liefern und nur wirklich geänderte Felder
auftauchen). Rest der ursprünglichen Idee (granulare Übernahme, Konflikterkennung)
zurückgestellt, siehe 4.5.19 am Ende dieses Kapitels.
- [x] **4.5.15** Umfangs-Umschalter im `AiAssistDialog`: "Einheit umplanen ohne Stunden zu ändern"
vs. "mit Stunden ändern" (Nutzer-Nachtrag zum Konzeptgespräch nach 4.5.9). Neue Checkbox
"Auch bestehende Stundeninhalte anpassen" (Default: an, entspricht dem bisherigen Verhalten).
Zweifach durchgesetzt statt der KI-Antwort blind vertraut: der (bewusst statisch gehaltene,
siehe 4.5.16) Systemprompt erklärt im Abschnitt "Umfang dieser Anfrage", dass die KI das
mitgesendete Feld `allowModifyingExistingLessons` selbst auswerten und befolgen muss, UND
`AiPlanningService.ApplyResponse`/`AiAssistDialogViewModel.Send` verwerfen client-seitig hart
jede zurückgegebene Änderung an einer bestehenden Lesson, falls der Umschalter das verbietet
— auch wenn die KI die Anweisung ignoriert. Noch nicht umgesetzt:
eine interaktive Rückfrage der KI selbst wäre über diese Schnittstelle (einzelne, separat
abgerechnete Anfragen) nicht sinnvoll möglich — der Umschalter muss vorher vom Nutzer gesetzt
werden, keine Rückfrage während der Anfrage.
- [x] **4.5.16** Prompt Caching für wiederkehrende Kontexte (Nutzer-Nachtrag zur Guthabenfrage):
der Systemprompt in `ai-backend/plan.php` ist bei jeder Anfrage identisch — dafür extra
der zuvor dynamisch interpolierte Umfangs-Abschnitt (4.5.15) wieder statisch gemacht, die KI
liest `allowModifyingExistingLessons` jetzt selbst aus der Eingabe statt einer variablen
Formulierung im Prompt. `AnthropicProvider.php` markiert den Systemprompt jetzt als
"cache_control: ephemeral"; wiederholte Anfragen innerhalb der Anthropic-Cache-TTL (Standard
5 Min.) zahlen dafür nur den reduzierten Cache-Read-Preis statt des vollen Input-Preises.
`transactions` um `cache_creation_input_tokens`/`cache_read_input_tokens` erweitert
(`ai-backend/migrations/2026-08-add-cache-tokens.sql` für bereits deployte Installationen),
Preistabelle in `config.example.php` um `cache_write`/`cache_read` je Modell ergänzt.
Provider-Interface entsprechend erweitert (`FakeProvider` liefert dafür einfach 0 zurück).
**Nicht ohne echten API-Key verifizierbar**, ob Caching tatsächlich greift — nur an
`cache_read_input_tokens > 0` in `transactions` bei einer zweiten Anfrage innerhalb der
TTL beobachtbar (siehe `ai-backend/README.md`).
- [x] **4.5.17** Fortschrittsanzeige + einfaches Nachfassen im `AiAssistDialog` (Nutzer-Feedback
nach erstem echten Live-Test: Dialog wirkte während der Anfrage "aufgehängt", und eine
geänderte Anweisung erforderte bisher komplettes Abbrechen/Neustarten). **Umsetzung:**
indeterminierter `ProgressBar` unter "Anfrage läuft…". Das Anweisungsfeld bleibt nach der
ersten Antwort sichtbar (statt zu verschwinden); ein zweiter Button "Erneut anfragen" sendet
eine geänderte/ergänzte Anweisung erneut, wobei die aktuell angehakten Vorschläge der letzten
Runde als Entwurfskontext mitgeschickt werden (`AiPlanningService.MergeDraft`: ersetzt
Lessons mit passender Id im tatsächlichen Datenbankstand durch die Entwurfsversion, hängt
neue Vorschläge ohne Id an) — die KI baut so auf dem bereits gezeigten, noch nicht
gespeicherten Stand auf, ohne dass dafür etwas in der Datenbank landen muss. Ergebnisliste
wird bei jeder Runde einfach ersetzt, **kein** Seite-an-Seite-Vergleich zwischen den Runden
(das wäre 4.5.18). Bewusst einfacherer erster Schritt statt des größeren Umbaus.
- [ ] **4.5.18** (Zurückgestellt, Nutzer-Idee zu 4.5.17) Statt die Ergebnisliste beim Nachfassen
einfach zu ersetzen: alten und neuen KI-Entwurf gegenüberstellen und den Nutzer pro Stunde
(oder insgesamt) wählen lassen, mit welchem Stand weitergemacht wird. Größerer Umbau als
4.5.17 (zwei Entwürfe parallel im Speicher halten, Seite-an-Seite-UI, Entscheidung je Stunde
statt nur global) — deshalb erst mal zurückgestellt, bis sich zeigt, ob das einfache
Nachfassen aus 4.5.17 in der Praxis schon ausreicht.
- [ ] **4.5.19** (Zurückgestellt, Rest von 4.5.14) Granularere Übernahme statt nur ganzer Stunden —
z.B. nur einzelne Verlaufsplan-Phasen einer Stunde übernehmen statt zwingend die komplette
Stunde. Außerdem: eigene zwischenzeitliche Änderungen an einer Lesson nicht versehentlich
durch eine übernommene KI-Antwort überschreiben, falls sich die Einheit seit dem Absenden
der Anfrage schon geändert hat (aktuell nicht erkannt/abgesichert). Größerer Umbau als
4.5.14 (Teil-Merge-UI je Phase, Änderungszeitpunkt-Vergleich) — deshalb zurückgestellt.
- [x] **4.5.20** Material-/Medienvorschlag mit Zwischenablage-Prompt für externe Erzeugung
(Nutzer-Feedback nach erstem echten Einsatz: eine KI-Stunde ist "nicht die eigene Idee",
Material/Medien wie Tafelbilder sollen die KI zumindest vorschlagen können, ohne dass die
eigentliche Erzeugung über das eigene, kostenpflichtige Backend laufen muss).
**Umsetzung:** `AiPhaseStep` bekommt ein neues optionales Feld `MaterialSuggestion` (kurzer,
konkreter Vorschlag, was ein Medium zu dieser Phase zeigen/enthalten sollte, z.B. Aufbau
eines Tafelbilds) — der Systemprompt in `plan.php` weist die KI an, das nur zu füllen, wenn
ein Medium über das ohnehin genannte `material`/`shorthand` hinaus wirklich einen Mehrwert
hätte. Bewusst **kein** neues Feld auf dem Domänenmodell `LessonPhaseStep`: der Vorschlag ist
nur für die Review-Anzeige/Prompt-Erzeugung relevant, keine Migration nötig, geht beim
"Übernehmen" bewusst nicht in die gespeicherte Lesson ein (siehe 4.5.21 für dauerhaftes
Speichern als mögliche spätere Erweiterung). `AiPlanningService.BuildMaterialPrompt` baut
daraus rein lokal (kein weiterer KI-Aufruf) einen vollständigen, eigenständigen Prompt aus
Fach/Stufe/Gruppe, Einheit, Stunde und Phase plus dem KI-Vorschlag; im `AiAssistDialog`
erscheint dieser als Kasten unter der jeweiligen Stunde mit Button "📋 Prompt kopieren
(extern erzeugen)" (Avalonia-`IClipboard`, erste Zwischenablage-Nutzung in der App), den die
Lehrkraft in eine eigene Claude-Sitzung einfügt. Kein neuer Server-Roundtrip: nur ein kurzer
zusätzlicher Textabschnitt je Phase in der ohnehin laufenden Planungsantwort.
- [x] **4.5.21** "Schattenfeld" mit didaktischem Hintergrund/Begründung je KI-Stunde, auf Nachfrage
sichtbar (Nutzer-Idee neben 4.5.20) — bei einer KI-generierten Stunde fehlt anders als bei
einer selbst geschriebenen das eigene Vorwissen zum "Warum", das für einen sicheren
Unterrichtseinsatz hilft. **Umsetzung:** entschieden für "nur auf Klick nachgeladen" statt
immer mitgeneriert — passt zum bestehenden Guthabenmodell (keine Kosten für ungenutzte
Erklärungen). Neuer, eigener Endpunkt `ai-backend/explain.php` statt Zusatzfeld in jeder
`plan.php`-Antwort: eigener statischer Systemprompt (Begründung des Phasenaufbaus, mögliche
Stolpersteine/Schülermissverständnisse, Differenzierungsideen — kurz gehalten, kein Roman),
eigene Abrechnung. Die Guthaben-Abzugslogik aus `plan.php` (SELECT-FOR-UPDATE gegen Race
Conditions, Transaktions-Insert) wurde dafür nach `ai_backend_call_and_charge` in `db.php`
ausgelagert, damit sie nicht doppelt gepflegt werden muss. Neue DTOs `AiExplainRequest`/
`AiExplainResponse`, `AiPlanningService.RequestExplanationAsync`. Im `AiAssistDialog`
erscheint je Stunde ein Button "💡 Didaktischen Hintergrund erklären"
(`AiLessonReviewItem.RequestExplanationCommand`), der nach Laden durch den Text ersetzt
wird — dieselbe Anfrage wird nicht zweimal angeboten. Kein neues DB-Schema nötig (nutzt
dieselben `users`/`tokens`/`transactions`-Tabellen wie `plan.php`).
- [x] **4.5.22** KI-Unterstützung direkt aus dem Editor einer einzelnen Stunde heraus statt nur aus
der Einheiten-Übersicht (Nutzer-Feedback: bisher musste man aus der Stunde raus, die
Einheiten-KI aufrufen und die gewünschte Stunde in der freien Anweisung erst benennen).
**Umsetzung:** neuer "Fokus-Modus" im `AiAssistDialog` statt eines separaten Dialogs — Button
"🤖 KI-Unterstützung für diese Stunde" im `LessonDialog` (nur bei bereits gespeicherten
Stunden, eine gerade erst angelegte hat noch keine Id zum Fokussieren) öffnet denselben
`AiAssistDialog`, aber mit `focusLesson` gesetzt. Neues Wire-Feld
`AiPlanningRequest.FocusLessonId`: der Systemprompt in `plan.php` weist die KI an,
ausschließlich diese eine Stunde zu bearbeiten, keine neuen Stunden vorzuschlagen und keine
andere Stunde anzufassen — zusätzlich (wie beim Umfangs-Umschalter 4.5.15) client-seitig hart
durchgesetzt in `AiAssistDialogViewModel.Send` (Antwort wird auf die Fokus-Id gefiltert,
bevor sie überhaupt angezeigt wird) und `AiPlanningService.ApplyResponse`. Der
Umfangs-Umschalter ("Auch bestehende Stundeninhalte anpassen") ergibt im Fokus-Modus keinen
Sinn und wird ausgeblendet (immer erzwungen an).
**Datenkonsistenz-Falle vermieden:** Da `AiAssistDialog` bei "Übernehmen" direkt ins
Repository speichert, wären die noch offenen Feldwerte des ursprünglichen `LessonDialog`
danach veraltet — ein anschließendes normales "Speichern" hätte die KI-Änderung
stillschweigend wieder überschrieben. Der `LessonDialog` schließt sich deshalb nach einer
angewendeten KI-Änderung automatisch (`LessonDialogViewModel.MarkAppliedExternally`, lädt den
frischen Stand statt der eigenen Felder), die aufrufende Liste aktualisiert sich wie nach
einem normalen Speichern.
- [x] **4.5.23** Unterrichtsmodus: eine konsolidierte Ansicht für die gerade laufende Stunde
(Nutzer-Idee — im Unterricht selbst bisher zu viele Klicks zwischen Verlaufsplan-Viewer,
Sitzplan-Tab und Mitarbeit-Tab einer Gruppe verteilt).
**Umsetzung:** Neuer Button "▶ Unterricht" in der "Heute"-Tagesliste des Stundenplans
(`TimetableView.axaml`, nur sichtbar wenn für den Slot bereits eine `Lesson` existiert —
bewusst NICHT im Wochenraster, der Modus ist für das aktive Unterrichten HEUTE gedacht,
nicht zum Durchblättern anderer Tage) öffnet ein neues, maximiertes Fenster
`TeachingModeWindow`: links der schreibgeschützte Verlaufsplan (Phasen, Hausaufgabe,
Reflexion — kompakte Kartenliste statt der breiten Tabellenansicht des bestehenden
`LessonViewerDialog`, da der Platz hier schmaler ist), rechts der volle, unverändert
wiederverwendete `SeatingPlanTabView` (Drag&Drop, Schnellbewertung per Sitzplatz-Klick,
Situations-Tags, Sitzplan-PDF-Export — alles bereits vorhanden, keine Duplizierung). Die
beiden bereits bestehenden Sprungziele aus dem Verlaufsplan-Viewer ("Zur Mitarbeit"/
"Zu den Noten", 4.5.3) sind identisch mitgekommen, da `TeachingModeViewModel` das
bestehende `LessonViewerViewModel` unverändert für die linke Spalte wiederverwendet statt
die Phasenanzeige zu duplizieren.
**Sitzung automatisch mit der Stunde verknüpft:** Neue Methode
`SeatingPlanTabViewModel.SelectOrCreateSessionForLesson(Lesson)` — anders als die
bestehende `EnsureTodaySession()` (anonyme "Sitzplan"-Sitzung, erst bei der ersten
tatsächlichen Aktion, um das Geistersitzungs-Problem aus 14.12/7.1.5-Nachtrag zu vermeiden)
darf hier sofort beim Start des Unterrichtsmodus eine mit `Lesson.Id` verknüpfte Sitzung
angelegt werden: welche Stunde gemeint ist, steht durch den expliziten Klick auf
"▶ Unterricht" für genau diese Stunde bereits unzweideutig fest, keine passive
Tab-Öffnung wie beim ursprünglichen Bug. Existiert bereits eine verknüpfte Sitzung (z.B.
über "Sitzung aus der Stunde erstellen", 3.3.1, aus der Planung heraus angelegt), wird
diese wiederverwendet statt einer zweiten.
**Bewusst zurückgestellt:** kein Entry-Point aus dem Dashboard (dessen "Heute"-Karte
navigiert bisher direkt zum Mitarbeit-Tab, ein anderer Anzeigetyp als
`TodayLessonItem`) — der Stundenplan-Tab ist der naheliegendere, bereits etablierte Ort für
"heutige Stunden mit Lesson-Bezug" (4.5.2 nutzt denselben Ort). Kein eigenes
Vollbild-/Kiosk-Fenster (kein `WindowState.FullScreen`, kein Precedent dafür im Code) —
stattdessen ein maximiertes, normal schließbares Fenster, um kein neues, ungetestetes
UI-Verhalten einzuführen.
**Nachtrag (August 2026, Nutzer-Feedback) — Schnellbewertung und Hausaufgabe direkt im
Unterrichtsmodus:** zwei neue Buttons oben ("⚡ Mitarbeit", "⚡ Anwesenheit/Hausaufgabe")
öffnen dieselben Schnellbewertungs-Dialoge wie der Mitarbeit-Tab der Gruppe
(`ParticipationQuickInputDialog`/`AttendanceHomeworkQuickInputDialog`), ohne den
Unterrichtsmodus verlassen zu müssen. Dafür bekommt `TeachingModeViewModel` jetzt eine
eigene, per DI aufgelöste `ParticipationTabViewModel`-Instanz (gleiches Muster wie
`SeatingPlanTabViewModel`), initialisiert auf die Gruppe und explizit auf die zu dieser
Stunde gehörende Sitzung vorselektiert (`SeatingPlan.SelectedSession.Id` nach
`SelectOrCreateSessionForLesson`). **Wichtige Falle dabei:** die beiden ViewModels laufen
als unabhängige Instanzen mit jeweils eigenem, lokal geladenem Zustand — eine über die
Schnellbewertung geänderte Anwesenheit/Hausaufgabe spiegelt sich nicht automatisch in den
Sitzplan-Badges (`SeatCellViewModel.AttendanceBadge`/`HomeworkBadge`), da
`SeatingPlanTabViewModel.RefreshSeatLessonData()` nur auf eigene Änderungen reagiert. Neue
öffentliche Methode `SeatingPlanTabViewModel.ReloadSeatBadgesFromRepository()` (öffentlich
statt intern, wie der Rest der Codebasis es hält — kein `InternalsVisibleTo`), von
`TeachingModeWindow.axaml.cs` nach jedem geschlossenen Schnellbewertungs-Dialog aufgerufen.
Zusätzlich: die linke Spalte zeigt jetzt statt der rein lesbaren Hausaufgaben-Anzeige einen
interaktiven Block — Hausaufgabe der **letzten** Stunde (120-Tage-Rückschau wie beim
bestehenden Stundenplan-Badge, `TimetableViewModel.HasUnhandledHomework`) mit
"Kontrolliert"-Checkbox (speichert sofort, dieselbe Semantik wie im `LessonDialog`, 4.5.4),
und die Hausaufgabe **dieser** Stunde als Textfeld mit Speichern-Button — Nutzer-Feedback:
die Hausaufgabe ergibt sich manchmal erst während der Stunde, dafür sollte man nicht extra
den vollen Verlaufsplan-Editor öffnen müssen. Neue kleine
`TeachingModeHomeworkViewModel : ObservableObject`, da `TeachingModeViewModel` selbst (wie
`LessonViewerViewModel`) keine Bindable-Basisklasse hat.
- [x] **4.5.24** Popup-Menü im Wochenraster für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
(August 2026, Nutzer-Feedback, zweite Runde). Die erste Fassung hatte das Problem am
falschen Ort gelöst — ein Dropdown in der "Heute"-**Tagesliste** (unten angedockt), obwohl
die dortigen zwei Buttons laut Nutzer schon in Ordnung waren. Zurückgesetzt auf den
ursprünglichen Stand (zwei Buttons, `TodayLessonItem` ohne `DestinationOptions`). Das
eigentliche Problem lag im **Wochenraster darüber**: ein Klick auf eine Stunden-Kachel führt
dort in den Planungsviewer oder zur Einheitenplanung, je nachdem ob schon eine `Lesson`
existiert — von außen nicht erkennbar, welches Ziel man bekommt.
- **Popup-Menü:** kleiner "⋮"-Button pro Kachel (`Button.weekCellMenuTrigger`, oben rechts in
der Zelle überlagert) öffnet ein `MenuFlyout` mit vier ausdrücklich benannten Zielen.
Unterrichtsansicht/Planungsviewer nur wenn schon eine `Lesson` existiert, Sitzplan/Planung
immer (Planung ist ohnehin der Ort, an dem man eine Lesson für den Slot erst anlegt).
**Wichtiger XAML-Fallstrick, diesmal genauer verstanden:** ein `$parent[ItemsControl]`-
Vorfahrenpfad (wie beim normalen Zeilen-Button) funktioniert in einem `MenuFlyout` nicht,
weil dessen Popup nicht im normalen visuellen Baum hängt — **aber** eine normale
`{Binding}` ohne Vorfahrensuche funktioniert dort sehr wohl, weil Avalonia die
DataContext-**Vererbung** (kein Baum-Durchsuchen, nur der geerbte Wert) auch in Flyouts
korrekt weiterreicht. Trotzdem bewusst `MenuItem.Click` statt `Command`-Binding gewählt
(kein `WeekCellItem`-eigenes Command nötig): Handler in `TimetableView.axaml.cs` liest die
Zelle über `((MenuItem)sender).DataContext`, das Ziel über `Tag="{x:Static
vm:TimetableLessonDestination.…}"` — beides ohne jede Vorfahrensuche.
- **Einheitlicherer Direktklick:** `OpenWeekCellCommand` prüft jetzt zusätzlich, ob gerade
(heute, ±10 Minuten Toleranz) Unterrichtszeit der Stunde ist (neues
`TimetableViewModel.IsAroundTeachingTime`, `PeriodScheduleService`/"Stundenraster" als
Grundlage) — dann direkt in den Unterrichtsmodus statt in den Planungsviewer. Ohne Lesson
bleibt es beim Sprung zur Einheitenplanung. Das Popup-Menü bietet immer alle vier Ziele
explizit an, unabhängig von dieser Automatik. Nutzt bewusst `lesson.Date` statt
`WeekCellItem.Date` — letzteres ist nur bei Kopfzeilen gesetzt, nicht bei
Stunden-Kacheln (führte im ersten Testlauf zu einem Bug: die Automatik griff nie).
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
`AttendanceStatus`) ergeben hätte — zwei potenziell widersprüchliche Datenquellen für dieselbe
Frage ("war der Schüler da?"). Auf Rückfrage entschieden: 5.2 wird als **Auswertung** der
bestehenden Anwesenheitsdaten umgesetzt, keine zweite Erfassung. 5.2.1 und 5.2.4 existierten
dadurch faktisch schon (Quick-Input-Dialog bzw. "Offene Entschuldigungen" im Dashboard).
### 5.1 Einträge erfassen
- [x] **5.1.1** Dialog "Dokumentation hinzufügen" mit Typwahl
(`Conversation`, `Incident`, `SupportPlan`, `Absence`) und typabhängigen Feldern —
[DocumentationDialog.axaml](LehrerApp.Desktop/Views/Students/DocumentationDialog.axaml),
[DocumentationViewModels.cs](LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs).
Deutsche Anzeige über `DocumentationTypeDisplay`/`SupportStatusDisplay`, analog zum
`NiveauDisplay`-Muster. Feldbezogene Validierung wie in 13.2.4.
- [x] **5.1.2** Teilnehmerliste (`Participants`) bei Gesprächen — Chip-Liste mit Hinzufügen/
Entfernen im Dialog, nur sichtbar bei Typ "Gespräch".
- [x] **5.1.3** Kennzeichen "vertraulich" — vertrauliche Einträge zeigen in der Übersicht nur
Datum/Typ/Schloss-Symbol, Titel und Inhalt erst nach Klick auf "Anzeigen"
(`DocumentationItem.IsRevealed`, session-lokal, keine erneute Passwortabfrage — dafür gibt
es bereits die App-Sperre aus 13.3.5).
- [x] **5.1.4** Bearbeiten/Löschen mit Rückfrage (neuer generischer `ConfirmDialog`); Löschen
markiert nur `IsDeleted`/`DeletedAt` statt hart zu entfernen —
`IDocumentationRepository.Delete`. Ein echtes `HardDelete` existiert separat, nur für die
Löschfristen-Bereinigung in 5.4.2.
- [x] **5.1.5** Zwei weitere Dokumentationstypen ergänzt (Nutzer-Feedback nach Erstauslieferung):
- **Elternanruf** (`DocumentationType.ParentCall`): Gesprächspunkte werden im normalen
Dialog vorab geplant (`ParentCallData.Points`); ein separater
[ParentCallSessionDialog](LehrerApp.Desktop/Views/Students/ParentCallSessionDialog.axaml)
("Gespräch begleiten", Button an jedem Elternanruf-Eintrag) hakt sie während des
Telefonats ab und hält Eindrücke/Ergänzungen als Protokoll fest
(`ParentCallData.Impressions`, `IsConducted`, `ConductedDate`). Punkte mit
unverändertem Text behalten beim erneuten Bearbeiten ihren Abhak-Status (Zuordnung
über `ParentCallPoint.Id`, sonst über Textabgleich — ein umbenannter Punkt gilt als
neu und startet offen; bewusste Vereinfachung).
- **Elternbrief** (`DocumentationType.ParentLetter`): Entwurf/Inhalt, Absendedatum,
Rückmeldung erhalten (ja/nein), Rückmeldedatum und -notiz (`ParentLetterData`).
- **Datei-Anhänge** (an allen Dokumentationstypen, nicht nur Elternbrief): über LiteDBs
eingebauten Dateispeicher (`ILiteStorage<string>`, per Skript verifiziert — funktioniert
zuverlässig, im Gegensatz zum defekten `Rebuild`-mit-Passwort aus 13.3.4) —
[LiteAttachmentStorage.cs](LehrerApp.Data/LiteAttachmentStorage.cs). Größe strikt auf
10 MB je Datei begrenzt (`IAttachmentStorage.MaxSizeBytes`), damit die Datenbankdatei
(und jedes Backup, 13.3.1) nicht durch Anhänge aufgebläht wird. Neu hochgeladene, aber
nie gespeicherte Anhänge werden beim Abbrechen des Dialogs wieder gelöscht, damit keine
verwaisten Blobs zurückbleiben; beim endgültigen Löschen eines Eintrags (5.4.2) werden
auch dessen Anhänge mit entfernt.
**Randnotiz:** Beim Schreiben der Tests für den Datei-Speicher fiel eine bereits vorher
latent vorhandene Testinfrastruktur-Schwäche auf: `LiteDbContext` nutzt LiteDBs
statischen, geteilten `BsonMapper.Global` für die Index-Auflösung — bei paralleler
Testausführung über mehrere Testklassen hinweg (xUnit-Standard) führte das sporadisch zu
"Member X not found on BsonMapper"-Fehlern in völlig unbeteiligten Tests. Behoben durch
`[assembly: CollectionBehavior(DisableTestParallelization = true)]` in
[AssemblyInfo.cs](LehrerApp.Data.Tests/AssemblyInfo.cs) — Data-Tests laufen jetzt
sequenziell (bei elementaren In-Memory-Tests kein spürbarer Zeitverlust).
- [x] **5.1.6** Labels zur Nachverfolgung (Nutzer-Feedback): freie Text-Labels an jedem
Dokumentationseintrag (`Documentation.Tags`), mit AutoCompleteBox-Vorschlägen
(`DocumentationTagDisplay.Suggestions`: Kritisch, Nacharbeiten, Mit JGL abklären,
Erkundigung einholen, Elterngespräch nötig, Mit Schulleitung abklären, Klassenkonferenz,
Frist beachten, Beobachten, Erledigt — eigene Labels bleiben trotzdem frei möglich).
Farbcodierung nach Dringlichkeit statt nach Label-Identität
(`DocumentationTagDisplay.ColorHex`): rot = Priorität, orange = Handlungsbedarf,
blau = im Blick behalten, grün = abgeschlossen, grau = freies Label. Als farbige Chips in
der Dokumentationsliste sichtbar (`DocumentationItem.TagChips`).
Dabei außerdem behoben: die Farblegende der Notenentwicklung-Balken (2.5) fehlte sichtbar
im UI (nur im Tooltip) — wirkte dadurch wie zufällige/abwechselnde Farbgebung statt wie das
eigentliche Signal "Auffälligkeit". Jetzt als kleine Legende über dem Diagramm sichtbar,
siehe [StudentDetailView.axaml](LehrerApp.Desktop/Views/Students/StudentDetailView.axaml).
- [x] **5.1.7** Dokumentation je Lerngruppe (Nutzer-Feedback: der "Dokumentation"-Tab der
Lerngruppe war seit seiner Anlage nur ein Platzhalter — "Wird implementiert."). Neuer
[GroupDocumentationTabView](LehrerApp.Desktop/Views/Groups/GroupDocumentationTabView.axaml)/
[GroupDocumentationTabViewModel](LehrerApp.Desktop/ViewModels/Groups/GroupDocumentationViewModels.cs)
listet die Dokumentationseinträge aller (auch ehemaligen) Schüler der Gruppe an einem Ort,
mit Schüler-Filter (Dropdown) und optionalem "Nur dieser Unterricht"-Schalter. Design-
Entscheidung nach Rückfrage: Einträge aus **anderen** Lerngruppen desselben Schülers werden
standardmäßig **mit angezeigt statt versteckt** — nur optisch gedimmt
(`DocumentationItem.ContentOpacity`) und mit `"aus: <Gruppenname>"` beschriftet — damit
Muster über mehrere Fächer/Kurse hinweg nicht verborgen bleiben; der Schalter blendet sie
bei Bedarf ganz aus. Einträge ohne Gruppenbezug (`Documentation.GroupId == null`, z.B. vom
Schüler-Tab aus angelegt) gelten als "eigen" und werden nie gedimmt.
`DocumentationDialogViewModel` bekam dafür einen optionalen Schüler-Picker
(`StudentOptions`/`SelectedStudent`, neuer `StudentOption`-Record): beim Anlegen aus dem
Gruppen-Tab (ohne festen Schüler) ist die Auswahl Pflicht (`StudentError`), neue Einträge
bekommen automatisch die `GroupId` der aktuellen Gruppe gesetzt; der bestehende Aufruf vom
Schüler-Tab (fester `studentId`, keine Optionsliste) bleibt unverändert und zeigt die
Auswahl gar nicht erst an. "Gespräch begleiten" (Elternanruf) und Anhänge funktionieren im
Gruppen-Tab identisch zum Schüler-Tab, da beide dieselbe `DocumentationItem`/
`DocumentationDialog`-Infrastruktur verwenden.
- [x] **5.1.8** Gruppenweite Planungs- und Erinnerungseinträge ohne Schülerbezug. Im
Dokumentationsdialog einer Lerngruppe steht jetzt „Gesamte Lerngruppe“ als eigener Bezug
zur Wahl; solche Einträge verwenden den angehängten Typ `DocumentationType.Planning`, die
bestehende `GroupId` und bewusst `Guid.Empty` als kompatiblen „kein Schüler“-Marker. Sie
erscheinen auch in Gruppen ohne Schüler und werden eindeutig als „Gesamte Lerngruppe“
beschriftet. Die globale Suche bietet zusätzlich die Schnellaktion „Lerngruppen-Eintrag“
mit einem kompakten Formular für Gruppe, Datum, Titel und Notiz.
### 5.2 Fehlzeiten (als Auswertung des bestehenden Anwesenheits-Trackings, siehe oben)
- [x] **5.2.1** Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über
`AttendanceHomeworkQuickInputDialog` und den Mitarbeits-Assistenten (Kapitel 3).
- [x] **5.2.2** Fehlzeitenbilanz je Schüler und laufendes Schuljahr —
[AttendanceBalanceService.cs](LehrerApp.Core/Services/AttendanceBalanceService.cs), reine
Auswertungslogik (kontrollierte Stunden, entschuldigt/unentschuldigt/offen, Fehlquote in %),
angezeigt im Schülerdetail-Tab "Dokumentation". Schulisch veranlasste Abwesenheit
(`OtherSchoolEvent`) zählt bewusst nicht als Fehlzeit des Schülers.
- [x] **5.2.3** Schwellenwert-Warnung (> 20 %) — `AttendanceBalance.ExceedsThreshold` im
Schülerdetail sowie eine neue "Fehlzeiten-Warnung"-Karte im Dashboard (alle Schüler über
dem Schwellenwert, sortiert nach Fehlquote). **Nachtrag (Nutzer-Feedback):** die
Dashboard-Karte meldete anfangs auch Schüler mit nur ein oder zwei erfassten Terminen — wer
zu Schuljahresbeginn einmal fehlt, steht damit rechnerisch schon bei 100 %, obwohl das noch
kein auffälliges Muster ist, nur eine zu kleine Stichprobe. `DashboardViewModel.LoadAttendanceWarnings`
meldet jetzt erst ab `AttendanceMinSampleSize` (8) im laufenden Schuljahr erfassten Terminen
(`AttendanceBalance.TotalChecked`) — dieselbe Konstante und Prüfung wie in der
Kurs-Übersicht (14.12, `GroupOverviewViewModel`), an der das zuerst aufgefallen ist.
`AttendanceBalanceService` selbst bleibt unverändert (liefert weiterhin die reine Quote ohne
Mindeststichprobe, z.B. für die Anzeige im Schülerdetail) — die Gate-Prüfung liegt bewusst
nur bei den beiden "Warnung"-Konsumenten.
- [x] **5.2.4** Nachträgliches Entschuldigen mit Frist-Hinweis — bereits vorhanden über die
"Offene Entschuldigungen"-Karte im Dashboard (21-Tage-Grenze, aus Kapitel 3).
### 5.3 Förderpläne
- [x] **5.3.1** Förderplan anlegen (Maßnahmenliste, Überprüfungsdatum, Status) — über den
5.1-Dialog mit Typ "Förderplan" (`SupportData`: `Measures`, `ReviewDate`, `Status`).
- [x] **5.3.2** Wiedervorlage: fällige Überprüfungen (Status Aktiv, Überprüfungsdatum in den
nächsten 14 Tagen oder überfällig) erscheinen als eigene Dashboard-Karte
"Förderplan-Wiedervorlage", überfällige rot hervorgehoben.
- [x] **5.3.3** Verlaufsdokumentation — die Dokumentationsliste im Schülerdetail zeigt alle
Förderplan-Einträge chronologisch; bewusst keine zusätzliche Gruppierung über eine
Plan-ID, da das bestehende flache `Documentation`-Modell dafür ausreicht.
### 5.4 Datenschutz
- [x] **5.4.1** Vertrauliche Einträge nur nach zusätzlicher Bestätigung anzeigen — siehe 5.1.3
(zusammen umgesetzt, da es sich um dieselbe UI-Stelle handelt).
- [x] **5.4.2** Löschfristen — neuer Tab "Datenschutz" in den Einstellungen: konfigurierbare
Aufbewahrungsfrist in Jahren
([PrivacySettingsService.cs](LehrerApp.Core/Services/PrivacySettingsService.cs), Standard 3
Jahre), Liste abgelaufener Einträge zur manuellen Prüfung mit "Endgültig löschen"
(`IDocumentationRepository.HardDelete`). Löscht nie automatisch.
- [x] **5.4.3** Export einzelner Schülerdaten für Auskunftsersuchen (Art. 15 DSGVO) —
[PersonalDataExportService.cs](LehrerApp.Core/Services/PersonalDataExportService.cs),
JSON-Export mit Stammdaten, Gruppenzuordnungen, Noten, Klausurergebnissen, Mitarbeit und
Dokumentation (auch als vertraulich markierte Einträge — das Vertraulich-Kennzeichen blendet
nur die laufende Ansicht aus, ist aber keine pauschale rechtliche Ausnahme vom
Auskunftsanspruch der betroffenen Person selbst). **Hinweis:** ob im Einzelfall eine
Ausnahme greift (z.B. schutzwürdige Belange Dritter nach Landes-Schulrecht), muss die
verantwortliche Lehrkraft/Schule selbst prüfen — das ist keine Rechtsberatung.
---
## 6. Arbeitszeit & Aufgaben
Modelle `WorkTask` und `TimeEntry` existieren, Repositories ebenfalls.
Navigationspunkt "Arbeitszeit" zeigt Aufgabenverwaltung (6.1), Zeiterfassung (6.2) und Auswertung
(6.3) als drei Tabs (`WorkloadViewModel`/`WorkloadView`, gleiches Container-Tab-Muster wie
`GroupDetailViewModel`). **Kapitel 6 ist damit vollständig abgeschlossen**, einschließlich des
CSV-Exports der Auswertung (6.3.3) über die gemeinsame Export-Infrastruktur aus 11.1.
### 6.1 Aufgabenverwaltung
- [x] **6.1.1** Aufgabenliste mit Filter nach Status, Kategorie, Gruppe und Fälligkeit —
`WorkTaskListViewModel`/`WorkTaskListView`. Drei Filter-ComboBoxen (Status/Kategorie/Gruppe);
"Fälligkeit" als Sortierung (fällige zuerst, unbefristete ans Ende) statt eigenem
Datumsbereich-Filter umgesetzt, da eine Liste mit wenigen Dutzend Aufgaben davon mehr
profitiert als von einer zusätzlichen Filter-UI.
- [x] **6.1.2** Aufgabe anlegen/bearbeiten: Titel, Kategorie, Gruppe, Fälligkeit,
geschätzte Dauer, Notizen — `AddEditWorkTaskDialog`, gleiches Feld-Fehler-Muster wie die
übrigen Dialoge (`{Field}Error` je Feld, Validierung sammelt statt beim ersten Fehler
abzubrechen).
- [x] **6.1.3** Status per Klick wechseln (`Open → InProgress → Done → Open`) über eine farbige
Status-Kachel je Zeile; "Offene Aufgaben" (alles außer `Done`) ist der Default-Filter, damit
Erledigtes automatisch ausgeblendet ist, ohne separate Sichtbarkeits-Logik.
- [x] **6.1.4** Wiederkehrende Aufgaben (wöchentlich/monatlich) — `WorkTask.Recurrence`
(`TaskRecurrence`: None/Weekly/Monthly). Kein eigenes Serien-/Vorlagen-Modell: Beim
Abschließen (Status → `Done` in 6.1.3) wird einfach eine neue `WorkTask` mit gleichem
Titel/Kategorie/Gruppe und um 7 Tage bzw. 1 Monat verschobenem Fälligkeitsdatum erzeugt —
genau das Verhalten, das ein Lehrer von "wöchentlich wiederkehrend" erwartet, ohne
Hintergrundjob oder Kalenderlogik. Braucht deshalb ein Fälligkeitsdatum als Ausgangspunkt
(im Dialog erzwungen, sobald eine Wiederholung gewählt ist).
- [x] **6.1.5** Automatische Aufgabe "Klausur korrigieren" beim Statuswechsel einer Klausur
auf `Conducted` (Anbindung an 1.1.3) — Hook in `GroupDetailViewModel.SetExamStatus`, feuert
nur beim *erstmaligen* Wechsel von `Planned` auf `Conducted` (nicht bei jedem erneuten
Klick), Fälligkeit 14 Tage nach dem Klausurtermin, Kategorie `Correction`.
- [x] **6.1.6** "Erinnerung" als eigene Aufgabenart (Nutzer-Feedback): "Aufgaben" wirkte wie reine
Zeiterfassung, dabei braucht es auch schlichte Reminder ohne Zeitbezug (z.B. "morgen Ansage
an die Klasse machen"). Neues `WorkTask.Kind` (`TaskKind`: `WorkItem`/`Reminder`, Default
`WorkItem` — kein Migrationsschritt nötig, fehlendes BSON-Feld deserialisiert auf den
Enum-Default). Eigener Button "🔔 Erinnerung" neben " Neue Aufgabe"
(`WorkTaskListViewModel.AddReminderCommand`) öffnet denselben `AddEditWorkTaskDialog`, aber
mit der Checkbox "Nur Erinnerung" vorbelegt — dadurch entfällt automatisch das
"Geschätzte Dauer"-Feld (Erinnerungen bekommen nie einen Zeitwert). Läuft bewusst nicht in
die Zeitauswertung (6.3) ein — die bereits rein datenmodell-bedingt, da `WorkloadEvaluationViewModel`
nur tatsächlich erfasste `TimeEntry`-Zeilen auswertet, keine `WorkTask`-Metadaten. In der
Aufgabenliste und im Dashboard-Widget "Offene Aufgaben" durch ein 🔔-Symbol gekennzeichnet.
- [ ] **6.1.7** TickTick-Sync für Erinnerungen (Nutzerwunsch): Der Nutzer pflegt Erinnerungen
privat bereits in TickTick (Handy-App, eingespielte Routine dort). Ziel: ausgewählte
`WorkTask`s (zunächst `TaskKind.Reminder`, 6.1.6) bidirektional mit einer TickTick-Liste
abgleichen, damit Fälligkeiten auf dem Handy ankommen und dort abgehakte Erinnerungen auch
in LehrerApp als erledigt gelten. Rechercheergebnis zur TickTick Open API
(developer.ticktick.com/docs#/openapi, Stand 2026-08-24) unten festgehalten, damit das nicht
erneut nachgeschlagen werden muss.
- **Auth:** Kein OAuth2-Redirect-Flow nötig — für den Eigengebrauch (ein Nutzer, ein Gerät)
reicht ein persönliches API-Token (TickTick-Web: Avatar → Settings → Account → API Token),
gesendet als `Authorization: Bearer <token>`. Ein neues Einstellungsfeld "TickTick API
Token" genügt, analog zur bereits pro Gerät hinterlegten WebUntis-URL — kein lokaler
Redirect-Listener, keine Client-ID/Secret-Registrierung.
- **Push (LehrerApp → TickTick):** `POST /open/v1/task` (einzeln) bzw.
`POST /open/v1/task/batch` (bis 50 Creates/Updates in einem Call) beim Anlegen/Ändern einer
passenden `WorkTask`. Erledigt-Setzen auf TickTick-Seite über
`POST /open/v1/project/{projectId}/task/{taskId}/complete` bzw. gebündelt
`POST /open/v1/task/completeTasks` (bis 50 Task-IDs).
- **Pull erledigt (TickTick → LehrerApp):** `POST /open/v1/task/completed`, gefiltert nach
`projectIds` + Zeitfenster (`completedTime`-Range) — liefert bis zu 200 im Poll-Intervall
erledigte Tasks der Zielliste in einem Call, kein Abklappern einzelner Task-IDs nötig.
- **Pull neu (TickTick → LehrerApp):** `POST /open/v1/task/filter` (Filter nach `projectIds`
+ `status`, bis 200 Treffer) oder `GET /open/v1/project/{projectId}/data` (Projekt + aktive
Tasks + Columns in einem Call) auf die Zielliste pollen, Ergebnis gegen die lokal
gespeicherten Fremd-IDs diffen — neue IDs werden als `WorkTask` angelegt.
- **Architektur-Vorbild:** gleiches Muster wie der bestehende WebUntis-Abgleich —
framework-freie Diff-Logik nach `LehrerApp.Core/Services/` (analog `UntisDiffService`),
HTTP-Aufruf + Timer-Orchestrierung in `LehrerApp.Desktop/Services/` (analog
`UntisSyncService`). Zuordnung TickTick-Task-ID ↔ `WorkTask` über ein neues
`WorkTask.ExternalId`-Feld, idempotent wie `SubstitutionEntry.ExternalId`
(`GetByExternalId`, Update statt Duplikat).
- **Offen:** kein Webhook-Support in der API bekannt — nur Polling. Rate-Limits sind in der
Doku nicht dokumentiert, vor Produktivbetrieb mit kurzem Poll-Intervall (< 5 Min.) prüfen.
Vorher klären: welche `WorkTask`-Teilmenge synct (nur `Reminder` oder auch `WorkItem`?),
welche TickTick-Liste ist die Zielliste (fest in den Einstellungen hinterlegt, analog zur
WebUntis-URL)?
**Nachtrag — Pädagogische Klassen-Aufgaben (Nutzer-Feedback):** Wunsch nach einer zweiten,
"weniger arbeitszeitrelevant als pädagogisch" gedachten Art von Todo-Item (Beispiele:
"Ansage an die Klasse", "Etwas zum Stichtag einsammeln/austeilen"), gleichberechtigt im
Hauptdashboard **und** im jeweiligen Kurs-Dashboard, farblich nach Frist/Priorität absetzbar,
mit optionaler Abhak-Liste.
Nutzervorschlag war eine eigene `TodoItem`-Basisklasse mit `JobTodo`/`KlassenTodo`-Ableitungen
(Vererbung). Dagegen entschieden: In dieser Codebasis mappt jedes Modell 1:1 auf eine eigene
benannte LiteDB-Collection (`LiteDbContext.cs`, keine `BsonMapper.Entity<T>()`-Discriminator-
Registrierung irgendwo im Projekt) — eine polymorphe Collection wäre der erste Fall dieser Art.
Zusätzlich verwendet die Papierkorb-Wiederherstellung (14.3, `LiteDbContext.MoveToTrash`/
`RestoreFromTrash<T>`) einfaches generisches `JsonSerializer.Serialize<T>`/`Deserialize<T>` ohne
Typ-Diskriminierung — polymorphes Round-Tripping bräuchte dort zusätzliche Handhabung. Stattdessen
wird die "Klassen-Aufgabe" rein über bereits vorhandene Felder abgebildet: `WorkTask.Kind ==
TaskKind.Reminder` (6.1.6, dessen Doku-Kommentar bereits "Ansage an die Klasse" als Beispiel
nennt) kombiniert mit dem längst nullable `WorkTask.GroupId`. Kein neues Modell, keine neue
Collection, kein neuer Dialog nötig — `AddEditWorkTaskDialog` konnte eine solche Aufgabe schon
vorher anlegen.
Neu auf `WorkTask`: `Priority` (`TaskPriority`: Low/Normal/High, Default Normal) und
`ChecklistItems` (`List<ChecklistItem>`, je Eintrag `Label`/`IsDone`/optionale `StudentId`).
Nutzervorschlag war "JSON als DB-String", um keine neue Tabelle zu brauchen und den bestehenden
Sync-Unterbau mitzunutzen — LiteDB verschachtelt C#-Listen/Objekte aber bereits nativ in einem
BSON-Dokument, ein JSON-String-Encoding wäre in einer Dokumenten-DB ein Umweg. Da
`WorkTaskRepository.Save` bei jedem Speichern das komplette `WorkTask`-Dokument über
`db.OnChange` an den Sync-Event-Publisher weiterreicht, syncen neue verschachtelte Felder
automatisch mit, ganz ohne Sync-Layer-Änderung — erreicht damit dasselbe Ziel wie der
JSON-String-Vorschlag, nur ohne Encoding-Zwischenschritt.
`ChecklistItems` vereinheitlicht die zwei vom Nutzer genannten Alternativen (Namensliste aller
Kurs-Schüler *oder* freie Teil-Punkte zum Abhaken) in einem Listentyp: `StudentId` ist gesetzt,
wenn ein Eintrag über "Aus Kursliste befüllen" aus der aktiven Kursmitgliedschaft erzeugt wurde
(`WorkTaskListView.LoadActiveRoster`, dieselbe `GroupMembershipService.IsActiveOn`-Prüfung wie
überall sonst), sonst bleibt es leer für frei eingetippte Punkte — beides im selben
`AddEditWorkTaskDialog`-Editor (Checkbox je Zeile, Entfernen-Button, Freitext-Eingabe).
Farbcodierung: `TaskPriorityDisplay` (Label/ColorHex/Options, gleiches Muster wie
`TaskCategoryDisplay`/`TaskRecurrenceDisplay`) — in der Aufgabenliste als linker Farbbalken nur
bei `High` sichtbar (Normal/Low sollen nicht zusätzlich "schreien"), im Hauptdashboard-Widget
"Offene Aufgaben" ebenso; Fälligkeitsfarbe (überfällig = rot) läuft weiterhin über den
bestehenden `Classes.overdue`-Stil.
Neue Karte "Anstehende Aufgaben" im Kurs-Dashboard (`GroupOverviewViewModel.GroupTasks`, über
neues `IWorkTaskRepository.GetByGroup`) — gleiches Karten-Muster wie die übrigen
Übersicht-Karten (`Has…`-Flag, `ObservableCollection`). Der "Zu den Aufgaben"-Link springt anders
als die übrigen Karten-Links nicht auf einen Tab *innerhalb* der Kurs-Detailansicht (die gibt es
für "Aufgaben" nicht), sondern in den eigenständigen Top-Level-Bereich "Arbeitszeit"
(`NavItem.Workload`) — neue `MainWindowViewModel.NavigateToWorkload()`, verdrahtet in
`GroupDetailView.axaml.cs` über `App.Services`, gleiches Delegate-Muster wie
`LessonViewerDialog`/`TeachingModeWindow` (View-Code-Behind statt ViewModel-zu-ViewModel-Referenz).
**Nachtrag — Direkter Anlege-Einstieg + Schnellüberblick in der Gruppenliste (Folge-Feedback):**
Die "Anstehende Aufgaben"-Karte im Kurs-Dashboard und die "Offene Aufgaben"-Kachel im
Hauptdashboard waren zunächst rein lesend — Anlegen ging nur über den Umweg
"Arbeitszeit". Beide Stellen haben jetzt einen ""-Button, der denselben
`AddEditWorkTaskDialog` öffnet wie die Aufgabenliste selbst (im Kurs-Dashboard mit der
aktuellen Gruppe vorbelegt). Damit das nicht zu drei fast identischen Kopien der
Gruppenlisten-/Kursliste-Ladelogik führt, wurde die Dialog-Öffnung in
`WorkTaskDialogHelper.ShowDialog` (`LehrerApp.Desktop/Views/Workload/`) extrahiert und wird von
`WorkTaskListView`, `GroupDetailView` (`GroupOverviewViewModel.OnAddGroupTask`) und
`DashboardView` (`DashboardViewModel.OnAddTask`) gemeinsam genutzt — gleiches
View-Code-Behind-Delegate-Muster wie überall sonst in dieser Schicht.
Bei der Gelegenheit auch das Auswahl-Panel der Gruppenliste (`GroupListView`) nachgezogen: Beim
Anwählen eines Kurses erschien bisher nur eine reine Sprung-Buttonliste ("BEREICHE") ohne jede
Kennzahl, und der Button für Tab 0 (Übersicht/Kurs-Dashboard, die oben gebaute Karten-Ansicht)
sowie Tab 3 (Mitarbeit) fehlten dort komplett — beide Tabs waren aus diesem Panel gar nicht
erreichbar. Ergänzt: ein "SCHNELLÜBERBLICK"-Block oberhalb der Buttonliste
(`GroupListViewModel.LoadQuickInfo`) mit nächster geplanter Stunde, nächster Klausur und den
(max. 3) nächsten offenen Aufgaben dieser Gruppe — bewusst dieselbe kompakte Auswertung wie die
obersten Karten des Kurs-Dashboards, hier nur ohne eigenen Tab-Wechsel. Sowie die beiden
fehlenden Buttons "📊 Übersicht" (Tab 0) und "✋ Mitarbeit" (Tab 3).
**Spätere UX-Vereinfachung (August 2026):** Dieses Auswahl-Panel wurde wieder entfernt, nachdem
die Gruppenansicht selbst bereits denselben Schnellüberblick als Übersicht-Tab anbot und dadurch
zwei nahezu identische Bereichsnavigationen nacheinander entstanden. Die Gruppenliste zeigt nun
responsive Karten; ein Klick öffnet unmittelbar den Übersicht-Tab der Gruppe. Bearbeiten,
Schuljahresübernahme, Archivieren/Reaktivieren und Löschen bleiben über ein Drei-Punkte-Menü an
jeder Karte erreichbar. Suche, Schuljahresfilter und Archivansicht bleiben erhalten.
### 6.2 Zeiterfassung
- [x] **6.2.1** Timer starten/stoppen mit Zuordnung zu Aufgabe oder Kategorie —
`TimeTrackingViewModel`. Bewusst ohne live mitlaufende Sekundenanzeige (keine
`DispatcherTimer`-Kopplung in die ViewModel-Schicht); während der Timer läuft, zeigt
"Läuft seit HH:mm Uhr" den Startzeitpunkt, die Dauer wird beim Stoppen aus der
Differenz berechnet (mind. 1 Minute).
- [x] **6.2.2** Manuelle Nacherfassung eines Zeitblocks (Datum, VonBis oder Dauer) —
`AddTimeEntryDialog`: entweder Dauer direkt eintragen oder Von/Bis angeben (daraus wird
die Dauer berechnet); mindestens eines von beidem ist Pflicht.
- [x] **6.2.3** Wochenübersicht der erfassten Zeit, Summen je Kategorie — Balken je Kategorie
(`CategoryTimeSummary`, `TimeEntry.Category` ist bewusst ein freier String statt
`TaskCategory`-Enum wie bei `WorkTask`, da das Domänenmodell das schon so vorgab; die
ComboBox schlägt trotzdem dieselben Kategorienamen vor). Nur die aktuelle Kalenderwoche,
keine Wochennavigation wie im Stundenplan — bei Bedarf später ergänzbar.
- [x] **6.2.4** Schätzung vs. tatsächliche Dauer je Aufgabe vergleichen — direkt in der
Aufgabenliste (6.1.1) als "X / Y min erfasst", sobald mindestens ein verknüpfter
Zeiteintrag existiert; dafür braucht `WorkTaskListViewModel` jetzt zusätzlich
`ITimeEntryRepository`.
**Nachtrag — Komfort-Funktion "Unterrichtszeit heute übernehmen":** Nutzer-Wunsch: Unterrichtszeit
soll nicht komplett automatisch erfasst werden ("weiß nicht, ob das rechtlich sinnvoll ist"),
aber ein Vorschlag mit Rückfrage ist erwünscht — man beginnt vor der ersten Stunde und geht nicht
sofort nach der letzten. Neuer Button "🕓 Unterrichtszeit heute übernehmen" in
`TimeTrackingView` (nur sichtbar, wenn die Gruppe heute laut Stundenplan überhaupt Unterricht
hat — `TimeTrackingViewModel.HasTeachingTimeSuggestionToday`). `ComputeTodaysTeachingWindow()`
ermittelt aus allen `TimetableSlot`-Einträgen des heutigen Wochentags (`ITimetableSlotRepository`)
und dem Stundenraster (6.2/4.2, `PeriodScheduleService`) die früheste Start- und späteste
Endzeit des Tages, zieht einen festen Puffer davor (`BufferBeforeFirstPeriodMinutes = 15`) und
danach (`BufferAfterLastPeriodMinutes = 10`) ab/dazu. Klick öffnet den bestehenden
`AddTimeEntryDialog` (6.2.2) mit Kategorie "Unterricht" sowie Start/Ende vorbefüllt — bewusst
weiterhin ein normaler, vom Nutzer bestätigter Nacherfassungs-Dialog, keine automatische
Buchung ohne Blick darauf. Neuer `TaskCategory.Teaching`-Wert (`TaskCategoryDisplay.Label`:
"Unterricht") ans Ende des Enums angehängt, um bestehende serialisierte Werte nicht zu
verschieben.
### 6.3 Auswertung
- [x] **6.3.1** Monats-/Jahresauswertung nach Kategorie und Gruppe (Diagramm + Tabelle) —
dritter Tab "Auswertung" (`WorkloadEvaluationViewModel`). Zeitraum wahlweise Monat
(Jahr+Monat) oder Schuljahr (nutzt `SchoolYearService`, 1.8.31.7. wie überall sonst in der
App — bewusst kein Kalenderjahr, um konsistent zur restlichen Schuljahres-Logik zu bleiben);
"Diagramm" als Balken je Kategorie/Gruppe (`ProgressBar`, gleiches Muster wie die
Wochenübersicht in 6.2.3), keine eigene Chart-Bibliothek.
- [x] **6.3.2** Abgleich mit der Pflichtstundenzahl — neuer `WorkloadSettingsService`
(`workloadsettings.json`, gleiches Muster wie `PeriodScheduleService`/
`SchoolCalendarSettingsService`). Bewusst **nicht** in den Einstellungen (Kapitel 12)
hinterlegt, sondern direkt auf dem Auswertungs-Tab editierbar: das Feld wird ausschließlich
dort verwendet, ein Sprung in eine andere Seite für eine einzelne Zahl wäre reine
Indirektion (siehe auch das Nutzer-Feedback zum "Stundenplan bearbeiten"-Knopf oben). Die
Pflichtstunden pro Woche werden auf die Wochenzahl des gewählten Zeitraums hochgerechnet und
der erfassten Ist-Zeit gegenübergestellt.
- [x] **6.3.3** Export der Arbeitszeitauswertung (siehe 11.2) — CSV enthält Zeitraum,
Gesamtzeit, optionalen Soll-/Ist-Abgleich sowie die Aufschlüsselung nach Kategorie und
Lerngruppe. Deutsche Dezimaldarstellung und Excel-kompatible UTF-8-Ausgabe mit BOM.
---
## 7. Schüler & Gruppen — Verfeinerungen
Grundfunktionen sind vorhanden ([StudentViewModels.cs](LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs),
[GroupViewModels.cs](LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs)).
Niveau-Zuordnung je Schüler (E/G/Förder, `GroupMembership.Niveau`) ist bereits umgesetzt, siehe
Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
### 7.1 Schüler
- [x] **7.1.1** Schülerverwaltung im Schülerdetail: Deaktivieren blendet den Schüler aus der
normalen Schülerliste aus und bewahrt alle historischen Daten; deaktivierte Schüler können
wieder aktiviert werden. Ein Verwaltungsdialog zeigt die Anzahl verknüpfter Gruppenzuordnungen,
Klausurergebnisse, Einzel-/Zeugnisnoten, Mitarbeit- und Dokumentationseinträge. Endgültiges
Löschen ist nur ohne solche Verknüpfungen möglich und zusätzlich im `StudentRepository`
abgesichert, damit kein anderer Aufrufer verwaiste Daten erzeugen kann.
- [x] **7.1.2** Suche in der Schülerliste mit Sofortfilter über ein gemeinsames Suchfeld für
Vorname, Nachname, vollständigen Namen oder Lerngruppe. Passende Gruppen werden einmalig
auf ihre Mitgliedschaften abgebildet, statt pro Schüler separate Repository-Abfragen
auszuführen.
- [x] **7.1.3a** Datenschutzneutrale Standardavatare aus dem bereits gespeicherten Geschlecht:
männlich/weiblich klar unterscheidbar, neutraler Fallback für divers oder nicht angegeben.
Anzeige in Schülerliste, Gruppenschülerliste und Schülerdetail; das Geschlecht kann dort
nachträglich korrigiert werden. Keine Bilddateien und keine zusätzliche Einwilligung nötig.
- [ ] **7.1.3b** Echtes Foto je Schüler (optional, lokal gespeichert) — bewusst ganz nach hinten
gestellt, da die nötigen Foto-Einwilligungen im schulischen Alltag voraussichtlich nur in
seltenen Sonderfällen vorliegen.
- [x] **7.1.4** Serienbrieffelder aus Kontaktdaten für Elternbriefe: Kontakte speichern eine
ausdrückliche Briefanrede (keine fehleranfällige Ableitung aus Beziehung oder Name) sowie
die bereits vorhandenen getrennten Adressfelder. DOCX-Vorlagen verwenden Word-
Inhaltssteuerelemente mit dokumentierten Tags wie `Letter.Salutation`, `Contact.Address`,
`Student.FirstName` und `Group.Name`.
- [x] **7.1.5** Sitzplan je Gruppe (Raster mit Drag & Drop), Sprung von Sitzplatz zur Bewertung.
Mehrere benannte Sitzpläne je Lerngruppe und Raum besitzen ein konfigurierbares Raster.
Schüler werden aus „Nicht zugeordnet“ auf Plätze gezogen, zwischen belegten Plätzen
getauscht oder durch Ablegen im freien Bereich wieder entfernt. Ein Klick auf einen
belegten Sitz öffnet die kompakte Sitzplatz-Schnelleingabe für alle aktiven
Mitarbeitsaspekte, Anwesenheit und Hausaufgaben. Die Eingabe unterstützt Maus sowie
Tastenkürzel und schreibt in die heutige Mitarbeitssitzung; falls noch keine existiert,
wird einmalig eine Sitzung „Sitzplan“ für heute angelegt.
**Bugfix (Nutzer-Feedback, "Geistereinträge"):** Diese Sitzung entstand ursprünglich schon
beim bloßen Initialisieren des Sitzplan-Tabs (`SeatingPlanTabViewModel.LoadTodaySessions`)
— und `GroupDetailViewModel.LoadGroup` initialisiert alle Tabs einer Gruppe sofort beim
Navigieren dorthin, unabhängig davon, welcher Tab tatsächlich angesehen wird. Ergebnis:
praktisch jede an dem Tag geöffnete Gruppe bekam eine leere "Sitzplan"-Sitzung, auch wenn nie
tatsächlich bewertet wurde. `LoadTodaySessions()` legt jetzt keine Sitzung mehr an; die
Erzeugung passiert erst über `EnsureTodaySession()` bei der ersten tatsächlichen Aktion
(Sitzplatz-Bewertung oder Situations-Tag).
**Nachtrag (Feature-Ergänzung, Nutzerwunsch): unbenutzte Plätze ausblenden.** Manche Räume
sind unregelmäßig bestückt (z. B. eine Klasse mit 3 Reihen, in zwei davon fehlt ein
Einzeltisch) — das feste `Rows × Columns`-Raster kennt aber keine per-Reihe abweichende
Spaltenzahl. Statt einer negativen "Schüler-ID" als Sonderfall (das war der ursprünglich
vorgeschlagene Ansatz, scheidet aber aus, da `SeatAssignment.StudentId` ein `Guid` ist, kein
vorzeichenbehafteter Zahlentyp) gibt es jetzt ein eigenes Feld `SeatingPlan.HiddenSeats`
(`List<HiddenSeat { Row, Column }>`, analog zu `SeatAssignment`) für Plätze ohne physischen
Tisch. Ein ausgeblendeter Platz bleibt Teil des Rasters (für die Spalten-/Reihenausrichtung
der `SeatingPlanPanel`-Layoutmathematik), wird im Ansichtsmodus aber nicht gerendert
(`SeatCellViewModel.ShowSeat`); im Bearbeitungsmodus bleibt er sichtbar (abgeblendet), mit
einem Umschalt-Button "Kein Tisch hier" / "Tisch einblenden" direkt auf dem Platz. Nur leere
Plätze lassen sich ausblenden (`CanToggleHidden`); Zuweisen/Verschieben eines Schülers auf
einen ausgeblendeten Platz wird sowohl im ViewModel (`AssignStudent`/`MoveSeat`) als auch im
Drag&Drop-Ziel-Check der View verhindert. `SeatingPlanRepository.Save` filtert
`HiddenSeats` zusätzlich auf das gültige Raster und blendet einen Platz automatisch wieder
ein, falls er (z. B. durch einen älteren, noch nicht aktualisierten Client per Sync)
trotzdem belegt wird — die Zuordnung eines Schülers ist wertvoller als das Ausblenden und
geht nie verloren. Keine Änderungen an `EventApplier`/`SyncEngine` nötig, `SeatingPlan` wird
bereits als Ganzes synchronisiert. Neue Tests: `RepositoryTests`
(Rasterfilterung, automatisches Wiedereinblenden bei Belegung), `SeatingPlanViewModelTests`
(Laden, Umschalten inkl. Persistieren, Sperre für belegte Plätze, verhinderte Zuweisung auf
ausgeblendete Plätze, `ShowSeat`/`CanToggleHidden` je nach Modus).
**Nachtrag (Nutzerwunsch): Klausur-Sitzplan mischen.** Neuer Button "🎲 Plätze für Klausur
mischen" neben Bearbeiten/Löschen — erzeugt aus dem aktuell gewählten Plan einen neuen,
unabhängigen Sitzplan mit gleichem Raster/Raum, aber zufällig vertauschten Insassen der
belegten Plätze (Fisher-Yates über `Random.Shared.Shuffle`, Platzkoordinaten bleiben
unverändert, nur wer wo sitzt wird neu gewürfelt). Der Ursprungsplan bleibt unangetastet
erhalten, damit er für den nächsten regulären Unterricht weiter nutzbar ist; der neu
angelegte Plan ist wie jeder andere `SeatingPlan`-Datensatz sofort über den bestehenden
Sitzplan-PDF-Export (11.4) druckbar, ohne weitere Anpassung. Test:
`SeatingPlanViewModelTests.ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan`
plus ein `CanExecute`-Test für die Bearbeitungsmodus-Sperre.
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
beim Review aber bestätigt. Die Löschung läuft transaktional und entfernt alle fachlich
abhängigen Datensätze (inkl. Zeugnisnoten, gruppenspezifischem Notenschema und
Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
- [x] **7.2.2** Gruppe ins neue Schuljahr übernehmen — eigener Dialog im Verwaltungsmenü mit
vorgeschlagenem Folgeschuljahr, editierbarem Gruppennamen und erhöhter Klassenstufe.
Fach, Gruppentyp, Bewertungssystem, Wochenstunden, Eigene-Klasse-Markierung und
Niveaudifferenzierung werden übernommen; ein gruppenspezifisches Notenschema optional.
Die Schülerliste bleibt korrigierbar, vorausgewählt sind nur aktive Schüler, deren
Mitgliedschaft bis zum Ende des Ausgangsschuljahres läuft. Frühere Austritte und reine
H1-Mitglieder sind sichtbar, aber abgewählt; deaktivierte Schüler nicht übernehmbar. Neue
Mitgliedschaften beginnen am 1. August als Ganzjahresmitgliedschaft, behalten das Niveau
und erhalten kein Austrittsdatum. Leistungs-, Mitarbeit-, Planungs- und Stundenplandaten
bleiben ausschließlich an der alten Gruppe; diese kann nach erfolgreicher Übernahme
automatisch archiviert werden.
- [x] **7.2.3** Schüler aus einer Gruppe austragen, ohne die Mitgliedschaft zu löschen — eigener
Dialog mit `CalendarDatePicker`, aktuellem Datum als Vorbelegung und frei wählbarem
Austrittsdatum. Setzt `GroupMembership.LeftAt`; Eintrittsdatum, Niveau und historische
Leistungen bleiben erhalten. Ehemalige Schüler können in der Gruppenansicht eingeblendet
und ihre Austragung zurückgenommen werden. Ein bereits gesetztes Austrittsdatum lässt sich
über denselben Dialog ändern.
- [x] **7.2.4** `JoinedAt`/`LeftAt` und `MembershipPeriod` werden über den gemeinsamen
`GroupMembershipService` in allen gruppenbezogenen Auswertungen berücksichtigt: Mitarbeit,
Mitarbeits-Assistent/-Aggregation, Klausur-Punkteeingabe, Sammelnoten, Notenübersicht und
Zeugnisnoten. Ein Schüler zählt nur an Tagen bzw. in Zeiträumen, in denen die Mitgliedschaft
tatsächlich aktiv war; der Austrittstag selbst zählt noch als zugehörig.
- [x] **7.2.5** Archivansicht abgeschlossener Schuljahre mit konsequentem Schreibschutz.
Archivierte Gruppen und sämtliche historischen Schüler-, Noten-, Klausur-, Mitarbeit- und
Planungsdaten bleiben sichtbar. Verändernde Bedienelemente sind deaktiviert und ein
deutliches Banner bietet die ausdrückliche Wiederaktivierung mit Bestätigung an. Zusätzlich
erzwingt die Datenschicht den Schutz für Mitgliedschaften, Klausuren/-ergebnisse, Einzel- und
Zeugnisnoten, Notenschema, Mitarbeitssitzungen/-einträge/-abschnitte, Einheiten, Stunden und
Stundenplan-Slots; ein übersehener Dialog kann den UI-Schutz daher nicht umgehen. Auch die
Gruppeneigenschaften können erst in einem getrennten Schritt nach der Reaktivierung geändert
oder gelöscht werden. Reine Ansichten und das Kopieren einer Unterrichtseinheit als Vorlage
in eine aktive Gruppe bleiben möglich.
- [x] **7.2.6** Einlagige Lerngruppen-Navigation: Gruppen werden als responsive Karten dargestellt
und öffnen mit einem Klick direkt den Übersicht-Tab. Die zuvor vorgeschaltete Detailspalte
mit einer zweiten Liste aller Bereiche wurde entfernt; die Tabs im Gruppendetail sind damit
die einzige Bereichsnavigation. Das Verwaltungsmenü sitzt direkt an jeder Karte und ist per
Automation-Namen zugänglich. `GroupListViewModelTests` prüfen Direktnavigation, kontextuelle
Bearbeitung, Suche und Archivierung.
- [x] **7.2.7** Schuljahresübergreifende Auswertung, erster Baustein: Klausurschnitt-Vergleich
mit der Vorgängergruppe (Nutzer-Feedback). Neues Feld `LearningGroup.PreviousGroupId`, von
`GroupRolloverService.RollOver` (7.2.2) automatisch auf die Ausgangsgruppe gesetzt — vor
dieser Ergänzung hochgestufte Gruppen bleiben unverknüpft, ein rückwirkendes manuelles
Verknüpfen ist bewusst nicht Teil dieses Durchgangs. Neue Karte "Vorjahresvergleich" im
Kurs-Tab "Übersicht" (`GroupOverviewViewModel`) zeigt den gepoolten Klausurdurchschnitt
(alle nicht-abwesenden `ExamResult`-Einträge über alle Klausuren der Gruppe, via
`GradingService.WeightedAverage`) dieses Jahres neben dem der verknüpften Vorgängergruppe;
nur sichtbar, wenn im Vorjahr tatsächlich Klausurergebnisse vorliegen. **Bewusst
zurückgestellt:** ein Vergleich, welcher Notenschlüssel bei welcher Aufgabenstruktur wie
gewirkt hat — `Exam.GradingKey` ist eine reine Momentaufnahme ohne Verweis auf die
verwendete `GradingKeyTemplate`, eine saubere Gruppierung dafür bräuchte zunächst diesen
Verweis.
### 7.3 Import
Nachträglich als erledigt markiert — die Umsetzung existierte bereits vollständig
(`StudentImportService`, `Core/Importing/`, `StudentImportDialogViewModel`), nur die Checkliste
war nie nachgezogen worden.
- [x] **7.3.1** CSV-Import von Schülerlisten. Abweichend vom ursprünglichen Plan keine manuelle
Spaltenzuordnung im Dialog, sondern drei fest hinterlegte, automatisch erkannte Formate
(`StudentMasterDataCsvImportHandler`, `LessonStudentListCsvImportHandler`,
`MarksPerLessonCsvImportHandler` über `ImportHandlerCatalog`) — deckt die real
vorkommenden Schulverwaltungs-Exporte ab, ohne Zuordnungs-UI.
- [x] **7.3.2** Dublettenerkennung mit Konfliktauflösung im Dialog: eindeutige Treffer werden
automatisch als "vorhanden" übernommen (`StudentImportResolutionKind.UseExisting`),
mehrdeutige Fälle als `ImportConflict` einzeln entschieden (bestehenden Schüler wählen /
neu anlegen / überspringen), inkl. "für alle übernehmen"-Abkürzung.
- [x] **7.3.3** Import-Vorschau (`StudentImportPreview`) mit Meldungsliste nach Schweregrad —
Fehler blockieren das Übernehmen (`CanApply`), dazu Zusammenfassung (neu / vorhanden / zu
ergänzen) und Lerngruppen-Zuordnungsübersicht vor dem endgültigen Übernehmen.
---
## 8. Kompetenzen
Katalogverwaltung und JSON-Import existieren in
[SettingsViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs),
Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-Prompt.md).
### 8.1 Katalogverwaltung
- [x] **8.1.1** Kompetenzen innerhalb eines Bereichs umsortieren (`SortOrder` bearbeitbar machen).
Bereiche und einzelne Kompetenzen lassen sich über Hoch-/Runter-Schaltflächen verschieben;
die Reihenfolge wird unmittelbar und lückenlos in `SortOrder` gespeichert.
- [x] **8.1.2** Katalog exportieren (JSON) — Gegenstück zum vorhandenen Import.
Bereits über „JSON exportieren“ in den Einstellungen umgesetzt; Fach, Klassenstufe,
Bereiche und Kompetenzen werden vollständig ausgegeben.
- [x] **8.1.3** Katalog von einer Jahrgangsstufe in eine andere kopieren.
Zielklassenstufe wird in einem Dialog gewählt; anschließend nutzt die Kopie dieselbe sichere
Vorschau, Konfliktauswahl und Merge-/Ersetzen-Logik wie der JSON-Import.
- [x] **8.1.4** Import-Konflikte behandeln: Merge statt Ersetzen anbieten.
Umgesetzt mit vollständiger Validierung vor dem Schreiben, Importvorschau, sicherem Merge
als Vorauswahl, Einzelentscheidung je Konflikt und bestätigungspflichtigem atomarem Ersetzen.
### 8.2 Verwendung im Unterricht
- [x] **8.2.1** Kompetenzen einer Unterrichtseinheit zuordnen (siehe 4.1.3).
Bereits im Unterrichtseinheiten-Dialog über die Kompetenz-Auswahl umgesetzt und in
`Unit.Competencies` gespeichert.
- [x] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4.
- [x] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft?
Eigener Tab je Lerngruppe unterscheidet geplante und behandelte Unterrichtseinheiten sowie
geplante und tatsächlich durchgeführte Klausuren pro Kompetenz.
### 8.3 Kompetenzorientierte Auswertung
- [x] **8.3.1** Kompetenzprofil je Schüler aus Klausuraufgaben-Ergebnissen berechnen.
Aufgabenpunkte korrigierter/zurückgegebener Klausuren werden nach zugeordneten Kompetenzcodes
aggregiert; abwesende Schüler und Aufgaben ohne positive Maximalpunktzahl werden nicht gewertet.
- [x] **8.3.2** Gruppenübersicht: durchschnittlicher Erfüllungsgrad je Kompetenz,
Identifikation von Wiederholungsbedarf. Gruppenmittel, Zahl der beteiligten Schüler und
Aufgabenwerte werden angezeigt; die Schwelle für Wiederholungsbedarf ist frei einstellbar.
- [x] **8.3.3** Kompetenzbericht je Schüler als Ausdruck/Export — umgesetzt als PDF-Druckvorlage
der Kompetenzübersicht (Details siehe 11.4, "Kompetenzbericht"): druckt die Analyse für
den aktuell im Tab ausgewählten Schüler.
---
## 9. Dashboard
Basis vorhanden in [DashboardViewModel.cs](LehrerApp.Desktop/ViewModels/DashboardViewModel.cs).
Kleiner Monatskalender ist bereits umgesetzt (Farbcodierung Unterricht/Klausur,
Hervorhebung "eigene Klasse" über `LearningGroup.IsOwnClass`, feste Kartenbreite/-position).
**Zwei Bugfixes am Kalender (Nutzer-Feedback):**
- Eine über "Sitzung erzeugen" (3.3.1) aus einer Stunde entstandene `ParticipationSession`
(`LessonId` gesetzt) erzeugte einen zweiten, redundanten Kalendereintrag neben der Stunde selbst
— dieselbe Unterrichtsstunde stand doppelt in der Tagesliste. `LoadCalendar()` überspringt
jetzt Sitzungen mit gesetzter `LessonId` (die zugehörige Lesson deckt den Tag bereits ab).
- Der "Meine Klasse"-Ring war nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
existierte — ein Tag, an dem laut Stundenplan (4.3) die eigene Klasse ansteht, aber noch keine
Lesson angelegt wurde (typischerweise "morgen"), zeigte den Ring fälschlich nicht. `LoadCalendar()`
projiziert `IsOwnClassDay` jetzt zusätzlich aus dem Stundenplan (`TimetableSlot`, abzüglich
Ferien/Feiertage und `SubstitutionKind.Cancelled`) — dieselbe Projektion wie bei den "ungeplanten
Stunden" (`LoadUnplannedLessons`), nur über den ganzen Kalenderraster statt nur die nächsten Tage.
- [x] **9.1** Heutige Stunden mit Uhrzeit und Raum anzeigen (Abhängigkeit zu 4.3, jetzt erfüllt).
**Umsetzung:** `DashboardViewModel.TodaysLessons` löst je Stunde den Raum über den zur
Stundennummer und dem heutigen Wochentag passenden `TimetableSlot` auf, die Uhrzeit aus
`Lesson.StartTime` (falls manuell gesetzt) oder sonst aus dem Stundenraster
(`PeriodScheduleService.GetTimes`) — gleiche Quellen, wie sie auch im Verlaufsplan-Editor
(4.2.2) und im Stundenplan verwendet werden. **Bewusst nicht dupliziert:** die reichhaltigere
Logik der "Heute"-Ansicht im Stundenplan selbst (Vertretung/Ausfall/Sondereinsatz-bewusst,
siehe 4.3 Nachträge) — das Dashboard zeigt hier nur die einfache, tatsächlich geplante
Stunde, für den vollständigen Tagesüberblick bleibt der Stundenplan zuständig.
- [x] **9.2** Direkter Absprung von einer Stunde in Mitarbeitserfassung bzw. Stundenplanung.
**Umsetzung:** Klick auf eine Stunde in "Heute" springt in die Lerngruppe, Tab "Mitarbeit"
(`DashboardViewModel.OnNavigateToLesson`, `NavigateToGroupDetail(id, 3)`) — bewusst anderes
Sprungziel als der bereits bestehende Klick in der Stundenplan-eigenen "Heute"-Ansicht
(springt dort auf Tab "Planung", siehe 4.4.2): vom Dashboard aus ist der naheliegende nächste
Schritt morgens eher die Mitarbeitserfassung als die Planung.
- [x] **9.3** Kachel "Anstehende Termine": Klausuren, Förderplan-Überprüfungen, Abgabefristen.
Bündelt geplante Klausuren, aktive Förderplan-Wiedervorlagen und offene Aufgaben mit
Fälligkeitsdatum für die nächsten 30 Tage; überfällige Einträge bleiben sichtbar und alle
Einträge springen zur passenden Lerngruppe bzw. zum Schüler.
- [x] **9.4** Kachel "Offene Korrekturen" mit Fortschritt (x von y Klausuren bewertet).
Durchgeführte und noch nicht zurückgegebene Klausuren zeigen den Fortschritt als Zahl und
Balken (`bewertete Arbeiten / am Klausurtag aktive Gruppenmitglieder`) und führen direkt in
den Klausuren-Tab der Lerngruppe.
- [x] **9.5** Kachel "Auffälligkeiten": Fehlzeitenüberschreitungen, Notenabfall, Versetzungsgefährdung.
Fehlzeiten nutzen den bestehenden konfigurierten Schwellenwert. Ein Notenabfall wird beim
Vergleich der letzten zwei mit den beiden vorherigen Einzelnoten erkannt (mindestens eine
Notenstufe bzw. drei Punkte); Versetzungsgefährdung basiert auf dem jüngsten gespeicherten
Zeugnisnotenstand (Note 5/6 bzw. höchstens 4 Punkte). Klick öffnet den betroffenen Schüler.
- [x] **9.6** Dashboard-Kacheln ein-/ausblendbar und in der Reihenfolge konfigurierbar.
„Bereiche anpassen“ bietet für jede Kachel Sichtbarkeit sowie Hoch-/Runter-Sortierung;
die Konfiguration wird lokal in `dashboardsettings.json` gespeichert und das Raster ohne
Lücken neu angeordnet.
- [x] **9.7** Automatische Aktualisierung beim Zurücknavigieren.
`MainWindowViewModel.GetDashboard()` führt beim Wechsel zurück zum Dashboard bereits den
`RefreshCommand` aus; damit werden Termine, Korrekturstände und Auffälligkeiten neu geladen.
- [x] **9.8** Kalender-Detailansicht: Tag im Monatskalender anklickbar/auswählbar, zeigt in
einem angrenzenden Feld die Termine dieses Tages (Unterricht, Klausuren, perspektivisch
Konferenzen/Sondertermine) mit Details und Sprungmöglichkeit in die jeweilige Ansicht.
Unterricht und Klausuren werden jetzt im Detailbereich der Kalenderkachel angezeigt und
verlinkt. Weitere Terminarten können später über das vorhandene `CalendarEventItem` ergänzt
werden, sobald dafür ein eigenes Termine-Modell existiert.
- [x] **9.9** Kachel "Ungeplante Stunden": erinnert an Stunden ohne Thema, für die noch kein
Verlaufsplan existiert. Nutzer-Wunsch: der zeitliche Vorgriff soll sinnvoll begrenzt sein
(morgens sollte wenigstens ein Stundenthema bereits bekannt sein) und einzelne Gruppen ohne
inhaltlichen Verlaufsplan (Klassenrat, Willkommenskreis) sollen sich ausnehmen lassen.
**Umsetzung:** `DashboardViewModel.LoadUnplannedLessons` läuft für jede Gruppe mit
`LearningGroup.RequiresLessonPlanning == true` (neues Feld, Default `true` — bestehende
Gruppen erhalten es beim LiteDB-Deserialisieren automatisch, keine explizite Migration
nötig) über die `TimetableSlot`-Einträge von heute bis morgen
(`UnplannedLessonsLookaheadDays = 1`, Nutzer hat sich im Dialog explizit für "Heute +
morgen" statt eines längeren Vorgriffs entschieden) und meldet jeden Termin, zu dem entweder
keine `Lesson` existiert oder deren `Topic` leer ist. Ferientage/Feiertage werden wie überall
sonst übersprungen (`IsFreeDay`, dieselbe Prüfung wie in `TimetableViewModel`/
`GenerateLessonSeriesDialogViewModel` — bewusst dupliziert statt in einen gemeinsamen
Service extrahiert, konsistent mit dem bestehenden Muster). Klick auf einen Eintrag springt
in die Lerngruppe, Tab "Planung" (`OnNavigateToUnplannedLesson`,
`NavigateToGroupDetail(id, 6)`), damit das Thema direkt ergänzt werden kann — bewusst
anderes Sprungziel als die "Heutige Stunden"-Kachel (9.2, Tab "Mitarbeit"). Deaktivierbar je
Gruppe über eine neue Checkbox "Benötigt Unterrichtsplanung" im Gruppen-Stammdaten-Dialog.
**Nachtrag zu 9.9 (Bugfix Doppelstunden-Erkennung):** Nutzer-Bug-Report: eine als Doppelstunde
geplante 3./4. Stunde (eine `Lesson` mit `LessonNumber = 3` und Thema, die laut Stundenraster
90 Minuten abdeckt, siehe Nachtrag zu 4.2.2) wurde auf dem Dashboard trotzdem als "4. Stunde noch
ungeplant" gemeldet, weil `LoadUnplannedLessons` je `TimetableSlot` stur auf eine `Lesson` mit
exakt derselben `LessonNumber` prüfte — für Periode 4 gibt es bei einer Doppelstunde aber bewusst
keine eigene `Lesson`. Neue Hilfsmethode `IsCoveredByEarlierDoppelstunde` wendet dieselbe
Rückwärts-Erkennung wie `LessonDialogViewModel.RecomputeTimeBudget` an: ausgehend von der
Vorperiode wird so lange rückwärts geprüft, wie der Stundenplan dort ebenfalls einen Slot hat;
trifft man auf eine `Lesson` mit Thema, gilt die Periode als abgedeckt, trifft man auf eine
`Lesson` ohne Thema, bricht die Suche ab (diese Periode bleibt eine eigene, ungeplante Stunde).
Eine eigene, direkt zugeordnete `Lesson` ohne Thema wird also weiterhin gemeldet — nur die
implizit mitabgedeckte Folgeperiode einer bereits geplanten Doppelstunde nicht mehr.
**Nachtrag zu 9.9 (Bugfix Stundenausfall):** Zweiter Nutzer-Bug-Report: eine laut Stundenplan
regulär stattfindende, aber per Vertretungs-Dialog als `SubstitutionKind.Cancelled` eingetragene
Stunde (4.3, "Stundenausfall") wurde trotzdem als ungeplant gemeldet, obwohl sie an diesem Tag
gar nicht stattfindet. `LoadUnplannedLessons` prüft jetzt zusätzlich `ISubstitutionEntryRepository.
GetByDate(date)` auf `Cancelled`-Einträge und überspringt betroffene Stundennummern vollständig —
gleiche Abfrage (Datum + Stundennummer, bewusst ohne Gruppenbezug, siehe `SubstitutionEntry.
PeriodNumber`-Doku) wie in `TimetableViewModel.BuildToday` für die "Heute"-Ansicht des
Stundenplans.
**Nachtrag Dashboard-Fokus (August 2026):** Oberhalb der konfigurierbaren Karten fasst ein neuer
Tagesfokus Unterricht, offene Aufgaben, Handlungsbedarf und Termine der nächsten 30 Tage in vier
Kennzahlen zusammen. Wetterdetails sind standardmäßig eingeklappt (amtliche Warnungen öffnen den
Bereich automatisch), die linke Inhaltsspalte erhält mehr Breite und leere reine Hinweis-Karten
(u.a. Fehlzeiten-Warnung, Förderplan-Wiedervorlage, Korrekturen und Auffälligkeiten) werden trotz
aktivierter Dashboard-Konfiguration automatisch ausgeblendet. Inhalte und Direktaktionen bleiben
unverändert erhalten; die Seite wird bei ruhiger Datenlage lediglich deutlich kürzer.
**Nachtrag Kursliste (August 2026):** Die Kachel „Meine Lerngruppen“ nutzt weiterhin ein echtes
mehrzeiliges `WrapPanel`, jetzt mit explizitem Zeilen-/Elementabstand und Mindestbreite statt einer
abschneidbaren Ein-Zeilen-Darstellung. Bei Auswahl eines Kalendertags werden Gruppen mit einer
tatsächlichen Lesson oder einem an diesem Tag aktiven Stundenplan-Slot zuerst angezeigt und mit
einem Akzentpunkt markiert; innerhalb dieser Gruppe sowie für den Rest gilt alphabetische Sortierung.
Ferien, Feiertage und vollständig ausgefallene Stunden werden dabei berücksichtigt.
---
## 10. Sync & Server
Vollständig funktionsfähig: Outbound-Publishing (10.1.6), Inbound-Apply (10.1.7),
Anhang-Sync (10.1.8), Client-UI (10.1.110.1.5) und Server-Härtung (10.2) sind umgesetzt.
Nur aktiv, wenn in den Einstellungen eine Server-URL konfiguriert und ein Login erfolgt ist.
Offen bleiben bewusst die Schlüsselverwaltungs-Punkte unter 10.3 (Gerätewechsel/-verlust) und
die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügbar).
### 10.1 Client
- [x] **10.1.1** Sync-Einrichtung in den Einstellungen: Server-URL, Login, Token speichern.
**Umsetzung:** Neuer Tab "Synchronisation" in den Einstellungen. Neu
`LehrerApp.Desktop/Services/SyncSettingsService.cs` (Muster `AiSettingsService`: Token
AES-256-verschlüsselt über `SyncCrypto`, eigener rein lokaler Schlüssel — ersetzt die
bisherigen Klartext-Helfer `AppBootstrapper.LoadServerUrl`/`SaveServerUrl` und die
unverschlüsselte `auth.token`-Datei). Neu `SyncAuthService` für den Login-HTTP-Aufruf gegen
`/api/auth/login`. Speichern/Anmelden startet die App neu (`AppBootstrapper.
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
Live-Re-Registrierungspfad.
**Nachtrag (Einstellungen-Reorg):** Der Tab-Balken war mit 13 Tabs oben (`TabPlacement="Top"`)
unübersichtlich lang geworden und wird mit weiteren Sync-Features noch länger. Umgestellt auf
`TabPlacement="Left"` (vertikale Liste, skaliert besser) und die Tabs in drei logische Gruppen
sortiert (Fachliches / Zeitplanung / System). Neuer Enum `SettingsTab` in
`SettingsViewModel.cs` ersetzt die bisherigen rohen `int`-Tab-Indizes bei
`MainWindowViewModel.NavigateToSettings`/`TimetableViewModel.OnNavigateToSettings` — beim
Umsortieren dabei ein bereits vorhandener Bug aufgefallen und mitbehoben: das Zahnrad-Symbol
im Stundenplan öffnete über den hartcodierten Index `7` tatsächlich den Tab "Datenschutz"
statt des in Kommentar/Tooltip beabsichtigten "Ferien & Feiertage" — mit benanntem Enum kann
diese Klasse von Fehler nicht mehr auftreten.
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
& angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
einen GET auf `/api/sync/status` mit optionalem Bearer-Token.
- [x] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
**Umsetzung:** Minimale Liste im Tab "Synchronisation" (kein Feld-Diff für v1 — die
Payloads sind clientseitig verschlüsselt, ein Diff würde ohnehin nur rohes JSON zeigen).
Zeigt Entität, Zeitpunkt und welche Seite gewonnen hat, mit "Gesehen"-Aktion. Neu
`EventQueue.MarkReviewed(id)` (bisher nur `AddConflict`/`GetUnreviewed`/`ConflictCount`,
kein Weg, einen Konflikt als gesehen zu markieren).
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`,
`SyncStatusBar` in `MainWindow.axaml`), nur hier noch nicht abgehakt.
- [x] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch
nicht abgehakt.
**Nachtrag (Bug beim Nutzertest gefunden):** Nach jedem App-Neustart zeigte die Statusleiste
fälschlich "Kein Server konfiguriert", obwohl Sync korrekt eingerichtet war — bis zum
nächsten Auto-Sync oder Klick auf "Jetzt synchronisieren". Ursache: `SyncEngine` feuert sein
erstes `StatusChanged` bereits im eigenen Konstruktor; da `SyncEngine` und
`SyncStatusViewModel` beide DI-Singletons sind und Letzterer Ersteren erst innerhalb der
eigenen Factory aus dem Container holt, läuft dieser erste Broadcast ab, bevor
`SyncStatusViewModel` überhaupt abonniert hat — der Status ging verloren, `StatusText` blieb
beim hartcodierten Default. Fix: `SyncStatusViewModel`-Konstruktor ruft nach dem Abonnieren
zusätzlich einmal `OnStatus(engine.Status)` mit dem bereits vorhandenen aktuellen Zustand auf
(`SyncStatusViewModel.cs`). Regressionstest `SyncStatusViewModelTests.cs`.
- [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
**Umsetzung:** `LiteDbContext.OnChange`-Hook (Sync-agnostisch, kein Verweis auf
`LehrerApp.Sync` aus `LehrerApp.Data`) — alle 27 Repositories rufen ihn nach Save/Delete auf,
Kaskaden (`GroupRepository.Delete` u.a.) und Batch-Methoden (`SaveMany`/`DeleteBySession`)
feuern genau ein Ereignis pro betroffener Entität statt pro Collection-Zugriff. In
`AppBootstrapper` an `SyncEventPublisher.Publish` gehängt, das den Hook in ein
AES-verschlüsseltes `EventQueue.Enqueue` übersetzt.
- [x] **10.1.7** Eingehende Sync-Ereignisse tatsächlich auf die lokale Datenbank anwenden.
Bisher komplett fehlender, in dieser Checkliste nicht erfasster Baustein: selbst mit 10.1.6
hätte `SyncEngine.PullAsync` empfangene Ereignisse nur zur Konflikterkennung genutzt, nie in
die lokale LiteDB geschrieben — ankommende Änderungen von anderen Geräten wären nirgends
sichtbar geworden.
**Umsetzung:** Neu `LehrerApp.Sync/EventApplier.cs` — entschlüsselt, dispatcht über eine
explizite `EntityType`-Tabelle, schreibt **immer direkt auf die rohe LiteDB-Collection**,
nie über eine Repository-Save/Delete-Methode (sonst würde der 10.1.6-Hook die gerade
angewendete Änderung als neues ausgehendes Ereignis re-enqueuen — Sync-Ping-Pong). Ein
gemeinsames Suppress-Flag wurde geprüft und verworfen (Timer-Thread vs. UI-Thread — ein Flag
könnte einen echten Nutzer-Save währenddessen verschlucken); der direkte Collection-Zugriff
ist zustandslos und dadurch korrekt. Kaskaden-Fälle nutzen dieselben internen
`LiteDbContext`-Hilfsmethoden wie die Repositories. Mit dediziertem Loop-Prevention-Test
abgesichert (`EventApplierTests`).
**Bekannte v1-Einschränkung:** weiche Geschäftsregeln (`ArchivedGroupWriteGuard`,
Namens-Eindeutigkeit bei Aspekten u.ä.) werden auf diesem Pfad nicht geprüft — nur harte
LiteDB-Unique-Constraints greifen noch und führen zum Überspringen des einzelnen Ereignisses.
Für Einzel-/Wenig-Geräte-Nutzung akzeptiert, siehe 10.3.4.
**Nachtrag (Bugfix — blockierter Sync bei jedem Fehler, unsichtbar und ungeloggt):**
Nutzer-Bug-Report — nach erfolgreicher Gerätekopplung kam eine neu angelegte `Lesson` auf dem
zweiten Gerät trotz mehrfacher manueller Synchronisation nicht an. Ursache im Code gefunden
(nicht live reproduziert, da keine zwei physischen Testgeräte zur Verfügung stehen):
`EventApplier.ApplyAsync` fing bisher nur `LiteException` ab — jede andere Ausnahme (z.B.
eine fehlerhafte Entschlüsselung/Deserialisierung eines einzelnen Ereignisses) fiel
unbehandelt aus der `foreach`-Schleife in `SyncEngine.PullAsync` heraus, **bevor**
`_queue.SetLastServerSeq()` erreicht wurde. Der nächste Sync-Versuch lud dadurch denselben
Ereignis-Batch erneut und scheiterte am selben Ereignis erneut — ein dauerhaft blockierter
Pull, bei dem selbst bereits im selben Batch erfolgreich angewendete Ereignisse nie als
erledigt markiert wurden. Verschärft durch fehlende Sichtbarkeit: weder `SyncEngine` noch
`EventApplier` protokollierten Ausnahmen — ein Fehlschlag zeigte sich höchstens als knapper
"Fehler: …"-Text in der kleinen Sync-Statusleiste, ohne Log-Eintrag (derselbe blinde Fleck wie
beim Pairing-Bugfix in 10.3.1).
**Umsetzung:** `EventApplier.ApplyAsync` fängt jetzt jede Ausnahme pro Ereignis ab (protokolliert
über neu injizierten `AppLogger`, übersprüngen statt den ganzen Batch abzubrechen) — ein
einzelnes fehlerhaftes Ereignis kann den Sync nicht mehr dauerhaft blockieren. `SyncEngine`
loggt seinerseits jeden Sync-Fehlschlag (HTTP wie generisch) vollständig statt nur die
knappe `ex.Message` im Status anzuzeigen. Neuer Regressionstest
(`SyncEngineTests.SyncNowAsync_KorruptesEreignisImPullBatch_BlockiertNachfolgendeEreignisseNicht`)
belegt mit einem absichtlich korrupten Ereignis vor einem gültigen im selben Pull-Batch, dass
Zweiteres trotzdem ankommt und `GetLastServerSeq()` fortschreitet statt stecken zu bleiben.
**Wichtig:** betrifft nur `LehrerApp.Sync`/`LehrerApp.Desktop` — der Server (`LehrerApp.Api`)
ist unverändert, ein Redeploy der API ist für diesen Fix nicht nötig, wohl aber ein
Neu-Build/Neustart der Desktop-Clients. Ob dies tatsächlich die vom Nutzer beobachtete Ursache
war, ist damit noch nicht bestätigt — nach dem Update sollte ein erneuter Testlauf entweder
funktionieren oder jetzt einen konkreten, geloggten Fehler liefern statt eines stillen
Fehlschlags.
**Nachtrag (Erfolgs-Protokollierung nachgerüstet):** Der Fix oben half nicht — beide Geräte
meldeten einen sauberen Sync ohne Fehler, die neu angelegte Lesson kam trotzdem nicht an.
Grund: bis dahin protokollierten `SyncEventPublisher`/`SyncEngine`/`EventApplier`
ausschließlich Fehlschläge — ein sauberes Log bewies also nur "nichts ist abgestürzt", nicht
"die Änderung wurde tatsächlich hoch-/heruntergeladen". Jetzt protokollieren alle drei auch
den Erfolgspfad: `SyncEventPublisher.Publish` beim Einreihen in die Outbox (mit
`SequenceNr`), `SyncEngine.PushAsync`/`PullAsync` mit Anzahl und Entitätstypen der
gesendeten/empfangenen Ereignisse sowie der vom Server bestätigten `ServerSequenceNr`, und
`EventApplier.ApplyAsync` bei jedem tatsächlich angewendeten Ereignis. Damit lässt sich beim
nächsten Testlauf anhand der Log-Dateien beider Geräte lückenlos nachvollziehen, an welcher
Stelle der Kette (Einreihen → Push → Server → Pull → Anwenden) eine Änderung tatsächlich
verloren geht — bisher war das reine Spekulation ohne Live-Testgeräte.
**Nachtrag (der eigentliche Bugfix — server-seitig in `EventStore.Pull`):** Die
Erfolgs-Protokollierung von oben hat sofort den Übeltäter gezeigt: Gerät A pusht 2 Ereignisse,
Server bestätigt (`ServerSequenceNr=420`, 0 Konflikte) — Gerät B pullt direkt danach und
bekommt "0 Ereignisse" zurück, obwohl Geräts A's Ereignisse eindeutig für Gerät B bestimmt
waren. Ursache: `EventStore.Pull()` gab als neuen "since"-Cursor bisher IMMER
`LastSeq(col)` zurück — den **globalen** Höchststand über ALLE Geräte hinweg, nicht die
höchste ServerSeq unter den tatsächlich in `Events` zurückgegebenen (nach
`DeviceId != requestingDeviceId` gefilterten) Ereignissen. `SyncEngine.PullAsync` übernimmt
diesen Wert 1:1 als neuen Cursor für den nächsten Pull. Hatte das anfragende Gerät selbst kurz
zuvor etwas gepusht (Push läuft in `SyncNowAsync` immer vor Pull — die eigenen Ereignisse
werden aus der Pull-Antwort korrekt herausgefiltert, da man sie nicht noch mal auf sich selbst
anwenden will), sprang der Cursor über die noch gar nicht abgeholten Ereignisse ANDERER Geräte
hinweg, sobald die eigenen neuer waren — sie wurden **dauerhaft** verpasst, ohne jeden
Fehler, da aus Sicht des Clients ein leeres Pull-Ergebnis ein völlig normaler, erfolgreicher
Zustand ist ("nichts Neues").
**Fix:** `ServerSequenceNr` in der Pull-Antwort ist jetzt `events.Count > 0 ?
events.Max(e => e.SequenceNr) : since` — der Cursor rückt nur noch so weit vor, wie
tatsächlich Ereignisse ausgeliefert wurden, nie darüber hinaus. Neuer Regressionstest
`EventStoreTests.Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_
UeberspringtFremdeEreignisseNicht` reproduziert exakt dieses Szenario (Gerät A pusht,
dann pusht Gerät B selbst etwas Neueres, dann pullt Gerät B mit einem alten "since") und
belegt, dass Geräts A's Ereignis jetzt zurückkommt und der neue Cursor bei dessen ServerSeq
steht statt beim (höheren) globalen Höchststand. **Wichtig:** betrifft `LehrerApp.Api` — ein
Server-Redeploy ist diesmal nötig (kein reiner Desktop-Client-Fix).
- [x] **10.1.8** Datei-Anhänge (Dokumentation) über den laufenden Sync mitschicken.
**Umsetzung:** Eigener, unverschlüsselt im JSON-Ereigniskanal nicht mitgeführter Binärkanal
(würde ihn für Fotos/Scans stark aufblähen) — neue Endpunkte
`POST/GET /api/sync/attachments/{storageId}` in `LehrerApp.Api`, neue
`EventQueue`-Warteliste für ausstehende Uploads, `AttachmentSyncer` (Upload, in
`SyncEngine.SyncNowAsync` nach dem Event-Push) und `EventApplier` (Download fehlender
Anhänge nach Anwenden eines `Documentation`-Ereignisses). Original-`StorageId` bleibt beim
Download erhalten (roher `db.Attachments.Upload`-Aufruf statt `IAttachmentStorage.Upload`,
das immer eine neue Id vergäbe).
**Nachtrag — Verallgemeinerung auf `IHasAttachments` (Bug, gefunden bei 4.2-Nachtrag
"Anhänge je Stunde"):** `SyncEventPublisher.Publish` und `EventApplier` (Gate +
Deserialisierung in `DownloadMissingAttachmentsAsync`) waren hart auf den Modelltyp
`Documentation` verdrahtet (`if (payload is Documentation doc)` bzw.
`evt.EntityType == nameof(Documentation)`). Als `Lesson` ein eigenes `Attachments`-Feld
bekam, hätte das bedeutet: die Metadaten synchronisieren (Lesson selbst synchronisiert
ohnehin vollständig), aber die eigentlichen Datei-Bytes nie — ein an einem Gerät
angehängtes Arbeitsblatt/eine Gefährdungsbeurteilung wäre auf dem zweiten Gerät nur ein
Verweis auf eine nicht existierende `StorageId` gewesen. Neues Marker-Interface
`IHasAttachments` ([Workload.cs](LehrerApp.Core/Models/Workload.cs), Property
`List<DocumentAttachment> Attachments`), implementiert von `Documentation` und `Lesson`.
`SyncEventPublisher.Publish` prüft jetzt `payload is IHasAttachments` statt des konkreten
Typs; `EventApplier` bekommt eine kleine, pro Entitätstyp erweiterbare
`AttachmentDeserializers`-Tabelle (analog zur bestehenden `Handlers`-Tabelle) statt der
festen `Documentation`-Deserialisierung. `AttachmentSyncer` (Upload-Seite) war bereits
generisch (kennt nur `StorageId`s, keinen Entitätstyp) und musste nicht geändert werden.
Neue Tests: `EventApplierTests.ApplyAsync_LessonMitFehlendemAnhang_LaedtIhnUeberHttpNach`,
neue Datei [SyncEventPublisherTests.cs](LehrerApp.Sync.Tests/SyncEventPublisherTests.cs)
(Documentation, Lesson, Entität ohne Anhänge).
- [x] **10.1.9** Schärfere Kollisionskontrolle beim Push (Konzeptgespräch nach dem
Pull-Watermark-Bugfix, siehe Nachtrag zu 10.1.7): statt der bisherigen 30-Sekunden-
Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst") trägt jedes
`SyncEvent` jetzt ein `BasedOnServerSeq` — die ServerSeq, auf der die lokale Änderung
aufbaut (`null` = Entität hier noch nie synchronisiert).
**Diskutierter Alternativvorschlag (verworfen):** Client-Zeitstempel (UTC-Ticks) statt
server-vergebener ServerSeq als Sync-Cursor. Verworfen, weil unsicher für Offline-First:
ein tagelang offline gewesenes Gerät würde beim Reconnect "alte" logische Zeitstempel
pushen; jedes andere Gerät, dessen Cursor während der Offline-Zeit bereits über diesen
Wall-Clock-Zeitpunkt hinausgelaufen ist, würde die Änderung dauerhaft und lautlos
überspringen. Der als Konfliktmarker vorgeschlagene Teil der Idee ("Stand-vor-Änderung
mitschicken") war dagegen richtig und ist die Grundlage von `BasedOnServerSeq` geworden.
**Umsetzung:**
- Server (`EventStore.Push`): nimmt ein Ereignis nur an, wenn `BasedOnServerSeq` exakt der
aktuellen ServerSeq der Entität entspricht (`GetLatestForEntity`/`LatestForEntity`);
liefert je akzeptiertem Ereignis die neu vergebene ServerSeq in `AssignedServerSeqs`
zurück. Neuer Endpunkt `GET /api/sync/entity/{entityType}/{entityId}` liefert den
aktuellen Server-Stand einer einzelnen Entität.
- Client (`EventQueue`): neue lokale Versionsverfolgung je Entität
(`GetKnownServerSeq`/`SetKnownServerSeq`, Collection `entity_versions`) — aktualisiert
sowohl beim Anwenden eingehender Ereignisse (`EventApplier.ApplyAsync`) als auch nach
erfolgreichem Push (`AssignedServerSeqs`).
- Client (`SyncEngine.PushAsync`): dedupliziert mehrere ausstehende Ereignisse derselben
Entität vor dem Senden auf das jüngste (Payload ist immer ein vollständiger Snapshot,
nie ein Delta — ältere Duplikate sind redundant und würden mit demselben, dann
veralteten `BasedOnServerSeq` unnötig kollidieren). `BasedOnServerSeq` wird erst
unmittelbar vor dem Senden aus der lokalen Versionsverfolgung gesetzt, nicht beim
Einreihen — zwischen Enqueue und Push kann ein Pull den bekannten Stand bereits
aktualisiert haben.
- **Ablehnungsbehandlung (Nutzer-Vorgabe: "Änderungen verwerfen ist das eine, aber man
sollte es wissen"):** bei einer Ablehnung wird der aktuelle Server-Stand sofort über den
neuen Endpunkt nachgeladen (statt auf den nächsten regulären Pull zu warten), nach
derselben Desktop-schlägt-Companion/neuerer-Timestamp-Politik wie `ConflictResolver`
aufgelöst (`ConflictResolver.DetermineWinner`, aus `TryResolve` herausgezogen, damit
Pull- und Push-Konflikte dieselbe Regel nutzen) und **immer** als `ConflictEntry` in der
bestehenden Konflikt-Review-UI (10.1.6) sichtbar gemacht — unabhängig davon, welche Seite
gewinnt. Bei "RemoteWon" wird der Server-Stand sofort angewendet und die lokale Änderung
verworfen; bei "LocalWon" bleibt das Ereignis unbestätigt in der Queue und wird beim
nächsten Sync-Versuch mit dem nun aktualisierten `BasedOnServerSeq` automatisch erneut
versucht.
- Neue Tests: `EventStoreTests` (Annahme/Ablehnung nach `BasedOnServerSeq`,
`AssignedServerSeqs`, `GetLatestForEntity`), `EventQueueTests`
(`GetKnownServerSeq`/`SetKnownServerSeq`), `SyncEngineTests` (Dedup, frisch gesetztes
`BasedOnServerSeq`, Versionsverfolgung nach Erfolg, RemoteWon- und LocalWon-Ablehnung).
- [x] **10.1.10** Zweiter, unabhängiger Pull-Cursor-Bug (Nutzer-Bug-Report direkt nach dem
Deploy von 10.1.9): "Client 1 hat übermitteln können. Der 2. PC erhält immer noch die
Mitteilung, dass es keine Änderungen für ihn gibt."
**Ursache:** `SyncEngine.PushAsync` setzte den lokalen Pull-Cursor bisher direkt aus
`PushResponse.ServerSequenceNr` — das ist bei `EventStore.Push` der **globale** Zähler über
ALLE Geräte NACH diesem Push (`seq = LastSeq(col)`, dann je akzeptiertem Ereignis
hochgezählt), nicht der Stand, den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der
Server zum eigenen Push-Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines ANDEREN
Geräts mit niedrigerer ServerSeq, sprang der Cursor beim eigenen Push darüber hinweg — der
direkt anschließende `PullAsync` (läuft in `SyncNowAsync` immer sofort danach) fragte dann
schon mit einem zu hohen "since" und bekam 0 Ereignisse, obwohl das fremde Ereignis nie
angewendet wurde. Ein zweiter, unabhängiger Bug mit demselben Symptom wie die
Wasserzeichen-Korrektur zu 10.1.7 — diesmal nicht in `EventStore.Pull` selbst, sondern im
Client, der sich mit dem PUSH-Antwortwert seinen eigenen (fixen) Pull-Cursor kaputtmachte.
**Fix:** Die Zeile `_queue.SetLastServerSeq(result.ServerSequenceNr)` in `PushAsync`
ersatzlos entfernt — `PullAsync` pflegt den Cursor bereits korrekt selbst, ausschließlich
anhand tatsächlich zugestellter Ereignisse (siehe 10.1.7). Neuer Regressionstest
`SyncEngineTests.SyncNowAsync_EigenerPushWaehrendFremdesEreignisNochAussteht_
LiefertFremdesEreignisTrotzdem` reproduziert exakt dieses Szenario (eigener Push während ein
fremdes Ereignis mit niedrigerer ServerSeq noch aussteht) und schlägt ohne den Fix fehl.
**Reparatur bereits betroffener Geräte:** Da der Cursor ein reines Vorwärts-Wasserzeichen
ist, kann ein durch diesen Bug bereits zu weit vorgerückter lokaler Stand sich nicht von
selbst heilen — der Fix verhindert nur künftige Fälle. Neuer Button "Vollständigen Sync
erzwingen" im Settings-Tab „Synchronisation" (`SettingsViewModel.SyncForceFullResync`)
setzt `EventQueue.SetLastServerSeq(0)` und stößt danach `SyncEngine.SyncNowAsync()` an —
sicher wiederholbar, da `EventApplier` jedes Ereignis idempotent per Upsert/Delete-by-Id
anwendet.
- [x] **10.1.11** Offene ViewModels aktualisierten sich nicht automatisch, wenn per Sync
eingehende Ereignisse angewendet werden (Nutzer-Bug-Report: eine per Sync empfangene neue
Stunde erschien im Stundenplan/Unterrichtsplanung erst nach Schließen und erneutem Öffnen
des Tabs — kein Datenverlust, nur ein reines Anzeigeproblem).
**Ursache:** `EventApplier.ApplyAsync` schreibt bewusst immer direkt auf die rohe LiteDB-
Collection (`db.Students.Upsert(...)` etc.), nie über eine Repository-Save/Delete-Methode —
sonst würde der `OnChange`-Hook erneut feuern und die gerade angewendete Änderung als neues
*ausgehendes* Ereignis re-enqueuen (Sync-Ping-Pong, siehe Kommentar am Klassenkopf). Genau
dieser direkte Collection-Zugriff bedeutet aber auch, dass kein ViewModel benachrichtigt
wird — viele ViewModels haben zwar bereits ein `Refresh()`/`Load...()`, das man manuell
auslösen kann (z.B. `PlanningViewModels.LoadLessons`, `GroupViewModels.LoadGroups`), aber
nichts ruft das automatisch auf, wenn `SyncEngine.PullAsync` im Hintergrund neue Daten
anwendet.
**Umsetzung** (zunächst für v1 zurückgestellt, dann doch umgesetzt, da leicht möglich): neues
`SyncEngine.DataChanged`-Ereignis, gefeuert nach jedem Pull mit mindestens einem Ereignis
sowie nach einer RemoteWon-Konfliktauflösung (dort wird ebenso lokal Daten angewendet).
`SyncStatusViewModel` reicht es unverändert durch (bereits als DI-Singleton überall
verfügbar, kein neuer Dienst nötig). `MainWindowViewModel` abonniert es und lädt darauf
**ausschließlich die gerade sichtbare Seite** (`CurrentPage`) über ihren eigenen, längst
vorhandenen Lade-Einstieg neu (Type-Switch: `DashboardViewModel`/`GroupListViewModel`/
`StudentListViewModel`/`TimetableViewModel`/`WorkloadViewModel` per `RefreshCommand`/
`Load()`, `GroupDetailViewModel` per `LoadGroup(Group.Id)`, `StudentDetailViewModel` per
`LoadStudent(Student.Id)` — letzteres nur wenn `!IsEditing`, um eine laufende
Inline-Bearbeitung von Name/Geschlecht nicht zu überschreiben; `GroupDetailViewModel` hat
kein Äquivalent, da dort ausschließlich über Dialoge bearbeitet wird). Bewusst **keine**
Navigation weg von einer offenen Detailansicht und keine neue Lade-Logik — nur die fehlende
Verdrahtung zwischen Sync-Empfang und den längst vorhandenen Refresh-Methoden. `SyncEngine.
DataChanged` feuert aus dem Sync-Timer-Hintergrundthread; `MainWindowViewModel` springt
deshalb wie `NotificationService.Show` per `Dispatcher.UIThread` zurück auf den UI-Thread.
Neue Tests: `SyncEngineTests` (`DataChanged` bei Pull mit/ohne Ereignisse, bei RemoteWon),
`SyncStatusViewModelTests` (Durchreichen). `MainWindowViewModel` selbst ist mangels
Testinfrastruktur für seine vielen ViewModel-Abhängigkeiten nicht direkt getestet — die
Refresh-Aufrufe delegieren aber ausschließlich an bereits anderswo getestete
Lade-Methoden der einzelnen ViewModels.
### 10.2 Server
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
([Endpoints.cs](LehrerApp.Api/Endpoints/Endpoints.cs)).
**Umsetzung:** `/api/auth/login` und `/api/auth/register` akzeptierten zuvor jeden
beliebigen Nutzernamen/Passwort und stellten ein gültiges 30-Tage-JWT aus (unadressierte
`// TODO`-Kommentare im Code) — konkrete, ausnutzbare Lücke bei echtem Deployment. Neu
`PasswordHasher` (PBKDF2, Salt pro Nutzer — kein neues NuGet-Paket, gleiche BCL-Technik wie
`SyncCrypto`) und `UserStore` (LiteDB-Collection `users`). `/api/auth/register` ersatzlos
entfernt (kein offener Registrierungs-Endpunkt für ein Einzel-/Familien-Deployment); neue
Nutzer werden per CLI angelegt (`dotnet LehrerApp.Api.dll create-user <name>`, dokumentiert
in `docker/README.md`), damit keine zusätzliche unauthentifizierte Angriffsfläche entsteht.
- [x] **10.2.2** Rate Limiting und Request-Größenbegrenzung.
**Umsetzung:** ASP.NET Cores eingebautes `Microsoft.AspNetCore.RateLimiting` (keine neue
Paketabhängigkeit). `/api/auth/login` speziell auf 5 Versuche/Minute begrenzt
(Brute-Force-Schutz), alle Endpunkte zusätzlich global auf 120 Anfragen/Minute je IP. Kestrel
`MaxRequestBodySize` auf 15 MB gedeckelt (Anhänge sind clientseitig ohnehin auf 10 MB
begrenzt, siehe `IAttachmentStorage.MaxSizeBytes`).
**Nachtrag (Bugfix — Pro-IP-Limit wirkte wie ein globales Limit für alle Geräte zusammen):**
Nutzer-Bug-Report — der zweite Gerätesync scheiterte wiederholt mit HTTP 429 (Too Many
Requests), obwohl der Nutzer den Endpunkt nach eigener Aussage nicht annähernd 120-mal pro
Minute manuell aufgerufen hatte. Ursache: das Pro-IP-Limit partitioniert nach
`HttpContext.Connection.RemoteIpAddress` — hinter dem Dokploy/Traefik-Reverse-Proxy ist das
für JEDE Anfrage dieselbe interne Proxy-Adresse, nicht die echte Client-IP, ohne
`ForwardedHeaders`-Middleware. Aus "120 Anfragen/Minute je Gerät" wurde dadurch faktisch "120
Anfragen/Minute für die gesamte Bereitstellung" — Auto-Sync (alle 5 Min.) auf beiden Geräten
plus manuelle Syncs plus die vorangegangenen Pairing-/Verbindungstests derselben Sitzung
teilten sich alle denselben Zähler und erschöpften ihn gemeinsam.
Behoben: `ForwardedHeadersOptions` (`XForwardedFor`/`XForwardedProto`) registriert und
`app.UseForwardedHeaders()` ganz am Anfang der Middleware-Pipeline eingehängt, `KnownIPNetworks`/
`KnownProxies` bewusst geleert — der Container ist nur über den Reverse Proxy erreichbar, der
unmittelbare Absender von `X-Forwarded-For` ist hier also vertrauenswürdig.
**Bekannte Einschränkung/Risiko:** `docker-compose.yml` published Port 5000 direkt
(`ports: ["5000:5000"]`). Ist dieser Port vom Internet aus erreichbar (abhängig von der
Docker-/Host-Firewall, nicht aus dem Compose-File allein ersichtlich), könnte ein Angreifer
Kestrel unter Umgehung von Traefik direkt ansprechen und `X-Forwarded-For` frei gefälscht
mitschicken, um das Rate-Limit vollständig zu umgehen. Nicht behoben, da die tatsächliche
Netzwerktopologie (Firewall-Regeln des Hosts) von hier aus nicht einsehbar ist — sollte vor
Ort geprüft werden.
- [x] **10.2.3** Serverseitiges Backup der Event-/Snapshot-Dateien.
**Umsetzung:** `docker/backup.sh` — Tar-Archiv von `./data` (Ereignis-Logs, Snapshots,
Anhänge, Nutzer), räumt Archive älter als 30 Tage auf, läuft direkt auf dem Host (kein
Container-Zugriff nötig), dokumentiert samt Cron-Beispiel in `docker/README.md`.
- [ ] **10.2.4** Docker-Setup in [docker/](docker/) verifizieren und dokumentieren.
**Teilweise:** `docker/README.md` um `create-user`-Flow, Backup und Rate-Limits ergänzt.
Ein tatsächlicher `docker compose up`-Durchlauf konnte in dieser Umgebung nicht verifiziert
werden (kein Docker verfügbar) — steht vor dem ersten echten Deployment noch aus.
**Nachtrag (echter Dokploy-Deploy-Versuch):** Build schlug fehl mit "A compatible .NET SDK
was not found" — `global.json` pinnt exakt `10.0.301` mit `rollForward: latestPatch`, das
erlaubt nur neuere Patches im selben Feature-Band (300er). Der treibende Docker-Image-Tag
`mcr.microsoft.com/dotnet/sdk:10.0` zeigte inzwischen auf `10.0.400` (Feature-Band 400) —
`latestPatch` lehnt das ab. Fix in `docker/Dockerfile.api`: Build-Stage exakt auf
`mcr.microsoft.com/dotnet/sdk:10.0.301` gepinnt. **Merke:** wird `global.json` je auf eine
neuere SDK-Version gehoben, muss dieser Tag mitgezogen werden, sonst bricht der Docker-Build
auf die gleiche Art wieder ab.
**Nachtrag 2 (Datenverlust bei Redeploy — ⚠️ war ein echter Vorfall):** `docker-compose.yml`
mountete `./data` (relativ zum Git-Checkout) als Bind-Mount. Dokploy klont das Repo bei
jedem Deploy frisch — ein per CLI angelegter Nutzer war nach einem reinen Routing-Redeploy
(keine Datenänderung beabsichtigt) spurlos verschwunden. Betraf nicht nur Nutzer, sondern
denselben `Api:DataPath` für Ereignis-Log/Snapshots/Anhänge — also potenziell **jeden**
Server-Datenbestand bei jedem Redeploy. Fix: `docker-compose.yml` auf ein Named Volume
(`api-data`) umgestellt, das unabhängig vom Git-Checkout im Docker-Daemon lebt und
`docker compose up --build --remove-orphans` übersteht. `docker/backup.sh` entsprechend auf
volume-basiertes Backup (Alpine-Hilfscontainer statt direktem Host-Pfad) umgeschrieben.
**Merke:** bei Git-basierten Deploy-Plattformen (Dokploy & vergleichbare) niemals Bind-Mounts
relativ zum Checkout-Verzeichnis für persistente Daten verwenden — nur Named Volumes oder ein
Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher.
- [x] **10.2.5** Login-`userId` war nicht stabil gegenüber Groß-/Kleinschreibung
(Nutzer-Bug-Report nach 10.1.10: "nur ein Nutzer im System", trotzdem bekam Client 2 auch
mit `since=0` durchgängig 0 Ereignisse zurück).
**Ursache:** `/api/auth/login` mintete den JWT-`userId` bisher aus dem roh eingegebenen
`req.Username`. LiteDBs Standard-Collation vergleicht Strings (und damit auch den
eindeutigen Index auf `Username` in `UserStore`) aber standardmäßig case-insensitive — durch
einen Test bestätigt: `VerifyPassword("Sebastian", ...)` authentifiziert erfolgreich gegen
ein als `"sebastian"` angelegtes Konto. Loggt sich ein zweites Gerät mit nur EINMAL anders
getippter Groß-/Kleinschreibung ein, meldet der Login trotzdem Erfolg, mintet aber eine
ANDERE `userId` — und `EventStore.GetCol(userId)` verwendet diese direkt als Dateiname für
den Server-seitigen Event-Speicher. Ergebnis: zwei komplett getrennte, nie überlappende
Datenbestände für ein und dasselbe, aus Nutzersicht einzige Konto — Push von Gerät 1 landete
unter `"Sebastian"`, Pull von Gerät 2 fragte unter `"sebastian"` nach und fand nichts, exakt
das beobachtete Symptom.
**Fix:** `UserStore.VerifyPassword(username, password): bool` ersetzt durch
`Authenticate(username, password): UserEntry?`, das bei Erfolg den KANONISCH gespeicherten
Nutzereintrag zurückgibt statt nur `true`. `/api/auth/login` mintet Token und `userId` jetzt
aus `user.Username` (dem gespeicherten Namen), nicht mehr aus `req.Username` — die
Groß-/Kleinschreibung beim Login hat damit keinen Einfluss mehr auf die serverseitige
Datenablage. Neuer Regressionstest
`UserStoreTests.Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen`.
**Betroffene Geräte:** einmal neu anmelden (Logout/Login in den Sync-Einstellungen) reicht,
damit beide Geräte fortan dieselbe `userId` verwenden — zusätzlich auf dem zuvor
"abgehängten" Gerät einmal "Vollständigen Sync erzwingen" (10.1.10) nutzen, damit es den
unter der korrekten `userId` bereits vorhandenen Bestand nachlädt.
**Nachtrag (Folgefehler nach dem Re-Login — 10.3.5):** Nach der Korrektur landete ein
Gerät in einer Endlosschleife: jeder Push desselben `Unit`-Ereignisses wurde mit einem 404
abgelehnt ("aktueller Server-Stand konnte nicht nachgeladen werden"). Ursache: die lokale
Versionsverfolgung je Entität (`EventQueue.entity_versions`, `BasedOnServerSeq`) UND der
Pull-Cursor (`GetLastServerSeq`) bezogen sich noch auf das ALTE (falsch geschriebene) Konto
— ServerSeq-Werte sind aber ausschließlich innerhalb des Event-Logs EINES Kontos
bedeutungsvoll. Der Push wurde deshalb zu Recht abgelehnt (`BasedOnServerSeq` passte nicht
zum neuen, leeren Konto), aber `GetLatestForEntity` kannte die Entität unter der neuen
`userId` gar nicht (404) — und `HandleRejectedAsync` gab bei einem 404 bisher einfach auf,
ohne den stale Cache-Eintrag zu bereinigen: derselbe Fehlschlag bei jedem weiteren Versuch.
Siehe 10.3.5 unten für den Fix.
### 10.3 Verschlüsselung
- [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
**Umsetzung:** Der Backend-Mechanismus (`SnapshotService`/`SyncCrypto`, Einmal-Code-Pairing)
existierte bereits, war aber an keiner Stelle im Client verdrahtet. Neuer Bereich "Gerät
koppeln" im Sync-Settings-Tab: ein Gerät erzeugt per `CreatePairingCode` einen verschlüsselten
DB-Snapshot + Code (Format `WORT-ZZ-WORT`, 24h gültig), das zweite Gerät gibt den Code über
`RedeemPairingCode` ein und übernimmt Datenbank + Sync-Schlüssel. Bestätigungsdialog vor dem
Einlösen (überschreibt die lokale Datenbank vollständig, alter Stand wird automatisch als
Backup gesichert). Kein QR-Code (nur Passphrase-Code) — für zwei eigene Geräte per Hand
abtippen ausreichend, QR-Code wäre erst für eine Companion-App relevant.
**Nachtrag (Bugfix unsichtbarer Fehlschlag beim Einlösen):** Nutzer-Bug-Report — beim
Einlösen eines Codes auf dem zweiten Gerät schloss sich die App kommentarlos, nach dem
Neustart war der alte Datenbestand weiterhin da. Ursache: `RedeemPairingCode()` ruft
`AppBootstrapper.RestartApplication()` bewusst in JEDEM Fall auf (auch bei Fehlschlag, siehe
Kommentar dort — `_dbContext` ist zu dem Zeitpunkt bereits disposed), aber direkt im Anschluss
an das Setzen von `PairingStatus`, ohne dass Avalonia je einen Frame damit rendern konnte —
ein Fehlschlag (falscher/abgelaufener Code, Server nicht erreichbar) war dadurch komplett
unsichtbar, zusätzlich wurde die Exception nirgends geloggt. Behoben: `SettingsViewModel`
bekommt jetzt `AppLogger` injiziert und protokolliert den Fehler; ein neuer, vom Code-Behind
gesetzter `OnShowPairingError`-Callback zeigt vor dem Neustart einen `ConfirmDialog`, den der
Nutzer aktiv wegklicken muss — das garantiert, dass die Fehlermeldung tatsächlich gelesen
werden kann, bevor der Prozess beendet wird. Zusätzlich `AppBootstrapper.RestartApplication()`
robuster gemacht: unter `dotnet run`/IDE-Debug zeigt `Environment.ProcessPath` auf den
SDK-Host (`dotnet`/`dotnet.exe`) statt auf die App selbst, ein Neustart ohne Argumente hätte
dort nur die dotnet-CLI-Hilfe gezeigt statt die App neu zu starten — die ursprünglichen
Kommandozeilenargumente werden in diesem Fall jetzt erneut mitgegeben.
**Nachtrag (eigentlicher Bugfix — Codes stimmten nie überein):** Der obige Dialog machte
den Fehlschlag zwar sichtbar, aber er trat *immer* auf — auch bei korrekt eingegebenem, noch
gültigem Code. Ursache, vom Nutzer selbst bis in den `catch`-Block von `SyncCrypto.
DecryptKeyWithCode` zurückverfolgt: `SnapshotService.CreateAndUploadAsync` lädt in zwei
Schritten hoch — Schritt 1 (ohne Schlüssel) holt vom Server einen frisch vergebenen Code,
Schritt 2 verschlüsselt den Sync-Schlüssel mit *diesem* Code und lädt erneut hoch. Beide
Schritte trafen serverseitig auf `SnapshotStore.Store()`, das aber bei **jedem** Aufruf
bedingungslos einen neuen `NewCode()` vergab und den vorherigen Eintrag löschte. Der dem
Nutzer am Ende angezeigte Code (aus Schritt 2) war damit nie derselbe, mit dem der Schlüssel
tatsächlich verschlüsselt wurde (aus Schritt 1) — jede Kopplung musste zwingend an der
Schlüssel-Entschlüsselung scheitern, unabhängig von Tippfehlern, Ablaufzeit oder
Netzwerkproblemen. Behoben: `SnapshotUploadRequest` bekommt ein optionales `Code`-Feld;
`SnapshotStore.Store()` aktualisiert bei vorhandenem, zum Nutzer passendem Code denselben
Eintrag (`Col.Update`) statt einen neuen mit neuem Code anzulegen; `CreateAndUploadAsync`
reicht den in Schritt 1 erhaltenen Code in Schritt 2 zurück. **Wichtig für den Rollout:**
diese Änderung betrifft `LehrerApp.Api` (Server) — ein reines Neubauen des Desktop-Clients
reicht nicht, der Server muss neu deployt werden.
- [x] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
**Ausgangslage:** Der Sync-Schlüssel verlässt nie den Server im Klartext (siehe
`SyncCrypto`-Kopfkommentar) — verliert das einzige Gerät mit dem Schlüssel `sync.key`
(Neuinstallation, defekte Platte), erzeugt `AppBootstrapper` bisher stillschweigend einen
neuen, unabhängigen Schlüssel; bereits synchronisierte Server-Daten werden für dieses Gerät
dauerhaft unlesbar, ohne dass der Nutzer etwas davon merkt. Eine ECHTE Wiederherstellung
danach ist nur möglich, wenn vorher proaktiv ein Rettungsanker angelegt wurde — dem Nutzer
zur Wahl gestellt (drei Stufen: nur Warnung / selbstverwalteter Code / dauerhafte
Server-Hinterlegung), Entscheidung: **selbstverwalteter Recovery-Code, kein neuer
dauerhafter Server-Speicher** für ein sicherheitsrelevantes Geheimnis.
**Umsetzung, zweiteilig:**
1. **Warnung.** `AppBootstrapper` erfasst `keyExistedBefore = File.Exists(keyPath)`
synchron VOR jedem möglichen Schreibzugriff und registriert `SyncKeyStatus
{ KeyWasRegenerated = !keyExistedBefore && syncSettings.IsLoggedIn }` — ein fehlender
Schlüssel bei einem noch nie eingeloggten Gerät ist der normale Erstlauf, kein Grund zur
Warnung. `SettingsViewModel` zeigt bei `SyncKeyWasRegenerated` ein rotes Banner oben im
Synchronisation-Tab mit der konkreten Handlungsempfehlung: solange noch ein anderes Gerät
den alten Schlüssel hat, DAMIT neu koppeln (10.3.1, bereits vorhanden); sonst hilft nur
ein zuvor gesicherter Recovery-Code.
2. **Wiederherstellungscode**, komplett offline, ohne Server-Beteiligung. Neue
`SyncCrypto`-Bausteine mit eigenem Salt (bewusst getrennt von `DeriveKeyFromCode` des
kurzlebigen Pairing-Codes, damit ein Schema-Wechsel am einen den anderen nicht
mitbeeinflusst): `GenerateRecoveryCode()` (15 Byte Zufall, 120 Bit, Base32 in 4er-Gruppen,
z. B. `ABCD-EFGH-…`), `EncryptKeyWithRecoveryCode`/`DecryptKeyWithRecoveryCode`
(PBKDF2-Ableitung, Code-Eingabe tolerant gegenüber Klein-/Großschreibung und fehlenden
Trennstrichen). Neuer `SyncKeyRecoveryService` (analog `SnapshotService`, aber ohne
HTTP): "Code erzeugen" verschlüsselt den aktuellen Schlüssel mit einem einmalig
angezeigten Code und lässt den Nutzer die verschlüsselte Datei selbst sichern (USB-Stick,
eigene Cloud o. ä.) — Datei und Code getrennt aufbewahren, erst beides zusammen ergibt
den Schlüssel. "Wiederherstellungscode einlösen" liest Datei + Code, schreibt den
entschlüsselten Schlüssel als neues `sync.key`, setzt vorsorglich Pull-Cursor und
Push-Versionsverfolgung zurück (falls zwischenzeitlich unter dem falschen Schlüssel schon
etwas synchronisiert wurde, analog 10.3.5) und startet die App neu — der im Speicher
gehaltene (falsche) Schlüssel ist ein DI-Singleton und kann zur Laufzeit nicht getauscht
werden, gleiches Muster wie `RedeemPairingCode`.
**Bewusst nicht getestet:** der erfolgreiche Redeem-Pfad endet in
`AppBootstrapper.RestartApplication()` (`Environment.Exit(0)`) — das darf ein Test nie
auslösen, deshalb sind nur die Zweige VOR diesem Aufruf ViewModel-getestet; derselbe Grund,
aus dem `RedeemPairingCode` ebenfalls keinen Erfolgstest hat. Neue Tests: `SyncCryptoTests`
(neu, bisher gab es keine dedizierte Testdatei — Schlüsselerzeugung, Verschlüsselungs-
Rundtrip, Code-Rundtrip inkl. Fehlertoleranz, eigenständige Ableitung ggü. dem
Pairing-Salt), `SyncKeyRecoveryServiceTests` (Datei-Rundtrip, falscher Code, ungültiges
Dateiformat), `SettingsViewModelTests` (Warnzustand, Code-Erzeugung inkl. Abbruch,
Redeem-Guards).
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus
Notenwert/-kommentar) trugen bisher personenbezogene Daten im Klartext über den
Companion/WebApp-Kanal (`PlainSyncEvent.Payload` ist unverschlüsseltes JSON, anders als der
AES-256-GCM-verschlüsselte Desktop-Kanal — der Server sieht den Klartext also nicht nur
während der Übertragung, sondern speichert ihn auch dauerhaft unverschlüsselt in der
EventStore-Collection). Aus `PlainEventStore.Allowed` entfernt — nur noch `WorkTask`/`Lesson`
(reine Lehrer-Planungsdaten ohne `StudentId`) laufen über diesen Kanal.
> ⚠️ **BEWUSSTE BLOCKADE, gehört zu geplanter Companion-App-Funktion:** Mitarbeitsnoten
> sollen laut Plan später über eine Companion-App erfassbar sein — das ist genau `Grade` mit
> `Category = Participation`. **Das geht mit dem aktuellen Stand nicht**, und zwar absichtlich:
> `PlainEventStore.Push` lehnt `Grade`/`ExamResult`-Events NICHT mit einem Fehler/4xx ab,
> sondern **schluckt sie still** — `Success = true`, das Event landet nur in
> `RejectedEventIds` statt gespeichert zu werden. Wer das nicht kennt, sucht stundenlang,
> warum Mitarbeitsnoten aus der Companion-App im Server nie ankommen. Bevor eine
> Companion-App Noten schreiben soll, muss zuerst 10.3.1 (Schlüsselaustausch) + eine echte
> Payload-Verschlüsselung für `PlainSyncEvent` gebaut werden — erst danach `Grade` wieder in
> `PlainEventStore.Allowed` aufnehmen.
- [ ] **10.3.4** Bekannte v1-Einschränkung aus 10.1.7: weiche Geschäftsregeln greifen beim Anwenden
eingehender Sync-Ereignisse nicht, nur harte LiteDB-Unique-Constraints. Bei mehreren eigenen
Geräten in Randfällen möglich, dass sich Datenstände leicht unterscheiden. Für v1 bewusst
akzeptiert (Einzel-/Wenig-Geräte-Nutzung) — falls das je zum echten Problem wird, müsste der
Event-Applier dieselben Validierungen wie die Repository-Save-Methoden durchlaufen, ohne
dabei erneut ein Sync-Ereignis auszulösen.
- [x] **10.3.5** Stale lokale Versionsverfolgung nach einem Kontowechsel (direkte Folge von
10.3.4 + 10.2.5, siehe Nachtrag zu 10.2.5).
**Fix, dreiteilig:**
1. `SyncEngine.HandleRejectedAsync`: ein 404 von `GET /api/sync/entity/{type}/{id}`
bedeutet, der Server kennt die Entität unter der aktuellen `userId` gar nicht — statt
endlos mit demselben Fehler zu scheitern, wird jetzt `EventQueue.ClearKnownServerSeq`
für genau diese Entität aufgerufen; der nächste Push behandelt sie korrekt als neu und
wird vom (für sie leeren) Konto angenommen. Selbstheilend, ohne Nutzeraktion nötig.
2. `EventQueue.ResetKnownServerSeqs()` (löscht die gesamte `entity_versions`-Collection) im
"Vollständigen Sync erzwingen"-Button (10.1.10) ergänzt — der Button setzt jetzt sowohl
den Pull-Cursor als auch die Push-Versionsverfolgung zurück.
3. `SyncLogin` erkennt einen Kontowechsel jetzt proaktiv: `SyncAuthService.LoginAsync`
liefert neben dem Token auch die KANONISCHE `userId` aus der Server-Antwort zurück (nicht
den beim Login eingegebenen Namen). Weicht sie von der zuletzt gespeicherten
(`SyncSettingsService.LastUserId`) ab, werden Pull-Cursor und Versionsverfolgung
automatisch zurückgesetzt — zukünftige Kontowechsel (oder Schreibweisen-Korrekturen wie
hier) lösen den Folgefehler dadurch gar nicht erst aus.
Neue Tests: `EventQueueTests` (`ClearKnownServerSeq`, `ResetKnownServerSeqs`),
`SyncEngineTests.PushAsync_AbgelehnterPushServerKenntEntitaetNicht_
LoeschtStaleCacheUndErholtSichSelbst` (Ende-zu-Ende: abgelehnter Push mit stale Cache →
Selbstheilung → zweiter Push erfolgreich), `SyncSettingsServiceTests` (`LastUserId`
persistiert/bleibt bei fehlendem `userId`-Argument unverändert).
**Verifikation:** `dotnet build LehrerApp.sln && dotnet test LehrerApp.sln` grün (591 Tests,
inkl. `EventApplierTests`, `ChangeHookCascadeTests`, `AttachmentSyncerTests`,
`AttachmentStoreTests`). Zusätzlich Live-Rauchtest gegen einen tatsächlich laufenden
`LehrerApp.Api`-Prozess (nicht gemockt): `create-user`-CLI, Login (richtig/falsch), Zugriffsschutz
auf `/api/sync/status`, entferntes `/api/auth/register` (404), Push/Pull-Roundtrip zwischen zwei
Geräte-Ids, Anhang-Upload/Download byteidentisch, Rate-Limit auf `/api/auth/login` löst nach 5
Versuchen tatsächlich 429 aus. **Nicht verifizierbar in dieser Umgebung:** zwei echte
Desktop-Instanzen gegeneinander synchronisieren (kein Mehrfach-AppData-Mechanismus vorhanden, GUI
nicht headless steuerbar) und ein echter `docker compose up`-Durchlauf (kein Docker verfügbar) —
beides vor dem ersten produktiven Zwei-Geräte-Einsatz empfehlenswert nachzuholen.
---
## 11. Export, Druck & Berichte
- [x] **11.1** Basisinfrastruktur: zentraler `ExportService` mit Formatdefinition,
Dateityp/Speicherdialog und plattformneutraler Ausgabe über Avalonia-Storage-Streams.
`CsvBuilder` erzeugt semikolongetrennte Dateien mit gemeinsamem Escaping; CSV wird mit
UTF-8-BOM geschrieben, damit Umlaute in Excel zuverlässig erkannt werden. Klausurauswertung
und Zeugnisnoten verwenden die Infrastruktur bereits statt eigener Dateidialog-Logik.
- [~] **11.2** CSV-Export für Notenlisten, Klausurauswertung, Arbeitszeit, Fehlzeiten —
Klausurauswertung, Zeugnisnotenliste und Arbeitszeitauswertung sind umgesetzt; allgemeine
Notenmatrix und Fehlzeitenbilanz fehlen noch.
- [x] **11.3** PDF-Erzeugung mit einheitlichem Layout.
**Umsetzung:** QuestPDF (Community-Lizenz, in `Directory.Packages.props` gepinnt),
referenziert **nur von `LehrerApp.Desktop`** — bewusst nicht von Core: QuestPDF zieht
SkiaSharp-Native-Binaries mit, die sonst transitiv (Core → Sync → Api) im
Server-Docker-Image landen würden. Neuer `PdfExportService`
(`Desktop/Services/PdfExportService.cs`) mit gemeinsamen Layout-Bausteinen (Kopfzeile mit
Titel/Untertitel/Stand-Datum, Fußzeile mit Seitenzahlen, A4 quer) und schlanken Druck-DTOs
statt ViewModels — die Erzeugung bleibt dadurch ohne Avalonia-Bezug testbar
(`PdfExportServiceTests`, Layout zusätzlich an gerenderten Muster-PDFs visuell geprüft).
Der Speicherdialog läuft über den bestehenden `ExportService` (neues Format
`ExportFormat.Pdf` + `ExportFile.Pdf`).
- [x] **11.4** Druckvorlagen: Notenliste, Klausur-Notenspiegel, Sitzplan, Kompetenzbericht,
Schülerdokumentation — alle fünf umgesetzt, jeweils als "Als PDF"-Button direkt in der
zugehörigen Ansicht, alle über `PdfExportService` (11.3) mit einheitlichem Kopf/Fuß-Layout.
**Sitzplan** (Kopfzeile des Sitzplan-Tabs): druckt das Raster mit Tafel-Banner an der
konfigurierten Position, Tischabständen als echte Lücken (Spaltenbreiten analog
`SeatingPlanPanel`), ausgeblendeten Plätzen als leere Rasterposition (Reihen verrutschen
nicht) und freien Plätzen dezent markiert — gedacht z. B. für Vertretungslehrer oder
Klausuraufsicht.
**Notenliste** (Werkzeugleiste der Notenübersicht): druckt exakt die aktuell angezeigte
Matrix (gewählter Zeitraum, Punkte-/Noten-Darstellung, Sortierung) mit Namens-, Noten-
und Schnitt-Spalten — gedacht z. B. für die Zeugniskonferenz.
**Klausur-Notenspiegel** (Fußleiste des Klausurauswertungs-Dialogs, neben dem
CSV-Export): Kennzahlen (bewertet/abwesend/Durchschnitt/Median/Schwellen je nach
Notensystem), Notenverteilung und Aufgabenanalyse jeweils mit Balkendiagramm; auffällig
schwache Aufgaben (< 50 %) rot markiert — gedacht z. B. für die Klausurrückgabe oder die
Dokumentationspflicht gegenüber der Schulleitung.
**Kompetenzbericht** (Kopfzeile der Kompetenzübersicht): druckt die Analyse-Tabelle für
den aktuell ausgewählten Schüler (Bereich/Code/Kompetenz, Unterrichts- und
Klausurabdeckung, Gruppen- vs. Schülerergebnis, farbige Empfehlung) samt
Schwellenwert-Angabe — erledigt zugleich 8.3.3.
**Schülerdokumentation** (Kopfzeile der Eintragsliste im Schülerdetail): druckt alle
Einträge chronologisch als Blöcke mit Typ/Datum, Inhalt und Tags; vertrauliche Einträge
und Entwürfe sind deutlich markiert — vertrauliche Inhalte werden bewusst MIT gedruckt
(der Ausdruck ist ein Werkzeug der Lehrkraft selbst, z. B. zur Vorbereitung eines
Elterngesprächs), die Markierung erinnert an die nötige Sorgfalt beim Umgang mit dem
Papier.
- [x] **11.5a** Einzelner Elternbrief als bearbeitbare DOCX-Kopie aus einer in Word gestalteten
Vorlage. Vorlagenverwaltung in den Einstellungen; das Original bleibt unverändert. Vor
Import und erneut vor jeder Erzeugung werden Inhaltssteuerelemente auch in Kopf-/Fußzeilen
geprüft. Kein Steuerelement, fehlender Tag und unbekannte Tags erzeugen Hinweise;
wahrscheinliche Tippfehler werden per Ähnlichkeitsprüfung deutlich mit einem konkreten
Korrekturvorschlag gemeldet. Fehlende verwendete Kontakt-/Gruppenwerte blockieren die
Erzeugung, damit fehlerhafte Briefe nicht unbemerkt vervielfältigt werden.
- [ ] **11.5b** Stapelerzeugung für eine ganze Lerngruppe mit Kontaktauswahl je Schüler und
Ergebnisübersicht. Bewusst nachgelagert; der validierte Einzelbrief bildet die Grundlage.
- [ ] **11.6** Vollständiger Datenexport eines Schuljahres (Archivierung).
---
## 12. Einstellungen & Stammdaten
Fächer- und Kompetenzverwaltung existiert bereits in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml), ebenso (neu, im Zuge
von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlaufsplans.
- [ ] **12.1** Lehrerprofil: Name, Kürzel, Schule, Pflichtstundenzahl.
- [ ] **12.2** Schuljahr-Einstellungen: Beginn/Ende, Halbjahresgrenze, Ferien
(`SchoolYearService` erweitern).
- [ ] **12.3** Standard-Notenschlüssel und Standard-Gewichtungsschema hinterlegen (2.3.3, 1.3.2).
- [~] **12.4** Darstellung: Hell/Dunkel-Modus, Schriftgröße.
**Umsetzung (Hell/Dunkel):** neuer Tab "Darstellung" in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml) mit Auswahl
Systemvorgabe/Hell/Dunkel, gespeichert über
[AppearanceSettingsService.cs](LehrerApp.Desktop/Services/AppearanceSettingsService.cs)
(gleiches Muster wie `DashboardSettingsService`). `App.axaml` folgte über
`RequestedThemeVariant="Default"` ohnehin schon der Systemvorgabe; neu ist die manuelle
Umschaltung zur Laufzeit über `App.ApplyTheme(...)` (setzt `Application.Current!.
RequestedThemeVariant`), ohne Neustart. Der gespeicherte Stand wird beim App-Start noch
vor dem ersten Fenster angewendet. **Bewusst nicht Teil dieser Umsetzung:** ein Audit der
ca. 23 XAML-Dateien mit fest codierten Hex-Farben statt Theme-Ressourcen — die
Umschaltung selbst funktioniert, aber einzelne Stellen dürften im Dunkelmodus optisch
nicht passen. Eigene, größere Aufgabe. Schriftgröße nicht umgesetzt.
**Nachtrag (August 2026) — Theme-Ressourcen, erster Schritt:** Neue
[SemanticBrushes.axaml](LehrerApp.Desktop/Styles/SemanticBrushes.axaml) definiert über
`ResourceDictionary.ThemeDictionaries` (Light/Dark) semantische Pinsel für Flächen
(`AppCardBackgroundBrush`, `AppListRowBackgroundBrush`, `AppChipBackgroundBrush`, …),
Akzent (`AppAccentTextBrush`, `AppFilterActive*Brush`, …) und Status
(`AppStatusOk/Info/Warning/DangerBrush`); eingebunden in `Application.Resources` von
`App.axaml`. Der Klassenlehrer-Bereich ist die erste vollständig umgestellte Ansicht — er
war mit ~36 Hex-Werten der stärkste Fall und im hellen Theme unbrauchbar (dunkle
Kartenflächen auf hellem Grund). Zwei Punkte, die beim Umstellen weiterer Ansichten
genauso gelten:
- Farben müssen per **`DynamicResource`** geholt werden — `StaticResource` löst einmalig
auf und bliebe beim Laufzeit-Wechsel stehen.
- Statusfarben dürfen **nicht als Hex-String aus dem ViewModel** kommen (bisheriges Muster
`StatusColor => "#FF5A67"`), aus demselben Grund. Stattdessen liefert das ViewModel eine
semantische Stufe (`ClassTeacherStatusKind`) plus `Is{Kind}Status`-Flags, und ein
Style-Selektor (`TextBlock.status.danger`, `Border.statusFill.warning`) setzt den Pinsel
— die Basisfarbe muss dabei ebenfalls aus einem Selektor kommen, sonst überstimmt sie das
lokale Attribut (siehe CLAUDE.md, Avalonia-XAML-Fallstricke).
Offen bleiben die übrigen Ansichten (Dashboard, Planung, Mitarbeit, Gruppen …).
- [ ] **12.5** Speicherort der Datenbank anzeigen und ändern.
- [ ] **12.6** Backup-Verwaltung (siehe 13.3) in den Einstellungen zugänglich machen.
---
## 13. Technische Basis
### 13.1 Tests
- [x] **13.1.1** Testprojekt `LehrerApp.Tests` angelegt (referenziert `LehrerApp.Core`, analog zu
[LehrerApp.Data.Tests](LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj) für die Datenschicht).
Noch ohne Testfälle — folgt mit 13.1.2ff.
- [x] **13.1.2** Unit-Tests für `GradingService` (Notenschlüssel-Grenzfälle, gewichteter Schnitt) —
41 Tests in [GradingServiceTests.cs](LehrerApp.Tests/GradingServiceTests.cs), deckt
`CalculateGrade`, `ValidateGradingKey`, `WeightedAverage`, `RoundToGrade`, `ParticipationGrade`,
`ValidateGradingScheme` und `CalculateReportGrade` ab.
- [x] **13.1.3** Unit-Tests für `SchoolYearService` (Jahreswechsel, Schuljahresgrenze) —
13 Tests in [SchoolYearServiceTests.cs](LehrerApp.Tests/SchoolYearServiceTests.cs).
Dafür `CurrentSchoolYear`/`RecentSchoolYears` um ein optionales `today`-Parameter
erweitert (Default weiterhin `DateTime.Today`), um den 1.-August-Wechsel deterministisch
zu testen, ohne bestehende Aufrufer zu ändern.
- [x] **13.1.4** Repository-Tests gegen eine In-Memory-LiteDB — `LiteDbContext` um einen
`Stream`-Konstruktor erweitert (`new LiteDatabase(stream)`, kein Datei-I/O nötig),
19 Tests in [RepositoryTests.cs](LehrerApp.Data.Tests/RepositoryTests.cs): u.a.
Kaskadenlöschung bei `GroupRepository.Delete`/`ExamRepository.Delete`/
`ParticipationSessionRepository.Delete`, Eindeutigkeitsprüfungen (`GroupMembership`,
`ParticipationEntry`), Fach-Validierung (Trimmen, Duplikate, Löschsperre bei Verwendung),
`GradingScheme`-Auflösung gruppenspezifisch vs. Voreinstellung.
- [x] **13.1.5** Tests für die Mitarbeits-Aggregation (3.2) und Zeugnisnotenberechnung (2.4) —
neues Testprojekt `LehrerApp.Desktop.Tests` (referenziert `LehrerApp.Desktop`, damit
ViewModel-Logik ohne echtes UI getestet werden kann), gemeinsame Fakes in
[Fakes.cs](LehrerApp.Desktop.Tests/Fakes.cs). 9 Tests für
[ParticipationGradeAggregationTests.cs](LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs)
(gewichtetes Mittel, "nicht bewertet" ≠ schlechte Note, Trend, Aspektgewicht 0, Halbjahresfilter,
Übernehmen-Update-statt-Duplikat) und 9 für
[ReportGradeCalculationTests.cs](LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs)
(Schema-Auflösung gruppenspezifisch/Voreinstellung/Fallback, Pflicht-Begründung beim
Übersteuern, Festschreiben friert den Stand ein, Rundungsregel).
- [x] **13.1.6** Tests für `ConflictResolver` — neues Testprojekt `LehrerApp.Sync.Tests`,
10 Tests in [ConflictResolverTests.cs](LehrerApp.Sync.Tests/ConflictResolverTests.cs)
(Desktop schlägt Companion immer, Gleichstand nach Zeitstempel, kein Konflikt bei
unterschiedlicher Entität/gleichem Gerät). Dabei zwei echte Fehler gefunden und behoben:
(1) `SyncEvent` hatte keine `[BsonId]`-Markierung auf `EventId` — LiteDB vergab dadurch
einen eigenen, unabhängigen `_id`, wodurch `EventQueue.Acknowledge()` nie etwas aus der
Queue löschte (verlorene Konflikte blieben für immer offen). (2) `ConflictResolver` verglich
`local.Timestamp` (aus LiteDB, `Kind=Local` mit verschobenen Ticks) direkt mit
`remote.Timestamp` (`Kind=Utc`) — `DateTime`-Vergleiche ignorieren `Kind` und vergleichen
nur rohe Ticks, wodurch die Gleichstand-Regel außerhalb von UTC+0 falsch entschied. Fix:
`.ToUniversalTime()` auf beiden Seiten vor dem Vergleich.
### 13.2 Fehlerbehandlung & Logging
- [x] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz —
[GlobalExceptionHandler.cs](LehrerApp.Desktop/Services/GlobalExceptionHandler.cs).
`Dispatcher.UIThread.UnhandledException` protokolliert Fehler aus Befehlen/Ereignis-Handlern.
Nur als wiederherstellbar eingestufte I/O-, Netzwerk-, Timeout- und Abbruchfehler werden mit
`Handled = true` behandelt und als Toast angezeigt. Unbekannte Programmier-/Zustandsfehler
dürfen die Anwendung kontrolliert beenden, statt mit möglicherweise beschädigtem Zustand
weiterzulaufen. `AppDomain.UnhandledException` und
`TaskScheduler.UnobservedTaskException` sind Sicherheitsnetze für Fehler außerhalb des
UI-Threads — dort kann ein bereits "IsTerminating"-Fehler nicht mehr verhindert werden,
wird aber vollständig protokolliert.
- [x] **13.2.2** Logging in Datei im App-Datenverzeichnis, mit Rotation —
[AppLogger.cs](LehrerApp.Core/Services/AppLogger.cs), tägliche Datei unter
`<AppData>/LehrerApp/logs/`, Dateien älter als 14 Tage werden beim Start gelöscht.
6 Tests in [AppLoggerTests.cs](LehrerApp.Tests/AppLoggerTests.cs).
- [x] **13.2.3** Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler —
[NotificationService.cs](LehrerApp.Desktop/Services/NotificationService.cs), Overlay unten
rechts in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml), blendet sich nach
3 s (Erfolg) bzw. 6 s (Fehler) automatisch aus. Aktuell an den globalen Exception-Handler
angebunden; bestehende Dialoge zeigen Erfolg/Fehler weiterhin über ihre eigenen
Statuszeilen (`StatusMessage`/`ValidationMessage`) — eine Umstellung dieser bestehenden
Stellen auf Toasts wäre ein eigener, größerer Umbau über viele Dateien hinweg und ist
bewusst nicht Teil dieser Aufgabe.
- [x] **13.2.4** Validierungsmeldungen einheitlich an den Eingabefeldern statt in Sammel-Labels.
Alle Dialoge mit Formularfeldern umgestellt: `AddStudentDialog`, `ContactEditDialog`,
`AddGroupDialog`, `StudentGradesDialog` (`GradeEditItem`), `CollectiveGradeDialog`,
`ExamDialog`, `AddSessionDialog`, `ParticipationWizardDialog` (Abschnitt-Formular),
Einstellungen (Fach anlegen, Notenschlüssel-Vorlage anlegen, Notenschlüssel-Editor).
Muster: pro Feld eine `{Feld}Error`-Property, `Save()` leert alle Fehler-Properties am
Anfang, sammelt alle Verstöße über eine lokale `valid`-Variable statt beim ersten Fehler
zurückzukehren, und bricht erst am Ende mit `if (!valid) return;` ab — so werden mehrere
Fehler gleichzeitig angezeigt. Bewusst als Sammel-Meldung belassen, wo die Meldung sich auf
eine Kombination mehrerer Felder bezieht statt auf ein einzelnes Feld: `GradingKeyValidation`
(Notenschlüssel-Tabelle als Ganzes), `GradingSchemeEditItem.ValidationMessage`
(Summe der drei Prozent-Felder muss 100 ergeben), `ReportGradeRow.ValidationMessage`
(Override-Wert + Begründung gehören zusammen), `CatalogValidation`
(Kompetenzkatalog-Panel: Fachauswahl, Bereichsname und JSON-Import teilen sich einen
Status-Bereich), `AddStudentToGroupDialogViewModel.ValidationMessage` (einzige Prüfung
"Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
### 13.3 Datensicherheit
- [x] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10) —
[BackupService.cs](LehrerApp.Core/Services/BackupService.cs). Reine Dateikopie nach
`<AppData>/LehrerApp/backups/`, läuft in `AppBootstrapper.BuildServices()` **vor** dem
Öffnen der Datenbank (unabhängig von Verschlüsselung, kein LiteDB-Handle nötig). Behält
standardmäßig die letzten 10 Backups, ältere werden entfernt. 6 Tests in
[BackupServiceTests.cs](LehrerApp.Tests/BackupServiceTests.cs).
- [x] **13.3.2** Wiederherstellung aus einem Backup über die Einstellungen — neuer Tab
"Sicherheit" in [SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)
listet vorhandene Backups mit Zeitstempel/Größe, "Wiederherstellen" fragt über einen neuen
generischen [ConfirmDialog](LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml) nach,
kopiert das Backup über die aktive Datenbankdatei und startet die App danach automatisch
neu (`AppBootstrapper.RestartApplication()`) — ein laufender LiteDB-Verbindung kann nicht
sicher "heiß" auf eine andere Datei umgehängt werden.
- [x] **13.3.3** Schema-Migration mit Versionsnummer — `LiteDbContext` schreibt die
Schema-Version in eine `meta`-Collection und führt Migrationsschritte nur noch aus, wenn
die gespeicherte Version dahinter liegt (`RunVersionedMigrations`), statt wie bisher bei
jedem Start erneut über alle Daten zu laufen. Die bestehenden (bereits idempotenten)
Migrationsschritte wurden als Version-1-Schritt gebündelt, für zukünftige
Schema-Änderungen ergänzt man einen weiteren `if (version < N)`-Block. 2 Tests in
[LiteDbContextTests.cs](LehrerApp.Data.Tests/LiteDbContextTests.cs).
- [x] **13.3.4** Optionale Verschlüsselung der lokalen Datenbank —
[DatabaseEncryptionService.cs](LehrerApp.Data/DatabaseEncryptionService.cs). Passwort
setzen/ändern/entfernen läuft über eine Kopie (neue Datei mit Zielpasswort anlegen, alle
Collections umkopieren, Datei austauschen) statt über `LiteDatabase.Rebuild(...)` mit
Passwort — das ist in LiteDB 5.0.21 nachweislich fehlerhaft (per Skript verifiziert: wirft
"this data file is encrypted" beim Rebuild einer unverschlüsselten Datei). Ist die
Datenbank verschlüsselt, fragt ein eigenes Fenster
([DbPasswordPromptWindow](LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml)) das
Passwort ab, **bevor** `AppBootstrapper.BuildServices()` (und damit das Öffnen der
Datenbank) läuft — siehe `App.axaml.cs`. Ändern/Entfernen des Passworts läuft über
Einstellungen → Sicherheit, jeweils mit Neustart der App danach. 4 Tests in
[DatabaseEncryptionServiceTests.cs](LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs).
- [x] **13.3.5** App-Sperre nach Inaktivität —
[AppLockService.cs](LehrerApp.Core/Services/AppLockService.cs) (eigenes, gesalzenes
PBKDF2-Passwort, unabhängig von einer eventuellen Datenbank-Verschlüsselung) +
[AppLockViewModel.cs](LehrerApp.Desktop/ViewModels/AppLockViewModel.cs) (Inaktivitäts-Timer,
Sperrbildschirm-Logik). Maus-/Tastatureingaben im Hauptfenster setzen den Timer zurück
([MainWindow.axaml.cs](LehrerApp.Desktop/Views/MainWindow.axaml.cs)); läuft er ab, blendet
ein Overlay in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) die gesamte
Oberfläche aus, bis das Passwort stimmt. Einstellbar (aktiv/inaktiv, Zeit, Passwort) über
Einstellungen → Sicherheit, wirkt sofort ohne Neustart. 2 Tests in
[AppLockViewModelTests.cs](LehrerApp.Desktop.Tests/AppLockViewModelTests.cs) (reine
Unlock-Logik; der zeitbasierte Inaktivitäts-Timer selbst ist nicht automatisiert getestet).
**Hinweis:** `RestartApplication()` startet den Prozess über `Environment.ProcessPath`
neu — im veröffentlichten Programm korrekt, im Entwicklungsbetrieb über `dotnet run`
zeigt `ProcessPath` auf den `dotnet`-Host statt auf die App, ein Neustart über die
Einstellungen ist dort also nur in einer veröffentlichten Build (`dotnet publish`)
sinnvoll zu testen.
- [x] **13.3.6** Sauberer Desktop-Lifecycle — beim Beenden wird ein LiteDB-Checkpoint ausgeführt
und anschließend der DI-Container samt Singleton-Datenbankverbindung entsorgt. Das initiale
Hauptfenster wird vom Avalonia-Desktop-Lifetime angezeigt; ein explizites `Show()` erfolgt
nur beim späteren Wechsel vom Passwortfenster zur Hauptansicht.
### 13.4 Codepflege
- [x] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`) — [CLAUDE.md](CLAUDE.md).
- [ ] **13.4.2** `AllRepositories.cs` und `IRepositories.cs` in Themendateien aufteilen,
sobald weitere Repositories dazukommen. Aktuell (319 bzw. 154 Zeilen für ~20 Repositories)
noch nicht unübersichtlich genug, um die Bedingung auszulösen — bewusst zurückgestellt.
- [x] **13.4.3** Wiederkehrende UI-Muster als wiederverwendbare Controls/Styles.
Bestandsaufnahme vor der Umsetzung ergab drei echte Duplikate (Suchleiste dagegen nur 3×
und jedes Mal eine simple `TextBox` ohne gemeinsames Chrome — Extraktion lohnt sich dort
nicht):
- Seiten-Kopfzeile (Titel 22px SemiBold + optionaler Untertitel) war identisch in
`GroupListView`, `StudentListView`, `GroupDetailView`, `StudentDetailView`,
`SettingsView` nachgebaut → neue
[PageHeader](LehrerApp.Desktop/Views/Shared/PageHeader.axaml)-Control, dort eingesetzt.
- Dialog-Titel (`FontSize="18" FontWeight="SemiBold"` als erstes Element) kam in 16
Dialogen jeweils inline vor → globale Style-Klasse `TextBlock.dialogtitle`
([App.axaml](LehrerApp.Desktop/App.axaml)).
- Leerlisten-Hinweis ("Noch keine …") kam an 10 Stellen vor, dabei war Opacity/FontSize
bereits leicht auseinandergedriftet (0.35 vs. 0.4, 12 vs. 13) → globale Style-Klasse
`TextBlock.emptyhint`, normalisiert alle Stellen auf einen Wert.
Absichtlich nicht angefasst: die vier Views mit eigenen lokalen `<Style Selector=>`-Blöcken
(MainWindow, DashboardView, StudentDetailView, ExamEvaluationDialog) sind jeweils
einzigartige Spezial-Visualisierungen (Kalenderzellen, Sparkline, Balkendiagramm,
Toast/Drawer) ohne Duplikate untereinander.
- [x] **13.4.4** Konverter und Styles zentralisieren. Bei der Bestandsaufnahme für 13.4.3 zeigte
sich: es gibt keine einzige eigene `IValueConverter`-Implementierung im Projekt — überall
werden bereits konsistent Avalonias eingebaute statische Konverter (`StringConverters`,
`BoolConverters`, `ObjectConverters`) verwendet. Nichts zu zentralisieren; die
Style-Duplikate wurden im Zuge von 13.4.3 behoben (siehe oben).
---
## 14. UX-Querschnitt
- [~] **14.1** Tastaturbedienung durchgängig: alle Hauptfunktionen ohne Maus erreichbar
(Vorbild: Mitarbeit-Schnelleingabe). **Hauptnavigation umgesetzt:** `Strg/⌘+1…8` öffnet
Dashboard, Lerngruppen, Schüler, Klausuren, Planung, Arbeitszeit, Klassenlehrer und
Einstellungen direkt; `Strg/⌘+K`, Pfeiltasten, Enter und Escape bedienen weiterhin die
globale Suche. Die vollständige Tastaturprüfung aller Fachdialoge bleibt offen.
- [x] **14.2** Globale Suche (Schüler, Gruppe, Klausur) über Tastenkürzel.
**Umsetzung:** `Strg+K` (Windows/Linux) bzw. `⌘K` (macOS) öffnet aus jeder Hauptansicht eine
modale Befehlspalette. `GlobalSearchViewModel` durchsucht lokal und ohne zusätzlichen Index
ausschließlich aktive Schüler/Lerngruppen sowie deren Klausuren und Aufgaben; Treffer springen direkt
ins Schülerdetail, Gruppendetail, den Klausuren-Tab oder die Aufgabenverwaltung. Pfeiltasten,
Enter und Escape bedienen die Palette vollständig ohne Maus. Gruppentreffer werden
zusätzlich über den zugeordneten Fachnamen gefunden und zeigen diesen
im Untertitel, damit gleichnamige Klassen/Kurse eindeutig bleiben. Bei leerer Suche stehen die
Schnellaktionen „Aufgabe anlegen“, „Erinnerung anlegen“, „Schüler anlegen“ und
„Lerngruppen-Eintrag“ bereit und
verwenden die bereits vorhandenen Dialoge samt Validierung. Zusätzlich ist der Einstieg als
zugänglich benannte Schaltfläche im Navigationsbereich sichtbar. Tests in
`GlobalSearchViewModelTests` decken Ergebnisarten, Navigation und Schnellerfassung ab.
- [~] **14.3** Rückgängig-Funktion für Löschvorgänge (mindestens Bestätigungsdialog überall).
**Umsetzung:** generischer, nicht-invasiver "Papierkorb light" statt einer
`IsDeleted`-Markierung pro Modell (Letzteres hätte jede Lese-Abfrage der betroffenen
Collections ändern müssen — bereits bei `Documentation.IsDeleted` sichtbar, das genau
dieses Muster nutzt und **keine** Wiederherstellungs-Oberfläche hat). Stattdessen:
[Trash.cs](LehrerApp.Core/Models/Trash.cs) definiert `TrashedItem` (Id, EntityType,
EntityId, JSON-Snapshot, Anzeigetext, Löschzeitpunkt) als eigene LiteDB-Collection.
`LiteDbContext.MoveToTrash<T>()`/`RestoreFromTrash<T>()` sind neue interne Hilfsmethoden,
die von den bestehenden `Delete(id)`-Methoden einzelner Repositories vor dem eigentlichen
Löschen aufgerufen werden — **keine neue Konstruktor-Abhängigkeit** für diese
Repositories. `Restore(Guid trashId)` (neu auf den betroffenen Repository-Interfaces)
dedserialisiert den Snapshot und ruft die **eigene** `Save()`-Methode auf, damit
Validierung und `OnChange`-Sync-Ereignis wie bei jedem normalen Speichern greifen. Neuer
Tab "Papierkorb" in
[SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)
([TrashViewModel.cs](LehrerApp.Desktop/ViewModels/Settings/TrashViewModel.cs)) listet alle
Einträge mit Typ, Löschzeitpunkt und Restlaufzeit; "Wiederherstellen" dispatcht anhand des
`EntityType` an das passende Repository. Aufbewahrung 30 Tage, Bereinigung
(`ITrashRepository.PurgeOlderThan`) läuft beim App-Start.
**Bewusst nur für 5 Entitäten ohne Kaskaden umgesetzt:** `SeatingPlan`, `Grade`,
`GradingKeyTemplate`, `WorkTask`, `TimeEntry` — jeweils hoher Alltagswert (Fehlklick beim
Löschen eines Sitzplans/einer Note ist besonders ärgerlich) bei überschaubarem Risiko
(keine abhängigen Collections). **Bewusst ausgeschlossen:** `LearningGroup` (14
kaskadierende Collections beim Löschen — deutlich riskanter, eigener Umbau nötig),
`Exam` (kaskadiert `ExamResult`), `ParticipationSession` (kaskadiert
`ParticipationEntry`), sowie alle übrigen, einfacheren Entitäten (u. a. `ShorthandCode`,
`SubstitutionEntry`, `SupervisionDuty`, `TimetableSlot`, `SchoolHoliday`,
`CompetencyDomain`, `Subject`, `Student`, `Unit`, `GroupMembership`,
`ParticipationAspect`, `ParticipationSection`, `AlternativeLessonPath`, `GradingScheme`,
`ReportGrade`) — der Mechanismus ist bewusst so gebaut, dass sich weitere Entitäten später
mit demselben Muster (drei Zeilen in `Delete()`, eine `Restore()`-Methode) ergänzen lassen.
Der Papierkorb ist **rein lokal, nicht Teil des Geräte-Sync** — `TrashedItems` löst nie
`db.OnChange` aus und landet damit nie im Sync-Ereignisstrom; nur das eigentliche
Save-/Delete-Ereignis der betroffenen Entität synchronisiert wie bisher. Bewusste
Vereinfachung, um keine neue Sync-Infrastruktur (Ereignistyp, Konfliktbehandlung für
Papierkorb-Einträge) einzuführen. Tests: 10 in
[TrashTests.cs](LehrerApp.Data.Tests/TrashTests.cs) (Löschen/Wiederherstellen je
Repository, Sortierung, Bereinigung), 6 in
[TrashViewModelTests.cs](LehrerApp.Desktop.Tests/TrashViewModelTests.cs).
- [~] **14.4** Ladeanzeigen bei längeren Operationen (Import, Sync, Export).
Schülerimporte blockieren die Liste während der asynchronen Dateianalyse mit Fortschritts-
anzeige und Status; der Sync-Button wechselt während des Abgleichs vom Vektoricon auf eine
indeterminierte Fortschrittsanzeige. Bereits vorhandene WebUntis-/KI-Ladevorgänge bleiben
erhalten. Eine einheitliche Anzeige für sämtliche PDF-/CSV-Exporte ist noch offen.
- [~] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen. Lerngruppen hatten
bereits „Erste Lerngruppe anlegen“; Schüler bieten jetzt „Ersten Schüler anlegen“ und
„Schülerliste importieren“, die Klausuren-Hauptseite erklärt den Anlageort und springt zu
den Lerngruppen. Fachspezifische Untertabellen werden schrittweise ergänzt.
- [~] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken.
**Umsetzung (Fenstergröße):**
[WindowSettingsService.cs](LehrerApp.Desktop/Services/WindowSettingsService.cs)
(gleiches Muster wie `DashboardSettingsService`) speichert Breite/Höhe/Maximiert-Status
beim Schließen und stellt sie beim Start wieder her
(`MainWindow.EnableWindowSizePersistence`). **Bewusst nicht gespeichert: die
Fensterposition** — bei wechselnder Monitor-Konfiguration (Laptop im Unterricht, externer
Monitor zuhause) könnte das Fenster sonst außerhalb des sichtbaren Bereichs landen. War die
App beim Schließen maximiert, bleibt die zuletzt bekannte "normale" Größe erhalten statt
der (dann bedeutungslosen) Bildschirmgröße. Tests in
[WindowSettingsServiceTests.cs](LehrerApp.Desktop.Tests/WindowSettingsServiceTests.cs).
**Spaltenbreiten: zurückgestellt.** Bestandsaufnahme aller 9 `DataGrid`s ergab: 5 haben
eine feste, in XAML deklarierte Spaltenliste (dort wäre Persistierung tatsächlich billig),
aber 4 bauen ihre Spalten im Code-behind dynamisch aus den Daten neu auf — je eine Spalte
pro Klausur bzw. Mitarbeits-Aspekt (`GradeOverviewTabView`, `ParticipationTabView`,
`ExamGradingDialog`, `AttendanceHomeworkQuickInputDialog`). Für diese vier gibt es keine
stabile Spalten-Identität, an der eine gespeicherte Breite verlässlich hängen könnte —
eine generische Lösung ist damit kein "billiger" Zusatz, sondern ein eigener, größerer
Umbau. Nicht in diesem Durchgang umgesetzt.
- [~] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht). Hauptnavigation und
Sync-Aktion haben mindestens 40 px große Ziele; besonders kleine Karten-/Menüaktionen wurden
vergrößert. Ein Test auf echter Tablet-Hardware bleibt offen.
- [~] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie
125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte
Drawer berücksichtigt bereits die schmalere verfügbare Breite mit reduziertem Außen-/
Innenabstand und einer eigenen Iconfläche, damit Windows-Emoji nicht abgeschnitten werden.
Das Hauptfenster kann jetzt bis 640×480 verkleinert werden und wechselt dadurch tatsächlich
in den Overlay-Drawer; zuvor verhinderte `MinWidth=900` exakt diesen Zustand. Aktionsleisten
in Dashboard, Schüler-, Gruppenliste und Gruppendetail umbrechen bzw. liegen unter dem Titel.
Ein echter Windows-DPI-Test bleibt offen.
- [~] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
Sichtbare Akzent-Fokusrahmen sind zentral für Buttons, Textfelder und Comboboxen definiert;
Hauptnavigation, Kalender-/Dashboard-Sortierung und zentrale Stundenplan-Iconaktionen haben
sprechende Automation-Namen und Tooltips. Die vollständige Prüfung aller Dialoge bleibt offen.
- [~] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
Emoji-Darstellung einführen. **Konkreter Bericht (Nutzer-Feedback):** Drawer-Icons erscheinen
auf einem Windows-PC gegen 22 Uhr einfarbig schwarz statt farbig — passend zum bekannten
Windows-Verhalten, dass Emoji-Codepunkte je nach Font-Fallback statt der farbigen
"Segoe UI Emoji" auf die einfarbige "Segoe UI Symbol" ausweichen können (typischer Auslöser:
ein zeitgesteuerter Hell/Dunkel-Wechsel des Systemthemas). Als Sofortmaßnahme pinnt
`TextBlock.navicon` in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) jetzt
explizit `FontFamily="Segoe UI Emoji,Segoe UI Symbol,Segoe UI"` — auf anderen Plattformen
folgenlos, da unbekannte Fontnamen einfach übersprungen werden. Nur eine Abmilderung für die
Drawer-Navigation, kein Nachweis der Ursache und keine Lösung für die übrigen Emoji im Rest
der App. **Hauptnavigation jetzt dauerhaft gelöst:** Suche und alle acht Navigationsziele
verwenden zentral hinterlegte `StreamGeometry`/`PathIcon`-Ressourcen; auch der Sync-Button
verwendet ein Vektoricon. Fachaktionen im restlichen UI enthalten teilweise weiterhin Emoji
und werden in einem späteren, separaten Austausch migriert.
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
über `MainWindowViewModel.ActiveNavItem` gesteuert.
- [x] **14.12** Tab "Übersicht" im Kurs (`GroupDetailView`, bislang nur Platzhaltertext) gefüllt —
neue [GroupOverviewTabView.axaml](LehrerApp.Desktop/Views/Groups/GroupOverviewTabView.axaml)
/ `GroupOverviewViewModel`. Bewusst kein Ersatz für die Detail-Tabs, sondern ein
"Was steht an"-Überblick aus bereits vorhandenen Daten mit Klick-Durchsprung in den
jeweiligen Tab: nächste geplante Stunde, nächste Klausur, Hinweis auf die letzte
Mitarbeitssitzung (warnfarben, wenn älter als 14 Tage — man vergisst Sitzungen in seltener
unterrichteten Kursen zuerst), offene Hausaufgaben-Kontrolle der letzten Stunde (dieselbe
Erkennung wie das Stundenplan-Badge, `TimetableViewModel.HasUnhandledHomework`) sowie offene
Entschuldigungen (`AttendanceStatus.ExcusePending`, direkt auflösbar) — letzteres dieselbe
Karte wie im globalen Dashboard (9), hier nur auf den einen Kurs eingeschränkt statt über
alle Gruppen aggregiert. **Nachtrag (Nutzer-Feedback):** zwei weitere Karten — offene
Dokumentationen (`Documentation.IsDraft`-Zählung, dieselbe wie das Badge in
`GroupDocumentationTabViewModel`) und auffällige Fehlzeiten. Dieselbe Quote wie die
Dashboard-Fehlzeiten-Warnung (`AttendanceBalanceService.WarningThresholdPercent`, 20 %),
aber optisch zurückhaltender (kein rotes Warnfall-Badge) und erst ab
`GroupOverviewViewModel.AttendanceMinSampleSize` (8) erfassten Anwesenheits-Terminen im
laufenden Schuljahr gemeldet — wer zu Schuljahresbeginn zweimal fehlt, hat rechnerisch schon
100 % Fehlquote, das ist noch kein auffälliges Muster, nur eine zu kleine Stichprobe.
Dieselbe Mindeststichprobe wurde im Zuge dessen auch in die Dashboard-Fehlzeiten-Warnung
selbst nachgezogen, siehe 5.2.3. **Zweiter Nachtrag (Nutzer-Feedback):** dritte Karte
"Fehlende Hausaufgaben" — Schüler mit `ParticipationEntry.HomeworkMissing` aus der letzten
Mitarbeitssitzung (bewusst nur die letzte, nicht die gesamte Historie, analog zur
Hausaufgaben-Kontrolle oben), mit dem konkreten Status je Schüler (`HomeworkDisplay.Label`,
z.B. "Nicht gemacht Nachreichen offen").
---
## 15. Auslieferung
- [ ] **15.1** Release-Build und Signierung für macOS.
- [ ] **15.2** Windows-Build prüfen (Zielplattform klären).
- [ ] **15.3** Versionierung und Changelog-Pflege.
- [ ] **15.4** Update-Mechanismus oder zumindest Versionsprüfung beim Start.
- [ ] **15.5** Kurze Bedienanleitung für den Eigengebrauch.
---
## 16. Datenmodell- und Architekturqualität
- [ ] **16.1** Referenzielle Integrität für alle Modellbeziehungen dokumentieren und je Beziehung
explizit `Cascade`, `Restrict`, `SetNull` oder Archivierung festlegen; Löschpfade mit
Transaktions- und Vollständigkeitstests absichern.
- [ ] **16.2** Domain-Validierung aus den Dialog-ViewModels in gemeinsam nutzbare Regeln/Services
überführen, damit Import, Sync und API dieselben Regeln durchsetzen.
- [ ] **16.3** Prüfen, ob Punkte, Gewichtungen und Prozentgrenzen von `double` auf `decimal` oder
skalierte Ganzzahlen migriert werden sollen; Rundungs- und Migrationsstrategie festlegen.
- [ ] **16.4** Redundant gespeicherte Bewertungswerte (`ExamResult.TotalPoints`, berechnete Note)
entweder ableiten oder zusammen mit einer unveränderlichen Version des verwendeten
Notenschlüssels als historischen Snapshot speichern.
- [ ] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei
Bedarf zusätzlich als historischen Snapshot speichern, damit Umbenennungen keine alten
Klausur- oder Sitzungsreferenzen brechen.
- [ ] **16.6** Navigation und manuelle Callback-Verdrahtung in `App.axaml.cs` langfristig durch
einen testbaren Navigationsdienst oder Messenger ersetzen; statischen Servicezugriff abbauen.
---
## Empfohlene Reihenfolge
Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbeitungsreihenfolge:
1. ~~**Kapitel 1** (Klausuren)~~ — erledigt.
2. ~~**Kapitel 3.2** (Mitarbeit-Aggregation)~~ — erledigt.
3. ~~**Kapitel 2** (Noten & Zeugnisnoten)~~ — erledigt.
4. ~~**Kapitel 13** (Technische Basis: Tests, Fehlerbehandlung, Datensicherheit, Codepflege)~~
erledigt (13.113.4 vollständig; 13.4.2 bewusst zurückgestellt, siehe dort).
5. ~~**Kapitel 5** (Schülerdokumentation)~~ — erledigt (5.2 als Auswertung des bestehenden
Anwesenheits-Trackings statt zweiter Erfassung, siehe dort).
6. ~~**Kapitel 4.1 + 4.2 + 4.3 + 4.4** (Unterrichtsplanung: Einheiten, Einzelstunden, Stundenplan,
Wochen-/Tagesansicht)~~ — vollständig erledigt, inkl. mehrerer Nachtrag-Iterationen aus
Nutzer-Feedback (Ferien-Pflege in den Einstellungen, Wochenraster mit Wochennavigation,
Ferientage ausgegraut, Serienerzeugung, Stundenraster mit Zeitbedarf-Rückmeldung, Aufsichten,
Vertretung/Sondereinsatz/Ausfall, anstehende Klausuren). Offen bleibt nur **4.5** (engere
Vernetzung Stundenplan ↔ Lesson-Planung — ausdrücklich vom Nutzer zurückgestellt, nicht aus
Unklarheit; Desktop bekommt dort perspektivisch Graph/Kompetenz-Verknüpfung/KI-Planung, ein
möglicher Companion-Client bleibt bewusst minimal).
**→ nächster sinnvoller Schritt: Kapitel 6, 10 oder 11.**
7. ~~**Kapitel 6** (Arbeitszeit & Aufgaben)~~ — vollständig erledigt (6.1 Aufgabenverwaltung
inkl. wiederkehrender Aufgaben, 6.2 Zeiterfassung, 6.3 Auswertung und CSV-Export).
**→ nächster sinnvoller Schritt: restliches Kapitel 11 (Export) oder 10 (Sync).**
8. **Kapitel 11** (Export, Basisinfrastruktur und erste CSV-Exporte erledigt), **10** (Sync) —
danach die noch offenen Berichte und produktiven Integrationsprüfungen.
9. ~~**Kapitel 4.5.94.5.19** (KI-gestützte Planungsunterstützung)~~ — in mehreren
Nutzer-Feedback-Iterationen weit über den ursprünglichen Punkt 4.5.9 hinaus ausgebaut:
Eingabeschema im Systemprompt (4.5.13), Umfangs-Umschalter (4.5.15), Prompt Caching (4.5.16),
Fortschrittsanzeige + Nachfassen (4.5.17), Feld-Diff bei geänderten Vorschlägen (4.5.14).
Bewusst zurückgestellt bleiben 4.5.14.5.8 und 4.5.10/4.5.11 (engere Stundenplan-Vernetzung,
Companion-Client, KI für Stundenplanung) sowie 4.5.18/4.5.19 (Diff-Vergleich zweier
KI-Entwürfe, granulare Phasen-Übernahme) — jeweils größere, eigenständige Umbauten.
**→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte
pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder
gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht.