Files
LehrerApp/TODO.md
T
adminandClaude Sonnet 5 3f8813df51 feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)
Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain,
add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und
delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb).

Lesson bekommt ein neues Competencies-Feld (analog zu Unit.Competencies) sowie
add_lesson_competency/remove_lesson_competency in LessonPlanTools.cs.

ICompetencyDomainRepository um GetBySubject() erweitert, um den Katalog eines Fachs ohne
bekannte Klassenstufe abzufragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:01:23 +02:00

379 KiB
Raw Blame History

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).
  • 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, 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

  • 1.1.1 Dialog AddExamDialog — Titel, Datum, Fach, Klausurnummer, Notizen. Ersetzt den TODO-Stub AddExam() in GroupViewModels.cs. Umgesetzt als ExamDialog/ExamDialogViewModel.
  • 1.1.2 Klausur bearbeiten/löschen (Kontextmenü im Klausuren-Tab, Löschen mit Rückfrage).
  • 1.1.3 Statuswechsel Planned → Conducted → Graded → Returned per Button, inkl. Farbcodierung des Status im DataGrid.
  • 1.1.4 Klausur aus bestehender Klausur duplizieren (Aufgaben + Notenschlüssel übernehmen, neues Datum) — für Parallelkurse.

1.2 Aufgabenstruktur (ExamTask)

  • 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).
  • 1.2.2 Automatische Anzeige der Gesamtpunktzahl, Warnung bei 0 Punkten.
  • 1.2.3 Optional: Teilaufgaben (a/b/c) — erfordert Modellerweiterung, vorher entscheiden.
  • 1.2.4 Zuordnung von Kompetenzen (CompetencyItem) zu einzelnen Aufgaben — Voraussetzung für die Kompetenzauswertung in 8.3.

1.3 Notenschlüssel

  • 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.
  • 1.3.2 Notenschlüssel als wiederverwendbare Vorlage speichern (neues Modell GradingKeyTemplate + Repository) und in Einstellungen verwalten.
  • 1.3.3 Live-Vorschau: Punktegrenzen absolut anzeigen (z.B. "Note 2 ab 45 von 60 P.").
  • 1.3.4 Validierung: lückenlose, absteigende Prozentgrenzen, keine Dopplungen (GradingService.ValidateGradingKey).

1.4 Punkteeingabe & Korrektur

  • 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).
  • 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.
  • 1.4.3 Kennzeichnung "Abwesend" (ExamResult.Absent) per Checkbox — Note zeigt "abwesend" statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
  • 1.4.4 Kommentarfeld pro Schüler (ExamResult.Comment).
  • 1.4.5 Validierung: Punkte < 0 oder > Maximalpunkte der Aufgabe wird rot markiert und nicht gespeichert (PointsCell.TrySetValue).
  • 1.4.6 Autosave nach jeder Zelle (kein expliziter Speichern-Button nötig).

1.5 Klausurauswertung

  • 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).
  • 1.5.2 Aufgabenanalyse: durchschnittlicher Erfüllungsgrad pro Aufgabe in Prozent, Kennzeichnung auffällig schwacher Aufgaben (Ø < 50 %, rot markiert).
  • 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.
  • 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.

  • 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ß").
  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • 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, GradeRepository ebenfalls. Der Tab "Noten" in GroupDetailView.axaml ist jetzt vollständig umgesetzt (siehe unten), ebenso der gleichnamige Tab im Schülerdetail (2.5).

2.1 Notenübersicht der Gruppe

  • 2.1.1 Matrix: Zeilen = Schüler, Spalten = alle Leistungen (Klausuren, Mitarbeit je Halbjahr, sonstige Noten). Zelle zeigt Note/Punkte.
  • 2.1.2 Spalte "Gesamt" mit gewichtetem Durchschnitt über GradingService.WeightedAverage().
  • 2.1.3 Sortierung nach Name / Gesamtnote, Umschalten Noten ↔ Punkte.
  • 2.1.4 Halbjahresfilter (H1 / H2 / Gesamtjahr), berücksichtigt GroupMembership.Period.

2.2 Einzelnoten pflegen

  • 2.2.1 Dialog "Note hinzufügen": Kategorie (GradeCategory), Wert, Datum, Gewichtung, Notiz.
  • 2.2.2 Note bearbeiten / löschen mit Historie (wer/wann geändert) — mindestens CreatedAt sichtbar.
  • 2.2.3 Sammelerfassung: eine Note (z.B. Hausaufgabenkontrolle) für die ganze Gruppe auf einmal.

2.3 Gewichtungsschema

  • 2.3.1 Neues Modell GradingScheme je Gruppe: prozentuale Anteile von Klausuren / Mitarbeit / sonstige Leistungen (z.B. 50/40/10).
  • 2.3.2 UI zur Bearbeitung, Validierung auf Summe 100 %.
  • 2.3.3 Voreinstellung je Gruppentyp (Class vs. Course) in den Einstellungen.

2.4 Zeugnisnote

  • 2.4.1 Berechnung der Zeugnisnote aus Schema (2.3) + allen Teilnoten, Rundungsregel konfigurierbar (kaufmännisch / pädagogisch abweichbar).
  • 2.4.2 Manuelles Übersteuern mit Pflicht-Begründung (pädagogischer Spielraum).
  • 2.4.3 Zeugnisnoten-Ansicht mit Sperren/Festschreiben zum Konferenztermin.
  • 2.4.4 Export der Zeugnisnotenliste (siehe 11.2).
  • 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 ParticipationSessions 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

  • 2.5.1 Verlaufsdiagramm pro Schüler über das Schuljahr (im Schülerdetail).
  • 2.5.2 Auffälligkeiten markieren: Abfall um ≥ 1 Note, Versetzungsgefährdung (Note 5/6).

Umgesetzt über GradeOverviewViewModels.cs (Matrix-Tab, Einzelnoten-Dialoge), ReportGradeViewModels.cs (Zeugnisnoten-Dialog) und die neuen Modelle GradingScheme/ReportGrade in 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.

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.

  • 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).
  • 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).
  • 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.
  • 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.
  • 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.

3.1 Aspekte konfigurieren

  • 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).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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

  • 3.2.1 Gewichtung je Aspekt konfigurierbar (z.B. Qualität 50 %, Quantität 30 %, Experiment 20 %).
  • 3.2.2 Berechnung einer Mitarbeitsnote je Halbjahr aus allen Sitzungen, Ausgabe als Note bzw. Punkte je nach GradingSystem der Gruppe.
  • 3.2.3 Umgang mit fehlenden Werten festlegen (nicht bewertet ≠ schlecht bewertet).
  • 3.2.4 Übernahme der berechneten Mitarbeitsnote als Grade mit Category = Participation (Anbindung an 2.1).
  • 3.2.5 Trendanzeige pro Schüler (Entwicklung über die Sitzungen hinweg).

Umgesetzt über den neuen Dialog "Ø Mitarbeitsnote" im Mitarbeit-Tab (ParticipationGradeViewModels.cs, 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

  • 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); 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).
  • 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).
  • 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), 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.
  • 3.3.4 Sitzungen mehrerer Gruppen im Kalenderüberblick. Erweitert den bestehenden Dashboard-Monatskalender (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ß.
  • 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.
  • 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, 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, 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)

  • 4.1.1 Listenansicht der Einheiten je Gruppe mit Status und Zeitraum (ersetzt den Platzhalter im Tab "Planung").

  • 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.
    
  • 4.1.3 Kompetenzen aus dem Katalog (siehe 8) einer Einheit zuordnen — Mehrfachauswahl.

  • 4.1.4 Einheit als Vorlage speichern und in eine andere Gruppe kopieren (inkl. Stunden, ohne Datumsbezug).

  • 4.1.5 Fortschrittsanzeige: gehaltene / geplante Stunden der Einheit.

4.2 Einzelstunden (Lesson)

  • 4.2.1 Stundenliste innerhalb einer Einheit, sortiert nach Datum/Stundennummer.
  • 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).
  • 4.2.3 Status Planned → Conducted setzen, Reflexionsfeld nach der Stunde.
  • 4.2.4 Stunden verschieben (z.B. bei Ausfall) — Folgestunden automatisch nachrücken.
  • 4.2.5 Stunden serienweise aus dem Stundenplan (4.3) erzeugen — neuer Button "Serie erzeugen" in der Stunden-Toolbar der Planung (GenerateLessonSeriesDialog.axaml, GenerateLessonSeriesDialogViewModel in 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.StartDateUnit.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 Lessons 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 (ersetzt den bisherigen Platzhalter), analog zu Mitarbeit/Noten als eigenes UserControl PlanningTabView.axaml mit eigenem PlanningTabViewModel (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, 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, LiteDbContext.cs (Schema-Version 3, MigrateLessonPhases() fasst alte Lessons verlustfrei in eine synthetisierte Phasen-Zeile zusammen) und 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 BsonDocuments 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 (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) 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 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, 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) — nach diesem Änderungsdurchgang muss gbu.php manuell auf den PHP-Server hochgeladen werden (siehe 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 (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 umgewandelt (~1800 Stoffe, ~760 KB).
  • Neuer Endpunkt 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 — 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 pdfplumbers 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.AgeRestrictionActivityRestriction (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

  • 4.3.1 Neues Modell TimetableSlot (Gruppe, Wochentag, Stunde, Raum) + Repository — Planning.cs, TimetableSlotRepository. 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.

  • 4.3.2 Wochenstundenplan-Ansicht als Raster mit Farbcodierung je Gruppe — neuer Navigationspunkt "Unterrichtsplanung" (bisher PlaceholderViewModel) zeigt jetzt 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).

  • 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.
    
  • 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.

  • 4.3.5 Schulferien und Feiertage — gesetzliche Feiertage werden je Bundesland berechnet (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). "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, SettingsView.axaml). Der Stundenplan selbst zeigt Bundesland und nächste unterrichtsfreie Tage nur noch lesend an (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, 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) sprang die Ansicht ursprünglich wieder auf die laufende Woche zurück — siehe "Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026)" am Ende dieses Abschnitts, wo dieses Verhalten auf Nutzer-Feedback hin wieder entfernt wurde.

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): 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) 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 TimetableSlots 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 TimetableSlots (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 UntisSlotMappings 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 (September 2026, Nutzer-Feedback) — Zeitraum-Datumsfelder ließen sich nicht ändern: Avalonias WinUI-artiger Spinner-DatePicker übernimmt Änderungen im Tag/Monat/Jahr-Flyout nur über einen separaten Häkchen-Button; ein Klick daneben verwirft sie stillschweigend. Alle verbliebenen Zeitraum-Datumsfelder mit diesem DatePicker auf CalendarDatePicker umgestellt (gleiche DateTimeOffset?-Bindung, kein Typwechsel nötig) — bereits das etablierte Muster für frei wählbare Einzeldatumsfelder in dieser Codebasis (WithdrawStudentDialog, CreateLetterDialog, 7.2.3), dort ohne dieses Problem, da ein Klick auf einen Kalendertag sofort übernimmt statt einen Bestätigungsschritt zu verlangen. Betroffen: WebUntisLessonAbsenceComparisonDialog.axaml, ClassTeacherRegisterView.axaml, ClassTeacherAbsencesView.axaml, WebUntisTimetableImportDialog.axaml und WebUntisDocumentationComparisonDialog.axaml.

Nachtrag (September 2026, Nutzer-Feedback) — CalendarDatePicker blieb nach der Umstellung leer und warf beim Auswählen eine InvalidCastException: Der erste Erklärungsversuch (Nullability der ViewModel-Eigenschaften) war falsch und hat das Problem nicht behoben. Per Reflection gegen die tatsächliche Avalonia-12.0.4-DLL verifiziert: CalendarDatePicker.SelectedDate (und DisplayDateStart/DisplayDateEnd) sind — vom WPF-Toolkit-Ursprung dieses Controls geerbt — DateTime?, nicht DateTimeOffset? wie beim eingebauten DatePicker. Avalonias TypeUtilities.TryConvert/TryConvertImplicit unterstützen keine automatische Konvertierung zwischen DateTime und DateTimeOffset (in keiner Richtung, nullbar oder nicht) — mit einem DateTimeOffset-Feld direkt gebunden bleibt CalendarDatePicker beim Anzeigen stumm leer und wirft beim Zurückschreiben (Klick auf einen Kalendertag) eine InvalidCastException. Das betraf nicht nur die fünf oben umgestellten Zeitraum-Felder, sondern genauso die bereits bestehenden CalendarDatePicker-Bindungen in WithdrawStudentDialog (SelectedDate, DisplayDateStart) und CreateLetterDialog (LetterDate) — nur bislang unbemerkt, weil dort niemand konsequent ein Datum ausgewählt hatte. Behoben mit einem neuen DateTimeOffsetToDateTimeConverter, an allen elf betroffenen Bindings in den sieben Dialogen/Views eingehängt (Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}). End-to-End mit einer echten kompilierten Ansicht in einer Avalonia.Headless-Instanz gegengeprüft (Anzeige des Anfangswerts und Zurückschreiben nach Kalenderauswahl, beides ohne Exception).

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.

  • "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.

  • "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ülerin anspringbar (ClassTeacherDetailsViewModel.StudentFilter). Zurückgestellte Ideen: Verknüpfung Roster-Zeile ↔ lokaler Student/Navigation zum Schülerdetail; ein Detail-Drill-down pro Schülerin (Heatmap gefehlter Einzelstunden, Verspätung/vorzeitiges Verlassen je Woche); eine KI-gestützte Zusammenfassung/Bewertung der Einträge (pro Schülerin 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ülerinnen der Klasse keinen gepflegten externen Schlüssel hat (in der Praxis vorkommend, z. B. frisch angelegt). externKey ist jetzt optional (UntisStudent.ExternKey/UntisStudentDto.ExternKeyint?) — betraf auch den bestehenden WebUntis-Klassenimport in der Kursansicht, der das aber schon immer über FirstNotEmpty beim Weiterimport toleriert hatte und deshalb nicht auffiel.
  • "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 synchronisiertUntisAbsenceCacheRepository/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.

  • 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).
  • "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).
  • "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 (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.
  • "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.axamlgood=#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.

  • "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.
  • "Klassenlehrer"-Feature — Verspätungen nicht mehr als fehlende Entschuldigung, sondern als eigenes Muster (August 2026): Nutzer-Feedback: "Offene Entschuldigungen" erinnerte auch bei Verspätungen, die aber i.d.R. gar nicht entschuldigungsfähig sind — die Erinnerung "Entschuldigung fehlt" war hier gegenstandslos.

    • ClassTeacherOpenExcuseRow.Build filtert jetzt zusätzlich !a.IsLate heraus — Verspätungen tauchen nicht mehr in der Karte "Offene Entschuldigungen" auf und lösen darüber auch keine Wiedervorlage mehr aus.
    • Stattdessen zweite Eskalationsstufe in der Mustererkennung: die bestehende Kurzfrist-Regel (≥2 Verspätungen in 7 Tagen → Warning-Hinweis ohne Aktion) bleibt, kommt aber erst zum Zug, wenn eine neue Jahresschwelle (LateYearEscalationThreshold = 5, Verspätungen seit Schuljahresbeginn) nicht bereits gerissen ist — dann Danger-Hinweis "Elterngespräch oder Brief erwägen" mit eigenem "+"-Button (CreateReminderForPatternNoticeCommand, legt wie bei "Offene Entschuldigungen" eine WorkTask an). Kernlogik aus dem bisher privaten BuildPatternNotices in ein neues öffentliches, statisches DetectLatePatterns gezogen (gleiches Muster wie DetectWeekdayPatterns), damit die Schwellenwerte ohne ViewModel-Instanz testbar sind. ClassTeacherPatternNotice hat dafür ein neues CanCreateReminder-Flag (Default false, steuert Sichtbarkeit des "+"-Buttons in der XAML).
  • "Klassenlehrer"-Feature — Fehlquote seit Schuljahresbeginn zu niedrig kurz nach Schuljahres- start (August 2026): Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag fehlten, zeigten trotzdem nur ~57 % statt der erwarteten ~100 % in ClassTeacherRosterRow.YearSummaryLabel. Ursache: der Nenner (SchoolDaysElapsed) zählte Werktage ab dem fest verdrahteten 1. August (SchoolYearService.SchoolYearStart) — die tatsächlichen Sommerferien enden je nach Bundesland/Jahr aber erst Wochen später, und kurz nach Schuljahresbeginn macht diese Restferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus (rechnerisch: absolut korrekte Fehlzeiten-Zähler, aber ein um mehrere Wochen zu großer Nenner). Mehrstufig gelöst, jede Stufe durch Nutzer-Feedback ausgelöst:

    1. Erster Versuch: EstimateTermStart (frühester Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten Unterrichtstag) — ohne zusätzlichen WebUntis-Abruf, aber nur eine Näherung.
    2. Nutzer-Hinweis: WebUntis kennt den echten Ferienkalender bereits (getHolidays-Bericht, in LehrerApp.WebUntis/WebUntisClient.GetHolidaysAsync schon implementiert, aber bis dahin nirgends im Desktop verdrahtet). Verdrahtet über WebUntisIntegrationService.GetHolidaysAsync (neuer UntisHolidayDto, gleiches Muster wie GetSchoolYearsAsync), gecacht (nicht über die LiteDB-Tabellen von UntisReportCacheService — deren heißes/kaltes Fenster ist auf sich laufend ändernde Fehlzeiten zugeschnitten, Ferien ändern sich dagegen innerhalb eines Schuljahrs praktisch nie — sondern einfacher tagesgenauer Cache direkt in WebUntisSettingsService: CachedUntisHoliday-Liste + HolidaysFetchedAt, unverschlüsselt, kein Geheimnis anders als iCal-URL/API-Zugangsdaten in derselben Datei). Neue ClassTeacherOverviewViewModel.CountSchoolWeekdays zieht Ferienzeiträume von der Werktagszählung ab.
    3. Nutzer-Verifikation deckte auf: getHolidays liefert für dieses Konto nie einen Sommerferien-Eintrag — geprüft anhand der tatsächlich gecachten Antwort (179 Einträge, 11 Jahre Historie ab 2015): Herbst-/Weihnachts-/Osterferien und einzelne bewegliche Ferientage sind lückenlos dabei, aber kein einziger Juli-/August-Zeitraum, in keinem der 11 Jahre. Vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen liegen (die bei 1.8./31.7. enden) statt "in" einem davon — WebUntis scheint sie deshalb keinem Schuljahr zuzuordnen. CountSchoolWeekdays (Ferien innerhalb eines Zeitraums abziehen) kann diese Lücke also grundsätzlich nicht schließen, unabhängig von Caching oder Implementierung. EstimateTermStart bleibt deshalb zusätzlich bestehen (behebt die Sommerferien-Lücke am Startpunkt), CountSchoolWeekdays läuft ab diesem geschätzten Starttag (behebt Herbst-/Weihnachts-/Osterferien & bewegliche Ferientage innerhalb des restlichen Jahres — oben unter "Trend & Fehlquote" noch als bewusste Vereinfachung dokumentiert, für den Teil jetzt erledigt). Beide Kombinationen als Regressionstest festgehalten (CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein) mit den vom Nutzer nachgezählten echten Werten (13 Schultage seit 13.08.2026).
    • Herkunft des Nenners jetzt direkt im Tooltip nachvollziehbar statt nur intern verrechnet: ClassTeacherRosterRow.TermStart ("seit dd.MM.") und HolidayWeekdaysExcluded ("X Ferientage abgezogen") — Lehre aus diesem Vorfall, bei dem der falsche Wert sonst erneut unbemerkt geblieben wäre.
    • AppLogger (optional, DI) protokolliert jeden GetHolidaysAsync-Aufruf: bei Erfolg Name + Zeitraum jedes geladenen Ferieneintrags, bei Fehlschlag die Exception — nächster Diagnoseschritt wäre sonst wieder nur stilles Rätselraten gewesen. Neue Abhängigkeiten WebUntisIntegrationService, AppLogger? in ClassTeacherOverviewViewModel (beide bereits als Singleton registriert, nur Konstruktor-Injection ergänzt). Nicht behoben, weil mit den vorhandenen Daten nicht erkennbar: Kolleg*innen, die die Anwesenheitsliste nur sporadisch führen, drücken die Quote auf dieselbe Weise (fehlende Fehlzeiten-Einträge an Tagen mit tatsächlichem Unterricht) — von echter Anwesenheit ist das aus dem WebUntis-Fehlzeitenbericht allein nicht unterscheidbar, dafür bräuchte es Daten darüber, ob für eine Stunde überhaupt eine Anwesenheitsprüfung stattfand.
  • "Klassenlehrer"-Feature — Klassenbuch-Tab als Dokumentations-Hub (September 2026): Nutzer-Feedback: der Klassenbuch-Tab zeigte bisher nur den WebUntis-Bericht anderer Lehrkräfte — die eigene Dokumentation zu Schüler*innen der Klasse (Gespräche, Vorkommnisse, Förderpläne, …) hatte dort keinen Platz, obwohl der reale Arbeitsablauf beides mischt. Erwogen wurde ein Merge beider Datensätze in eine Liste, verworfen: ClassTeacherClassRegisterRow (flache, ID-lose WebUntis-Berichtszeile) und Documentation (LiteDB-Entität mit typspezifischen Unterdaten, Anhängen, Vertraulichkeits-Freigabe, Tags) sind strukturell zu verschieden — ein Merge hätte genau die vom Nutzer selbst befürchtete fummelige Konverter-Klasse gebraucht, für zwei Dinge, die inhaltlich verschieden sind (was eine Kollegin notiert hat vs. was ich selbst dokumentiert habe). Stattdessen ein Umschalter ClassTeacherDetailsViewModel.ShowOwnDocumentation zwischen "Klassenbuch (Untis)" und "Eigene Dokumentation", mit Zahlen-Badges links/rechts (Gesamtzahl je Quelle, in Rot hervorgehoben bei kritischen WebUntis-Kategorien bzw. eigenen Einträgen mit dem Tag "Kritisch"/IsDraft-Status "Nacharbeiten") — dieselben Badges zusätzlich klein am "Klassenbuch öffnen"-Button der Übersicht (ClassTeacherOverviewViewModel.BuildOwnDocumentationCounts). Die eigene Dokumentation gibt es nur lokal, nicht in WebUntis — Documentation.StudentId lässt sich der (rein WebUntis-basierten) Klasse deshalb nur über denselben Namensabgleich zuordnen, den die Übersicht schon für ihr Roster nutzt (ClassTeacherOverviewViewModel.MatchStudent). Liste, Anlegen/Bearbeiten/Löschen und Vertraulichkeits-Freigabe sind eins zu eins vom Gruppen-Tab übernommen (DocumentationItem, DocumentationDialogViewModel/DocumentationDialog, ConfirmDialog) statt neu gebaut. Die Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren ClassTeacherDetailsViewModel.FilterOwnDocumentation (gleiches Muster wie ClassTeacherRosterRow.Build), damit Anlegen/Bearbeiten/Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Bewusst zurückgestellt: Kategorien-Chipreihe (bisher nur WebUntis-CategoryGroup) auf eigene Tags erweitern; "Gespräch begleiten" (Elternanruf-Durchführung) aus dem Gruppen-Tab wurde hier nicht übernommen, da dieser Hub bewusst schlank gehalten wurde.

  • "Klassenlehrer"-Feature — Fehlquote behandelte Verspätung wie Schwänzen, Roster-Sortierung ignorierte Muster über "heute" hinaus (September 2026): Zwei verwandte Nutzer-Feedback-Punkte zur selben Ursache: kein Unterschied zwischen "10× 5 Min. verspätet" und "10× ganztägig unentschuldigt gefehlt".

    • Fehlquote: ClassTeacherRosterRow.YearAbsenceDayCount (Zähler von YearAbsenceRatePercent, angezeigt als "X % Fehlzeit seit Schuljahresbeginn") zählte bisher jeden Tag mit irgendeinem Fehlzeiten-Eintrag gleich, egal ob ganztägig unentschuldigt oder nur 5 Minuten verspätet. Jetzt dieselbe überschneidungsfreie Kategorisierung wie im Wochentrend (BuildTrend: Unentschuldigt vor Verspätet vor Entschuldigt) — reine Verspätung (nicht auch unentschuldigt) zählt nicht mehr in die Quote, sondern separat in neuem YearLateDayCount/YearExcusedDayCount. Ist jemand unentschuldigt UND verspätet, bleibt das der schwerwiegendere Fall und zählt weiter voll in die Quote (gleiche Rangfolge wie AttentionRank/StatusKind). YearSummaryLabel/-Tooltip zeigen reine Verspätungstage jetzt als eigene Zeile statt sie in die Prozentzahl einzurechnen.
    • Roster-Sortierung: die bisherige "Auffällig"-Ansicht filtert rein auf den Heute-Snapshot (HasAbsenceToday) — ein/e Schüler*in mit 5 Tagen unentschuldigter Vorgeschichte, aber heute zufällig anwesend, verschwand komplett aus der Liste, während ein einmaliger Erstfehltag ganz oben landete, sobald er der/die einzige "heute Auffällige" war. Neuer vierter Reiter "Gesamtbild" (SelectedRosterFilter == 3, jetzt der beim Öffnen aktive Standard-Reiter statt "Auffällig") sortiert stattdessen nach neuem ClassTeacherRosterRow.PatternScore — kumulierte, gewichtete Summe über das ganze bisherige Schuljahr (unentschuldigt × 3,0, entschuldigt abwesend × 1,0, nur verspätet × 0,5; feste statt einstellbare Gewichte, Nutzer-Entscheidung), bewusst ohne zeitliche Abklingkurve (für die genannten Fälle reicht die reine Summe). Die drei bestehenden Reiter (Auffällig/Klassenbuch/Alle) blieben unverändert.
    • Nachtrag (September 2026): PatternScore bezog zunächst nur Fehlzeiten ein — Nutzer-Feedback: Klassenbucheinträge anderer Lehrkräfte (fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine Suspendierung) sollen ebenfalls einfließen, "wenn die negativen Einträge durch die Decke gehen". ClassTeacherRosterRow bekam dafür YearNegativeClassRegisterCount/ HasSuspensionEntry/ClassRegisterScoreComponent: jeder Klassenbucheintrag mit CategoryGroup "Negativ" zählt mit Gewicht 1,0 (ein einzelner Eintrag fällt kaum ins Gewicht, erst die Häufung), CategoryName/Text-Schlüsselwörter "hausaufgabe"/"mitarbeit" zählen mit zusätzlichem Bonus 1,5, das Schlüsselwort "suspendier" mit Ausreißer-Gewicht 15,0 (schießt den Score sofort deutlich über jedes normale Muster, wie gewünscht) — alles einfache case-insensitive Contains-Prüfungen, gleiches Muster wie die bestehende "Negativ"/"verspät"- Erkennung. Dafür musste ClassTeacherOverviewViewModel.Load den Klassenbuch-Abruf von "letzte 7 Tage" auf "seit Schuljahresbeginn" ausweiten (_cache.GetClassRegisterEventsAsync(..., yearStart, today), dieselbe dauerhaft gecachte "kalte Historie" wie bei den Fehlzeiten) — die bestehenden 7-Tage-Badges (RecentClassRegisterCount, HasRecentClassRegisterEntry, "Klassenbuch"-Reiter) filtern seitdem clientseitig aus genau diesem einen Abruf heraus, statt weiterhin separat und enger abzurufen, damit sich an ihrem Verhalten nichts ändert. ClassTeacherRosterRow.Build bekam dafür einen neuen optionalen Parameter yearClassRegisterEntries, der ohne Angabe auf den bisherigen recentClassRegisterEntries- Parameter zurückfällt (Rückwärtskompatibilität für bestehende Aufrufer/Tests). Bewusst zurückgestellt: einstellbare Gewichte (Schieberegler o.ä.) für PatternScore — Nutzer-Entscheidung für feste Werte zunächst, lässt sich bei Bedarf später ergänzen.
  • "Klassenlehrer"-Feature — "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge (September 2026): Nutzer-Feedback: manche Probleme (Schuleschwänzen, wiederholte Hausaufgaben- Verweigerung, ein Konflikt) müssen intensiver abgearbeitet werden, dafür fehlte ein Ort, an dem Titel, Problembeschreibung, Schlagwörter, alle dazugehörigen Documentation-Einträge und die relevanten Klassenbucheinträge gebündelt einsehbar sind (auch für den Nachweis der Maßnahmen gegenüber der Schulleitung) — statt über datumsgefilterte Listen verstreut. Neues, eigenständiges Modell Vorgang (LehrerApp.Core/Models/Vorgang.cs): Titel, Beschreibung, StudentIds (eine/r oder mehrere), freie Tags (VorgangTagDisplay.Suggestions: Absentismus, Hausaufgaben, Verspätungen, Konflikte, Mitarbeit, Elternkontakt, Eskalation), Status Offen/ Geschlossen, DocumentationIds (nur Referenzen, kein gecachter Inhalt trotz Nutzer-Idee einer "Uploadversionsnummer" — es gibt keine app-seitig nutzbare Versionsnummer pro Objekt, nur einen internen Server-Sequenzzähler in EventQueue, und die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur Invalidierungs-Komplexität eingeführt hätte; gleicher Präzedenzfall wie die bewusst verworfene Plan-ID-Gruppierung für Förderpläne, 5.3.3), sowie eingefrorene ClassRegisterEntries (Werte-Kopie statt ReferenzUntisForeignClassRegisterEventDto/ UntisClassRegisterCacheEntry haben keine über einen Cache-Refresh hinweg stabile ID). Sync-fähig nach demselben Muster wie Documentation (VorgangRepository.Save/Delete feuern db.OnChange, EventApplier nutzt dafür den generischen Simple<Vorgang>-Helfer statt eines Kaskaden- Sonderfalls, da kein Hard-Delete existiert). Neuer vierter Tab "Vorgänge" in der Klassenlehreransicht (ClassTeacherCasesViewModel/ ClassTeacherCasesView, Geschwister-ViewModel zu DetailsTab, löst wie dieses die Untis-Roster- Namen der Klasse auf lokale Students auf — bewusst ein dritter, nicht geteilter Abgleich nach demselben kleinen Muster, siehe ClassTeacherOverviewViewModel.MatchStudent). Jede Fallmappe ist eine aufklappbare Karte (VorgangItem, gleiches Reveal-Muster wie DocumentationItem) mit verknüpfter Dokumentation (neu anlegen-und-verknüpfen über den bestehenden DocumentationDialog, oder bestehende Einträge der Vorgangs-Schülerinnen nachträglich verknüpfen/entfernen) und angehefteten Klassenbucheinträgen. Anheften läuft vom Klassenbuch-Tab aus: Rechtsklick auf eine Zeile → Kontextmenü "→ An Vorgang anheften" (bewusst kein DataGridTemplateColumn-Button, da ein $parent[DataGrid]-Bindungspfad in Zellen-Templates in diesem Codebasis-Stand nirgends erprobt ist — DataGrid.SelectedItem zweigleisig gebunden + ContextMenu.CommandParameter ist das bereits an anderer Stelle (GroupDetailView) bewährte Muster für Zeilen-Aktionen), öffnet einen Auswahldialog (bestehenden offenen, zur/zum Schülerin passenden Vorgang wählen oder per Titel einen neuen anlegen). ClassTeacherDetailsViewModel bekommt dafür ClassTeacherCasesViewModel per Konstruktor-Injection (Geschwister-VM-Zugriff für reine Datenabfragen statt Func-Hook, da keine UI im Spiel ist).

    • Nachtrag: Nutzer-Feedback — verknüpfte Dokumentation ließ sich nur als kurze Karte sehen, zum Nachlesen musste man in den Schülerdatensatz wechseln. Statt eines neuen Seitenpanels (DockPanel/Dock="Right") oder eines eigenen Lese-Fensters wird der bereits vorhandene DocumentationDialog wiederverwendet: "Bearbeiten"-Button an jedem verknüpften bzw. noch verknüpfbaren Eintrag (nur sichtbar nach IsRevealed, gleiche Vertraulichkeits-Gate-Konvention wie im Gruppen-Tab) öffnet ihn zum vollständigen Lesen/Bearbeiten, ohne den Vorgänge-Tab zu verlassen. VorgangItem bekam dafür einen weiteren Callback (onEditDocumentation, gleiches Konstruktor-Callback-Muster wie onLink/onUnlink), ClassTeacherCasesViewModel.EditDocumentation speichert das Ergebnis direkt über IDocumentationRepository, unabhängig davon, welche Vorgänge den Eintrag verlinken. "Runde 2" (September 2026, direkt im Anschluss umgesetzt): einstellbare Gewichte plus Score-Dämpfung nach geschlossenem Vorgang inkl. Rückfall-Warnung — siehe eigener Eintrag unten.
  • "Klassenlehrer"-Feature — konfigurierbare Gewichtung + Score-Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang (September 2026, "Runde 2"):

    • Gewichtung: PatternScoreWeights/PatternScoreSettingsService (LehrerApp.Core/Services/PatternScoreSettingsService.cs, gleiches JSON-in-appData-Muster wie WorkloadSettingsService/DashboardSettingsService) ersetzen die bisherigen festen Konstanten. Neuer Dialog PatternScoreWeightsDialog (Vorbild ParticipationAspectsDialog: NumericUpDown pro Gewicht), erreichbar über einen neuen "⚙ Gewichtung"-Button neben dem "Gesamtbild"-Filter; beim Schließen wird LoadCommand erneut ausgeführt, damit die neue Gewichtung sofort wirkt.
    • Dämpfung (Nutzer-Entscheidung: bereichsbezogen statt komplett): die Schlagwörter eines geschlossenen Vorgangs bestimmen per Schlüsselwort-Erkennung (ClassTeacherRosterRow.MatchDomains, gleiche Contains-Konvention wie die bestehende "Negativ"/"verspät"-Erkennung), welcher der drei VorgangScoreDomain-Bereiche (Attendance/Lateness/Classbook) gedämpft wird — Ereignisse vor dem Schließ-Datum zählen ab dann nicht mehr in PatternScore. Kein Schlagwort erkannt (z.B. reines "Elternkontakt") → vorsichtshalber alle drei Bereiche gedämpft, damit ein geschlossener Vorgang nie folgenlos bleibt. Bewusst unverändert: die faktische "% Fehlzeit"-Quote (YearAbsenceDayCount/YearAbsenceRatePercent) — nur die PatternScore-Sortierung wird gedämpft, nicht die für Zeugnis/Attestpflicht relevante Historie.
    • Rückfall-Warnung: neues RegressionScore/RegressionVorgangTitle je Roster-Zeile — der Score-Anteil, der seit dem Schließ-Datum des zuletzt geschlossenen Vorgangs (nur in dessen eigenen Bereichen) neu hinzugekommen ist. ClassTeacherOverviewViewModel.BuildVorgangRegressionNotices (Vorbild BuildAttendanceParticipationNotices) meldet ab einer Schwelle von 3,0 (≈ 1 unentschuldigter Tag, Nutzer-Entscheidung: "erst ab spürbarem Muster", nicht bei jeder Kleinigkeit) einen Danger-Hinweis "Nach Bearbeitung von '{Titel}' erneut auffällig" in die bestehende "Muster erkannt"-Seitenleiste, inkl. vorhandenem "+"-Wiedervorlage-Button — keine neue UI dafür nötig.
    • ClassTeacherRosterRow.Build bekam dafür zwei weitere optionale Parameter (weights, closedVorgaengeByNameKey, beide mit Default = heutiges Verhalten, volle Rückwärtskompatibilität für bestehende Tests/Aufrufer). ClassTeacherOverviewViewModel.Load löst die geschlossenen Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen Build*-Methoden dieser Klasse auf (IVorgangRepository.GetByStudent).

Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026): Nutzer-Feedback: beim Planen der nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder manuell zur zuvor betrachteten Woche zurückgeblättert werden ("man muss nicht nur einmal hinspringen, sondern jedes Mal wieder neu"). Ursache war das in der vierten Iteration dokumentierte Verhalten, dass MainWindowViewModel.GetTimetable() bei jedem erneuten Aufruf der Seitenleisten- Navigation TimetableViewModel.WeekOffset explizit auf 0 zurücksetzte — obwohl TimetableViewModel als DI-Singleton ohnehin über die gesamte Sitzung hinweg bestehen bleibt und WeekOffset daher ohne diese Zeile ganz von selbst erhalten bliebe. Der Reset wurde entfernt; ein separater Zustands- Singleton/eine statische Variable dafür war nicht nötig. Das erklärt vermutlich auch den zweiten gemeldeten Effekt ("der Zurück-Button springt manchmal zwei Wochen zurück"): wer z.B. in Woche +1 plante, kurz wegnavigierte und danach einmal "" klickte, landete wegen des Resets in Woche -1 statt der erwarteten Woche 0 — gefühlt ein Sprung um zwei Wochen, obwohl PreviousWeekCommand/ NextWeekCommand selbst korrekt je Klick nur um eine Woche verschieben (siehe NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute, das zwei aufeinanderfolgende Klicks bewusst als zwei Wochen erwartet). Der "Diese Woche"-Button existierte bereits seit der vierten Iteration (CurrentWeekCommand, nur sichtbar wenn !IsCurrentWeek).

Nachtrag zu 4.3, Abgleich fehlender Hausaufgaben (September 2026): Nutzer-Feedback: negative Klassenbucheinträge zur Kategorie "fehlende HA" sind ein brauchbares Signal, ob ein/e Schüler*in die Hausaufgabe nicht gemacht hat, und sollen als Vorschlag mit der eigenen Erfassung (ParticipationEntry.Homework) abgeglichen werden können — ausdrücklich nicht die erteilte Hausaufgabe selbst (Lesson.Homework), das bleibt ein offener, separater Punkt.

  • Neuer Dashboard-weiter Dialog WebUntisHomeworkComparisonViewModel/WebUntisHomeworkComparisonDialog (LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs), Button "Fehlende Hausaufgaben abgleichen…" neben dem bereits vorhandenen "Klassenbuch abgleichen…" auf dem Dashboard. Baut auf demselben Muster wie WebUntisDocumentationComparisonViewModel (Dashboard-weit, da der WebUntis-"-alle-"-Bericht klassenübergreifend ist) und WebUntisLessonAbsenceComparisonViewModel (schreibt wie dort über IParticipationRepository.GetBySessionAndStudent/Save).
  • Filter: CategoryGroup == "Negativ" und CategoryName/Text enthält "Hausauf" — dieselbe Substring-Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, nur zusätzlich auf negative Einträge eingeschränkt.
  • Fach-Erkennung: Der Klassenbuch-Bericht liefert kein zuverlässiges Fach-Feld, aber laut Nutzer enthält der mitgeschickte Text praktisch immer das Untis-Fachkürzel. Deshalb wird Text (Fallback CategoryName) gegen Subject.ShortName alle Lerngruppen-Fächer abgeglichen (Wortgrenzen-Split, nicht Contains, um kurze Kürzel nicht versehentlich in anderen Wörtern zu treffen); mehrdeutige Kürzel (zwei Fächer mit demselben ShortName) werden bewusst nicht zugeordnet statt zu raten.
  • Lerngruppe/Stunde auflösen: aus dem per Namensabgleich gefundenen Student werden über IGroupMembershipRepository.GetByStudent alle am Eintragsdatum aktiven Mitgliedschaften (GroupMembershipService.IsActiveOn) genommen und auf das erkannte Fach + Schuljahr des Eintragsdatums eingeschränkt; bleibt mehr als eine Lerngruppe übrig, bleibt die Zeile unaufgelöst statt irgendeine zu wählen (gleiches Vorsichtsprinzip wie beim Namensabgleich). Die ParticipationSession der Lerngruppe am Eintragsdatum liefert dann die Stunde für IParticipationRepository.
  • Kein Überschreiben (Kernanforderung): eine Zeile wird nur vorausgewählt, wenn HomeworkDisplay.Effective(entry) noch null ist, also lokal überhaupt noch nichts erfasst wurde — jeder bereits vorhandene Status (auch ein bereits gesetztes "fehlt", nicht nur "Teilweise"/ "nachgereicht") bleibt unangetastet und wird nur zum Vergleich daneben angezeigt; die Checkbox bleibt trotzdem bedienbar, falls doch bewusst überschrieben werden soll. Übernommen wird immer nur HomeworkStatus.MissingOpen ("Nachreichen offen") — nie automatisch MissingOverdue, die weitere Nachverfolgung bleibt laut Nutzer App-intern.
  • Bewusst kein Caching: GetOwnClassRegisterEventsAsync wird wie beim bereits bestehenden WebUntisDocumentationComparisonViewModel direkt (ungecacht) aufgerufen statt über UntisReportCacheService, dessen Cache-Tabellen aktuell klassen-/GetForeignClassRegisterEventsAsync- spezifisch aufgebaut sind (ein Schema-Umbau für die eigenen, klassenübergreifenden Einträge wäre ein eigenständiger, deutlich größerer Umbau gewesen, siehe Nutzer-Rückfrage). Beide "eigene Einträge"- Dialoge lösen pro Öffnen einen eigenen Live-Abruf aus; falls das in der Praxis stört, wäre eine gemeinsame Cache-Erweiterung ein sinnvoller separater Nachtrag.
  • Aus Konsistenz mit den beiden bestehenden, strukturell identischen Abgleichs-ViewModels bewusst ungetestet gelassen: WebUntisIntegrationService ist sealed und nicht auf ein Interface gemockt, WebUntisLessonAbsenceComparisonViewModel/WebUntisDocumentationComparisonViewModel haben aus demselben Grund ebenfalls keine Tests.

Nachtrag zu 4.3, Untis-Hub (September 2026): Nutzer-Feedback: die vier bestehenden WebUntis- Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) liefen bislang alle rein manuell und ohne jede Übersicht, wann welcher zuletzt lief — bei bewusst fehlendem Caching (s.o.) besteht sonst das Risiko, denselben Bericht aus Unsicherheit zu oft abzurufen ("bei WebUntis auffallen").

  • Neuer UntisHubService (LehrerApp.Desktop/Services/UntisHubService.cs) verwaltet nur Fälligkeits-Zeitstempel (UntisHubJobState, LiteDB-Collection untis_hub_job_states) — kein eigener WebUntis-Zugriff, reine Lesefunktion aus der lokalen DB. Der Fehlzeitenabgleich bekommt zwei Kadenzen je Lerngruppe mit gesetzter WebUntisLessonId (kurzfristig alle 14 Tage/fällig, 21 Tage/überfällig; langfristig seit Schuljahresbeginn alle 60/90 Tage), die drei dashboard-weiten Abgleiche je 14/21 Tage.
  • UntisHubActions (LehrerApp.Desktop/Services/UntisHubActions.cs) führt die vier bestehenden Vergleichsdialoge unverändert aus und erkennt nicht-invasiv (über den Busy-Übergang der jeweiligen ViewModels) einen tatsächlichen Ladeversuch, um danach den Fälligkeitsstand zu aktualisieren — egal ob über den Hub oder das Menü ausgelöst.
  • Neues Fenster UntisHubDialog (LehrerApp.Desktop/Views/UntisHub/) zeigt alle Job-Zeilen tabellarisch (Bereich, Prüfung, Fälligkeit, letztes Ergebnis, "Jetzt prüfen") — rein manuell, keine automatischen Hintergrund-Requests.
  • Einstiegspunkte konsolidiert: Nutzer wollte keine weiteren Einzelfunktionen in der Sidebar. MainWindow bekam dafür ihre erste klassische Menüleiste (bisher gab es keine) mit Menüpunkt "WebUntis" (Untis-Hub, Offene Stunden, Klassenbuchabgleich, Hausaufgabenabgleich); der bisherige Sidebar-Eintrag "Offene Untis-Stunden" sowie die beiden Dashboard-Buttons "Klassenbuch abgleichen…"/"Fehlende Hausaufgaben abgleichen…" (siehe Nachtrag oben) sind dafür entfallen. Auf dem Dashboard bleibt statt der Buttons nur noch ein kompakter Status-Badge ("WebUntis ✓" / "WebUntis ⚠ N fällig", nur sichtbar wenn WebUntis konfiguriert ist), der den Hub öffnet. Der Fehlzeitenabgleich-Button in der einzelnen Lerngruppe (GroupDetailView) blieb unverändert, da kontextgebunden.

4.4 Wochen-/Tagesansicht

  • 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.
  • 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).
  • 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):

  • 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.

  • 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`.
    
  • 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.

  • 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.
    
  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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).

  • 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.

  • 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.

  • 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).

  • 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.

  • 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.
    
    **Nachtrag (September 2026, Nutzer-Feedback) — zweite Sitzung bei zusätzlicher Stunde am
    selben Tag:** `SelectOrCreateSessionForLesson` prüfte bisher ausschließlich auf eine bereits
    exakt mit `lesson.Id` verknüpfte Sitzung. Kam neben einer Doppelstunde noch eine dritte
    Stunde desselben Tages hinzu (eigene `Lesson`-Id, z.B. durch Vertretung), fand die Methode
    keinen Treffer und legte eine zweite, unabhängige Mitarbeitssitzung für denselben Tag an —
    dadurch zeigten Unterrichtsmodus und der normale Mitarbeit-Tab der Gruppe (der über
    `ParticipationTabViewModel.LoadSessions()` unabhängig davon die nach Datum jüngste Sitzung
    wählt) je nach Reihenfolge unterschiedliche Sitzungen für denselben Tag. Nutzer-Entscheidung:
    strikt nach Datum statt nach Stunde gehen — eine zusätzliche Stunde am selben Tag (Vertretung
    o.ä.) bekommt keine eigene Sitzung, sondern die Lehrkraft passt die Einschätzung der bereits
    bestehenden Sitzung des Tages an. `SelectOrCreateSessionForLesson` fällt jetzt, wenn keine
    exakt verknüpfte Sitzung existiert, zusätzlich auf jede andere Sitzung desselben Tages
    zurück (Verhalten analog zu `EnsureTodaySession`, das schon immer pro Tag statt pro Stunde
    arbeitet). Dieselbe Lücke bestand in `PlanningTabViewModel.CreateParticipationSession`
    ("Sitzung aus der Stunde erstellen", 3.3.1) — gleicher Fallback dort ergänzt.
    
  • 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).

  • 4.5.25 Lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools), 2026-09-11: erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung — analog zur bestehenden Regel "LLM nur für Intent, nie für Geometrie" gilt hier "LLM nur für Anfrage/Absicht, nie für direkten Datenbankzugriff": alle Zugriffe laufen über typisierte Tools, kein freier Query-Zugriff. - Architektur: neues, eigenständiges LehrerApp.McpBridge-Projekt (Konsolenprozess, vom KI-Client per stdio gestartet) reicht JSON-RPC-Nachrichten zeilenweise unverändert über eine Named Pipe (LehrerApp.Core/Mcp/McpPipeConstants.cs) an einen In-Process-MCP-Server im laufenden Avalonia-Hauptprozess durch (McpServerHostedService.cs). Grund: LiteDB ist Single-Writer/Embedded ohne Cross-Prozess-Benachrichtigung — ein separater Prozess mit eigener DB-Verbindung hätte Stale-Reads gegenüber der laufenden GUI riskiert. Nutzt ModelContextProtocol.Core (StreamServerTransport direkt auf dem NamedPipeServerStream, kein eigenes JSON-RPC-Parsing nötig) statt eines selbstgebauten Protokolls. - Tools (5, alle Read-only): get_students, get_exams, get_grades, get_schedule, get_time_entries — je eine schlanke Tool-Klasse unter Services/Mcp/Tools/, DTOs statt direkt serialisierter LiteDB-Entities. McpToolScope.cs dokumentiert die Allowlist der exponierten Tool-Namen nach demselben Muster wie PlainEventStore.Allowed (Klartext- Sync-Kanal) — Gesprächsnotizen/Vorfälle/Förderpläne (Documentation/Vorgang) werden von keiner Tool-Klasse referenziert und sind damit technisch nie erreichbar, nicht nur per Konvention. - Opt-in: standardmäßig deaktiviert, McpSettingsService + Checkbox im neuen Einstellungen-Tab "MCP-Server" (SettingsViewModel.McpSettings.cs). Kein Token/Login nötig (anders als bei 4.5.9) — die Named Pipe selbst ist die Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte). Wirkt erst nach Neustart der App (kein Live-Reload des Pipe-Listeners). - Verifiziert: End-to-End-Smoke-Test (Bridge-Prozess als echter Kindprozess, initializetools/listtools/call get_students über die reale Named Pipe) bestätigt die volle Kette; 9 Unit-Tests für Tool-Filterlogik und die Scope-Allowlist in McpToolsTests.cs. - Bewusst zurückgestellt (spätere Phasen): Write-Tools samt Bestätigungsdialog-UI (folgt in 4.5.26), get_lesson_plans (folgt in 4.5.26), Worksheets-Tools (list_worksheets/download_worksheet/...), macOS-Bundle-Signierung der Bridge-Binary, Named-Pipe-basierte Einzelinstanz-Absicherung (die Spec-Idee dazu funktioniert nicht, da die Pipe nur bei aktiviertem Opt-in existiert — LehrerApp hat ohnehin noch keinen Single-Instance-Mechanismus, unabhängig von MCP).

  • 4.5.26 Lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans), 2026-09-11, direkte Fortsetzung von 4.5.25. - Bestätigungsdialog: IMcpConfirmationService/AvaloniaMcpConfirmationService (Services/Mcp/AvaloniaMcpConfirmationService.cs) zeigt den bereits vorhandenen ConfirmDialog (Views/Shared, bisher nur intern genutzt) über Dispatcher.UIThread.InvokeAsync an, obwohl der aufrufende Tool-Handler auf dem Pipe-Session-Hintergrund-Thread läuft — "kein silent write": ein Write-Tool schreibt nie, ohne dass der Nutzer den konkreten (menschenlesbaren, nicht bloß JSON/GUIDs) Vorschlag gesehen und bestätigt hat. 2 Minuten Timeout (schließt den Dialog automatisch und wertet als abgelehnt), damit eine hängende Pipe-Session nicht unbegrenzt den wartenden KI-Client blockiert. Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen (FakeMcpConfirmation in Fakes.cs). - Neue Tools: create_time_entry, create_grade_entry (beide Write, mit Bestätigung), update_student_group_assignment (Write; legt GroupMembership an oder ändert nur Niveau/Zeitraum/Beitritt/Austritt — bei bereits identischem Stand keine erneute Nachfrage), get_lesson_plans (Read; Unit+Lesson einer Gruppe im Zeitraum). McpToolScope um AllowedWriteTools erweitert, McpServerHostedService prüft per Debug.Assert weiterhin, dass die tatsächlich registrierten Tools exakt der Allowlist entsprechen. - Bewusste Abweichung von der Spec: create_note (Spec: "nur für nicht-sensible Notiztypen") wird nicht umgesetzt — das einzige existierende Notiz-Modell (Documentation, Gesprächsnotizen/Vorfälle/Förderpläne) ist bereits vollständig MCP-ausgeschlossen (4.5.25), ein separates "nicht-sensibles" Notiz-Konzept existiert im Datenmodell nicht und würde eine neue Entität erfinden, nur um die Spec-Zeile zu erfüllen. - create_lesson_plan/update_lesson_plan bewusst weiter zurückgestellt: Lesson ist das mit Abstand komplexeste Modell (verschachtelte Phases, Anhänge) und verdient einen eigenen Schritt statt in Phase 2 mit reinzurutschen. - Verifiziert: End-to-End-Smoke-Test wie in 4.5.25, erweitert um einen echten create_time_entry-Aufruf über Bridge → Pipe → McpServer → Confirmation-Callback → Repository-Save (mit automatisch bestätigender Test-IMcpConfirmationService-Instanz statt echtem Dialog) — bestätigt, dass async Write-Tool-Handler mit CancellationToken-Bindung durch das SDK korrekt funktionieren. 18 Unit-Tests in McpToolsTests.cs (vorher 9), decken u.a. ab: Bestätigung/Ablehnung je Write-Tool, dass die Bestätigungsnachricht den Schülernamen statt einer rohen GUID enthält, und dass eine unveränderte Gruppenmitgliedschaft keine erneute Nachfrage auslöst.

  • 4.5.27 build-macos-app.sh um die Bridge-Binary erweitert (2026-09-11, aus 4.5.25/4.5.26 zurückgestellt): LehrerApp.McpBridge wird jetzt als zweites Self-Contained-Publish pro Architektur gebaut und als lose Executable neben die Hauptapp nach Contents/MacOS/LehrerApp.McpBridge gelegt (nicht als CFBundleExecutable - sie wird nie von Finder/launchd gestartet, sondern vom KI-Client direkt über ihren vollen Pfad als Kindprozess). Die bisher einmalige Publish/Universal-Binary/Kopier-Logik ist in eine place_binary()-Funktion extrahiert und wird für Hauptapp und Bridge gleichermaßen aufgerufen. Bridge wird zusätzlich einzeln ad-hoc signiert (Schritt 6/7), bevor der Bundle-weite codesign --deep-Schritt (7/7) läuft — sie liegt lose in Contents/MacOS statt in einem eigenen .framework/.bundle, daher nicht verlassen auf das (in dem Fall nicht sicher dokumentierte) Rekursionsverhalten von --deep. - Nicht gelöst, bewusst zurückgestellt: Laufzeit-Duplikation (beide Publishes bringen ihre eigene self-contained .NET-Runtime mit) — siehe Planungsdokument, spätere Optimierung über einen framework-dependent Bridge-Build + angepasste runtimeconfig.json. - Nicht verifiziert: Diese Session lief auf Windows, ohne Zugriff auf sips/iconutil/ lipo/codesign — das Skript wurde nur auf Bash-Syntax geprüft (bash -n), nicht auf einem echten Mac gebaut/ausgeführt. Vor Verlass auf das erzeugte Bundle: auf macOS ./build-macos-app.sh laufen lassen und open build/LehrerApp.app sowie einen MCP-Client, der auf build/LehrerApp.app/Contents/MacOS/LehrerApp.McpBridge zeigt, gegen die laufende App testen.

  • 4.5.28 Lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools), 2026-09-11: statt eines einzelnen update_lesson, das die komplette Stunde inkl. Verlaufsplan als ein großes JSON-Objekt tauscht, gezielt kleine Tools je Teil-Operation — auf Nutzervorschlag: Lesson ist zwar technisch ein eingebettetes LiteDB-Dokument ohne Zeilen-Locking auf DB-Ebene, aber kleinteilige Tools verkürzen das Lesen-Schreiben-Zeitfenster je Operation drastisch und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, wurde bewusst NICHT gebaut. - Neue Tools: create_unit/update_unit (Einheiten-Metadaten), create_lesson (ohne Phasen; übernimmt GroupId automatisch von der übergeordneten Unit, kein eigener groupId-Parameter — verhindert Inkonsistenz zwischen Lesson und Unit), update_lesson (Metadaten, Phasen bleiben unangetastet), add_lesson_phase/ update_lesson_phase/remove_lesson_phase (je eine LessonPhaseStep), sowie download_lesson_attachment (Read-Tool, Base64, gedeckelt auf LessonPlanTools.MaxInlineAttachmentBytes = 3 MB — größere Anhänge liefern einen klaren Fehler statt einer aufgeblähten Antwort; echtes MCP-Resource-Streaming für große Dateien bleibt ein offener Punkt, siehe Planungsdokument). - ILessonRepository um GetById(Guid id) ergänzt (fehlte bisher komplett - GetByUnit/GetByGroupAndDate/GetByGroupAndRange decken keinen Einzelabruf per ID ab). Ohne diese Methode wäre kein einziges der neuen Lesson-Tools möglich gewesen, da sie alle eine bestehende Stunde gezielt nachladen müssen. LessonRepository.GetById delegiert auf das bereits intern (in Delete) genutzte db.Lessons.FindById(id). - get_lesson_plans liefert jetzt zusätzlich Attachments (Speicher-ID + Dateiname + Größe) je Stunde und Phases inklusive Id je Phase — beides war vorher nicht exponiert und ist Voraussetzung dafür, dass ein KI-Client eine Phase oder einen Anhang gezielt referenzieren kann. - McpToolScope/McpServerHostedService entsprechend erweitert (7 Read-, 10 Write-Tools insgesamt), 14 neue Unit-Tests in McpToolsTests.cs (jetzt 28), decken u.a. ab: dass create_lesson die Gruppe von der Einheit übernimmt statt einen eigenen Parameter zu vertrauen, dass update_lesson/update_lesson_phase wirklich nur die angegebenen Felder ändern, und dass ein zu großer Anhang beim Download einen Fehler statt einer Antwort liefert.

  • 4.5.29 Lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung), 2026-09-11 — schließt die Spec ab (Rest siehe "Bewusst nicht umgesetzt" unten). - "Worksheets" umbenannt zu Elternbrief-Vorlagen: die Spec sah list_worksheets/ download_worksheet/upload_worksheet/update_worksheet mit Fach-/Klassenstufe-Metadaten vor — das existiert im Datenmodell nicht. Was tatsächlich existiert, ist der .lavorlage-Vorlagenmechanismus (LehrerApp.Templating), der ausschließlich für Elternbriefe genutzt wird (an Schüler+Kontakt gebunden, siehe CreateLetterDialogViewModel). Neue Tools list_letter_templates (Read) und render_letter (Read — schreibt nichts in die Datenbank, deshalb ohne Bestätigungsdialog, liefert ein Base64-PDF). - upload_worksheet/update_worksheet bewusst NICHT umgesetzt: das Seitenlayout ist eine eigene, positionsbasierte DSL (LayoutParser/.tpl-Dateien mit absoluten Koordinaten), für die es einen eigenen visuellen Editor gibt (LehrerApp.TemplateDesigner) — ein LLM müsste diese DSL blind erzeugen, mit hohem Risiko für unbrauchbare oder defekte Layouts. render_letter deckt den tatsächlich nützlichen Fall ab: eine bestehende, von Hand gestaltete Vorlage mit Werten füllen. - Platzhalter-Logik aus CreateLetterDialogViewModel extrahiert nach Services/LetterPlaceholderBuilder.cs, damit Dialog und MCP-Tool exakt dieselben Standard-Platzhalternamen (Datum, Anrede, Contact.Address, ...) befüllen, statt still auseinanderzudriften. Bestehende CreateLetterDialogViewModelTests liefen nach dem Refactor unverändert grün. - Registrierungs-Workflow: neuer McpClientRegistrationService.cs trägt den Bridge-Pfad in claude_desktop_config.json ein (Windows: %APPDATA%\Claude\..., macOS: ~/Library/Application Support/Claude/...) — bewusst nur für Claude Desktop, der einzige in der Spec konkret genannte Client mit dokumentierter Config-Konvention. Button "Bei Claude Desktop eintragen"/"Eintrag entfernen" im MCP-Einstellungen-Tab, bewusst nur auf explizite Nutzeraktion, nie automatisch beim App-Start (Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer externer Seiteneffekt). Bestehende Config-Inhalte (andere MCP-Server, sonstige Claude-Desktop-Einstellungen) bleiben beim Eintragen erhalten; eine nicht als JSON lesbare bestehende Datei wird nicht angefasst, sondern liefert einen Fehler mit Pfadangabe statt sie zu überschreiben. Funktioniert nur bei einer gepackten Installation (Bridge liegt neben der Hauptapp-Executable) — bei einem lokalen dotnet build/run liegt die Bridge in ihrem eigenen separaten bin-Ordner und wird nicht gefunden. - McpToolScope/McpServerHostedService um list_letter_templates/render_letter erweitert (9 Read-, 10 Write-Tools). 6 neue Tests in LetterTemplateToolsTests.cs (rendert echte PDFs über QuestTemplateRenderer, kein Fake) und 6 in McpClientRegistrationServiceTests.cs (u.a. bestehende Fremdeinträge bleiben erhalten, kaputtes JSON wird nicht überschrieben). - Bewusst nicht umgesetzt (verworfen, nicht nur zurückgestellt): upload_worksheet/ update_worksheet (siehe oben), Mehrbenutzer-/Remote-Zugriff, Lösch-Tools, Dokumentationstypen-Zugriff, Zugriff über den Sync-Server — alle laut Spec explizit "Out of Scope (v1)". Windows-Installer-Anpassungen und die Runtime-Dedup-Optimierung für macOS (4.5.27) bleiben offen, sind aber nicht MCP-spezifisch.

  • 4.5.30 add_lesson_attachment-Tool (2026-09-11, Nutzer-Nachtrag zu 4.5.28): Nutzer nutzt die bestehende Anhang-Funktion an Einzelstunden (Lesson.Attachments) tatsächlich, um Arbeitsblätter abzulegen — download_lesson_attachment (4.5.28) deckte davon nur die Leserichtung ab. Neues Write-Tool in LessonPlanTools.cs nimmt Dateiname + Base64-Inhalt entgegen, validiert (leer/ungültiges Base64/über IAttachmentStorage.MaxSizeBytes) vor der Bestätigungsnachfrage, lädt erst nach Bestätigung über IAttachmentStorage.Upload hoch und hängt den DocumentAttachment-Eintrag an die Stunde. DocumentAttachment hat keine eigene Guid-Id (nur StorageId, ein string) — WriteResultDto.Id bleibt deshalb null, die storageId steht stattdessen in der Erfolgsmeldung, damit ein Folgeaufruf (z.B. zur Kontrolle per download_lesson_attachment) ohne erneutes get_lesson_plans möglich ist. 4 neue Tests in McpToolsTests.cs (jetzt 32).

  • 4.5.31 Bestätigungsdialog-Prominenz + move_lesson/delete_lesson (2026-09-12, Nutzer-Feedback nach erstem Live-Test mit echtem Bridge-Prozess). - Dialog fiel zu wenig auf: der bisher wiederverwendete ConfirmDialog (Views/Shared, an vielen anderen Stellen der App für Routine-Bestätigungen im Einsatz) ist nur zum Hauptfenster modal — läuft LehrerApp im Hintergrund, während der Anstoß von einem KI-Client in einem anderen Fenster kommt (der Normalfall bei MCP), geht der Dialog leicht unter. Neuer, eigenständiger McpConfirmDialog statt einer Style-Änderung am geteilten ConfirmDialog (hätte alle dessen bestehenden Aufrufer mitbetroffen): breiter (520 statt 420), auffälliger Kopfbereich ("Vorschlag deines KI-Assistenten — bitte prüfen"), Topmost. AvaloniaMcpConfirmationService holt das Hauptfenster zusätzlich aus einer möglichen Minimierung und aktiviert es (WindowState/Activate()), bevor der Dialog erscheint. - move_lesson: deckt sowohl "eine Stunde verschieben" als auch "Lücke für eine neue Stunde öffnen" ab, indem es die bereits vorhandene, bislang nur UI-seitig genutzte LessonSchedulingService.Move kapselt (shiftFollowingLessons=true verschiebt alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mit — "Lücke öffnen" = die Stunde hinter der Lücke so verschieben, danach create_lesson auf das freigewordene Datum). Keine neue Terminlogik geschrieben, nur wiederverwendet. - delete_lesson — bewusste, gezielte Ausnahme von "v1 ohne Lösch-Tools": explizite Nutzeranforderung. Lesson hängt (anders als die meisten anderen Entitäten) nicht am Papierkorb-System — ILessonRepository.Delete löscht endgültig, kein Restore. Deshalb: eigene McpToolScope.AllowedDestructiveWriteTools-Liste getrennt von den normalen Write-Tools (fällt beim Lesen sofort auf), Destructive = true-Annotation im MCP-Protokoll, und eine Bestätigungsnachricht, die explizit "NICHT rückgängig zu machen" sagt (Wortlaut an den bestehenden Lösch-Dialog in PlanningTabView.axaml.cs angelehnt: "wird endgültig gelöscht"). - 8 neue Tests in McpToolsTests.cs (jetzt 40), u.a.: shiftFollowingLessons verschiebt nur Stunden NACH der bewegten, nicht davor; ein belegtes Ziel liefert nach bereits erfolgter Bestätigung einen Fehler statt stillschweigend zu überschreiben (die bestehende EnsureTargetIsFree-Prüfung in LessonSchedulingService greift unverändert).

  • 4.5.32 Sitzungsfreigabe für den Bestätigungsdialog + LessonStatus.Cancelled (2026-09-12, Nutzer-Feedback: 17x "Phase hinzufügen" einzeln bestätigen müssen ist unzumutbar). - Sitzungsfreigabe: McpConfirmDialog bietet jetzt zwei Checkboxen ("diese Art von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"), nur beim Bestätigen wirksam (Ablehnen + Checkbox wäre widersprüchlich, wird ignoriert — siehe McpConfirmDialog.axaml.cs). IMcpConfirmationService.ConfirmAsync bekommt einen neuen operationKey-Parameter, bewusst per [CallerMemberName] automatisch befüllt (Name der aufrufenden Tool-Methode, z.B. "AddLessonPhase") — kein bestehender Aufruf in den Tool-Klassen musste dafür geändert werden. Freigaben leben als reines In-Memory-Bookkeeping auf der AvaloniaMcpConfirmationService-Singleton-Instanz, gelten bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut), nie persistiert. Automatisch bestätigte Aufrufe werden trotzdem geloggt (AppLogger.Info), damit stillschweigende Bestätigungen nicht spurlos bleiben. - LessonStatus.Cancelled ("Ausgefallen") als Alternative zu delete_lesson, wenn eine Stunde nur ausgefallen ist (Exkursion, Feiertag, Vertretung), aber als Ereignis dokumentiert bleiben soll — als Cancelled = 4 angehängt (nicht eingefügt, siehe Kommentar auf dem Enum: bestehende LiteDB-Werte 0/1 sind fix). delete_lessons Tool-Beschreibung weist jetzt aktiv auf diese Alternative hin. LessonStatusDisplay.Options/ToName/FromName erweitert — der bestehende Status-Auswahl-ComboBox in LessonDialog.axaml (bindet bereits an StatusOptions) zeigt "Ausgefallen" dadurch automatisch an, ohne XAML-Änderung. - Konsistente Behandlung wie bereits durchgeführte Stunden an allen Stellen, die bisher nur auf Conducted prüften (eine ausgefallene Stunde ist ebenfalls "resolved", nicht mehr offen): LessonSchedulingService.Move/SplitAndMoveSecondPart (nicht mitverschoben bzw. Fortsetzung auf Entwurf zurückgesetzt), GroupOverviewViewModel.LoadNextLesson (nicht als nächste Stunde vorgeschlagen), AdvanceLessonStatus (Endzustand, kein Weiterschalten). Fortschrittsanzeige (UnitSummary) zählt ausgefallene Stunden weder zum Pensum noch zu den gehaltenen Stunden (Nenner sinkt statt künstlich zu wachsen). - LoadOpenHomeworkCheck/HasUnhandledHomework (Hausaufgabe-kontrollieren-Erinnerung) überspringen ausgefallene Stunden bei der Suche nach der "letzten Stunde" — sonst hätte eine ausgefallene Stunde mit liegengebliebenem Hausaufgabentext fälschlich weiter genagt. - 9 neue Tests: 2 in LessonSchedulingServiceTests.cs, neues LessonStatusDisplayTests.cs (Round-Trip aller Status-Namen + Fortschrittsberechnung), 1 in McpToolsTests.cs (belegt den [CallerMemberName]-Mechanismus ohne echtes UI).

  • 4.5.33 Fach-/Kompetenzkatalog-Verwaltung + Kompetenzzuordnung zu Einzelstunden über MCP (2026-09-12, Nutzer-Nachtrag): Nutzer möchte einem KI-Agenten die volle Verwaltung der Fächer (Subject) und des Kompetenzkatalogs (CompetencyDomain/CompetencyItem, siehe Kompetenzen-Tab in den Einstellungen, Kapitel 8) überlassen — anlegen, ändern, löschen, "einfach alles was dazugehört" — sowie das Zuordnen von Kompetenzcodes zu einzelnen Stunden. - Neue CompetencyTools.cs: get_subjects/get_competency_catalog (Read), create_subject/update_subject/ create_competency_domain/update_competency_domain/add_competency_item/ update_competency_item/remove_competency_item (Write, je mit Bestätigung), sowie delete_subject/delete_competency_domain als weitere, gezielte Ausnahmen von der "v1 ohne Lösch-Tools"-Regel neben delete_lesson (4.5.31) — beide Repositories löschen endgültig ohne Papierkorb, deshalb Destructive = true und Aufnahme in McpToolScope.AllowedDestructiveWriteTools statt der normalen Write-Liste. delete_subject nutzt die bereits bestehende Verwendungsprüfung in SubjectRepository.Delete (schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog das Fach referenziert) — keine neue Validierung nötig. - ICompetencyDomainRepository um GetBySubject(Guid) erweitert (alle Klassenstufen eines Fachs), da bisher jede Katalog-Abfrage im gesamten Code eine bekannte gradeLevel verlangte (GetBySubjectAndGrade) — für get_competency_catalog ohne Klassenstufen-Filter fehlte diese Möglichkeit. CompetencyDomainRepository/FakeCompetencyDomains entsprechend ergänzt. - Kompetenzzuordnung zu Lessons: Lesson bekommt ein neues Feld Competencies (List<string>, analog zu Unit.Competencies) sowie zwei neue Tools in LessonPlanTools.cs: add_lesson_competency/remove_lesson_competency. Bewusst granular statt eines "set_lesson_competencies", das die ganze Liste ersetzt — dieselbe Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (4.5.28). Bewusst auf Stundenebene, nicht je Phase — die in TODO.md 4.5.8 beschriebene feinere Zuordnung je Verlaufsplan-Phase bleibt offen und ist von dieser Änderung unabhängig; Lesson.Competencies ist ein eigenständiges, gröberes Feld. - FakeSubjects (Tests) von No-Op-Stub auf echtes In-Memory-CRUD (inkl. Namens-Dubletten-Prüfung analog zu SubjectRepository.Save) umgestellt, da bisher kein Test tatsächlich über MCP schrieb und ein reiner No-Op genügte. - 16 neue Tests in McpToolsTests.cs (jetzt 57).

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

  • 5.1.1 Dialog "Dokumentation hinzufügen" mit Typwahl (Conversation, Incident, SupportPlan, Absence) und typabhängigen Feldern — DocumentationDialog.axaml, DocumentationViewModels.cs. Deutsche Anzeige über DocumentationTypeDisplay/SupportStatusDisplay, analog zum NiveauDisplay-Muster. Feldbezogene Validierung wie in 13.2.4.
  • 5.1.2 Teilnehmerliste (Participants) bei Gesprächen — Chip-Liste mit Hinzufügen/ Entfernen im Dialog, nur sichtbar bei Typ "Gespräch".
  • 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).
  • 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.
  • 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 ("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. 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 — Data-Tests laufen jetzt sequenziell (bei elementaren In-Memory-Tests kein spürbarer Zeitverlust).
  • 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.
  • 5.1.7 Dokumentation je Lerngruppe (Nutzer-Feedback: der "Dokumentation"-Tab der Lerngruppe war seit seiner Anlage nur ein Platzhalter — "Wird implementiert."). Neuer GroupDocumentationTabView/ GroupDocumentationTabViewModel 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.
  • 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)

  • 5.2.1 Schnelle Abwesenheitserfassung je Stunde — bereits vorhanden über AttendanceHomeworkQuickInputDialog und den Mitarbeits-Assistenten (Kapitel 3).
  • 5.2.2 Fehlzeitenbilanz je Schüler und laufendes Schuljahr — 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.
  • 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.
  • 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

  • 5.3.1 Förderplan anlegen (Maßnahmenliste, Überprüfungsdatum, Status) — über den 5.1-Dialog mit Typ "Förderplan" (SupportData: Measures, ReviewDate, Status).
  • 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.
  • 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

  • 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).
  • 5.4.2 Löschfristen — neuer Tab "Datenschutz" in den Einstellungen: konfigurierbare Aufbewahrungsfrist in Jahren (PrivacySettingsService.cs, Standard 3 Jahre), Liste abgelaufener Einträge zur manuellen Prüfung mit "Endgültig löschen" (IDocumentationRepository.HardDelete). Löscht nie automatisch.
  • 5.4.3 Export einzelner Schülerdaten für Auskunftsersuchen (Art. 15 DSGVO) — 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

  • 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.

  • 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).

  • 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.

  • 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).

  • 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.

  • 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 WorkTasks (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)?
  • 6.1.8 E-Mail-Ordner-Triage fürs dienstliche Postfach (Nutzerwunsch, 2026-08-30): Der Nutzer verwaltet Termine/Bitten/Rundschreiben/Messevorbereitungen aktuell ausschließlich per Mail (eM Client gegen ein schlecht konfiguriertes Open-Xchange-"Fake-Exchange", echtes Outlook/EWS funktioniert dort nicht zuverlässig — Open-Xchange spricht aber Standard-IMAP). Ziel ist keine vollwertige Mail-Client-Funktion in LehrerApp (kein Compose/Reply, keine allgemeine Ordnerverwaltung, kein Postfach-weites Lesen), sondern eine schmale Brücke: Der Nutzer legt in eM Client einen Ordner an (z.B. "→LehrerApp") und verschiebt dorthin manuell, was eine Aktion in der Schule braucht. LehrerApp verbindet sich per IMAP ausschließlich mit diesem einen Ordner plus einem festgelegten Archiv-Zielordner — keine anderen Ordner werden je adressiert, insbesondere nicht der Posteingang. Zeigt die Mails im Triage-Ordner an (Betreff/Absender/Datum/Textvorschau) und bietet pro Mail "→ Termin anlegen" / "→ Aufgabe anlegen" (öffnet die bestehenden Dialoge aus 6.1.2 bzw. Kapitel 4, vorbefüllt mit Betreff als Titel). Nach dem Anlegen verschiebt LehrerApp die Mail automatisch in den Archiv-Ordner (UID MOVE, RFC 6851; falls vom Open-Xchange-Server nicht unterstützt: Fallback COPY + \Deleted-Flag + EXPUNGE, jeweils nur innerhalb des Triage-Ordners) — genau der manuelle "Mail raussuchen und archivieren"-Schritt entfällt damit. Echtes Löschen bleibt eine separate, bewusst zusätzliche Aktion (eigener Button, nicht automatisch beim Archivieren mitgemacht).

    • Bevorzugt zu prüfen — eM Client 11 MCP-Support (Stand 2026-09-02, laut Ankündigung des Herstellers noch in der Beta): eM Client kündigt für v11 einen MCP-Server an ("find accounts and folders, search and retrieve emails or conversations, perform selected mailbox actions, draft and send messages, and more"). Klingt breit genug, um genau die hier benötigten Primitive abzudecken (Ordner ansprechen, Mails lesen, Mailbox-Aktion ausführen) — und würde, wenn der Funktionsumfang das hergibt, die IMAP-Variante unten komplett überflüssig machen: LehrerApp bräuchte dann gar keine eigenen Postfach-Zugangsdaten, sondern spräche als MCP-Client die bereits eingeloggte eM-Client-Instanz auf demselben Gerät an. Löst den Mehrgeräte-Fall (siehe unten) sogar eleganter als der IMAP-Ansatz: die Aktion ist automatisch nur dort verfügbar, wo eM Client lokal läuft und angemeldet ist — ganz ohne eigenen SourceMailAccount-Abgleich. Vor einer Festlegung zu klären, sobald die Beta zugänglich ist: (1) deckt "perform selected mailbox actions" wirklich gezieltes Verschieben in einen bestimmten Zielordner ab, oder nur eine feste Auswahl (z.B. Lesen/Flag/Junk)? (2) welcher Transport (lokaler Prozess über stdio, lokaler HTTP-Port?) und gibt es dafür schon eine nutzbare .NET/C#-MCP-Client-Bibliothek? (3) muss eM Client dafür im Vordergrund laufen oder reicht ein im Hintergrund laufender Prozess? (4) Stabilität der Schnittstelle vor Verlassen der Beta. Solange das offen ist, bleibt der IMAP-Weg unten der Fallback-Plan — funktioniert unabhängig vom Mail-Programm und auch, falls der Nutzer eM Client je wechselt.
    • Fallback-Architektur (IMAP via MailKit): wie LehrerApp.WebUntis ein eigenständiger, serverunabhängiger Client — neues LehrerApp.Mail-Projekt (IMAP-Verbindung + Nachrichtenliste + Move/Delete, via MailKit — .NET hat kein natives IMAP in der BCL, System.Net.Mail deckt nur SMTP ab; MailKit ist der De-facto-Standard, MIT-lizenziert, Eintrag in Directory.Packages.props), Desktop bindet es analog zu LehrerApp.WebUntis direkt ein. Zugangsdaten (IMAP-Host/Port/Nutzer/Passwort, Triage- und Archiv-Ordnername) dürfen wie WebUntis-Zugangsdaten nicht über LehrerApp.Api laufen — rein lokal, gleiches Einstellungsfeld-Muster wie die WebUntis-URL (Kapitel 12). Passwort wird lokal verschlüsselt abgelegt (Nutzerentscheid: gleiches Bedrohungsmodell wie eM Client selbst — wer lokal an die Zugangsdaten kommt, hätte auch direkten Zugriff auf das Mailprogramm), kein separater OS-Credential-Store nötig. Der Postfach-Bereich (Navigationspunkt, Triage-Ansicht) bleibt in der UI komplett verborgen, bis IMAP-Zugangsdaten hinterlegt sind — gleiches Sichtbarkeits-/DI-Registrierungsmuster wie SyncEngine/SnapshotService, die laut AppBootstrapper.LoadServerUrl nur bei konfigurierter Server-URL überhaupt registriert werden.
    • Mehrgeräte-Fall: Ein aus einer Mail erzeugter WorkTask/Termin synct wie jedes andere Objekt ganz normal über den bestehenden Sync-Layer zu allen Geräten (kein Zusatzaufwand, siehe die OnChange-Begründung im Nachtrag oben). Es fehlt nur der Rückkanal: das tatsächliche Verschieben/Löschen der Mail kann nur das Gerät ausführen, das selbst IMAP- Zugangsdaten zum gleichen Postfach hinterlegt hat. Dafür trägt der WorkTask ein neues, nicht-geheimes Herkunftsfeld (z.B. SourceMailAccount = IMAP-Nutzername/Adresse, SourceMailFolder, SourceMailUid) — synct automatisch mit. Jeder Client vergleicht dieses Feld beim Anzeigen mit seiner eigenen lokalen IMAP-Konfiguration: nur bei Übereinstimmung wird der "Postfach archivieren"-Button überhaupt angezeigt; ohne passende (oder ganz ohne) lokale Zugangsdaten sieht der Task nur einen Hinweis "stammt aus Mail auf …", aber keine ausführbare Aktion. Verhindert, dass ein Gerät ohne die richtigen Zugangsdaten versucht, auf ein Postfach zuzugreifen, das es gar nicht kennt.
    • Offen: Poll-Intervall; ob UID MOVE vom Open-Xchange-Server tatsächlich unterstützt wird (vorher gegen den echten Server prüfen, sonst greift der Fallback); ob eine Mail-Vorschau nur Text oder auch einfaches HTML rendern soll; Verhalten bei Anhängen (zunächst vermutlich ignorieren, nur Text/Metadaten).

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

  • 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).
  • 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.
  • 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.
  • 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.

Nachtrag — Dashboard-Erinnerung "Unterrichtszeit nacherfassen" (Nutzer-Feedback): "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — der Vorschlag oben deckte nur den heutigen Tag ab und nur, solange man die Arbeitszeit-Ansicht überhaupt öffnet. Neue Dashboard-Karte (DashboardViewModel.LoadMissingTeachingTime) schaut die letzten 14 Tage zurück und listet jeden Schultag, an dem laut Stundenplan (alle Gruppen, wie beim bestehenden Vorschlag) Unterricht stattfand, aber noch kein TimeEntry der Kategorie "Unterricht" existiert — Ferien, Feiertage und per SubstitutionKind.Cancelled komplett ausgefallene Tage zählen nicht mit (gleiche Prüfung wie bei "Ungeplante Stunden", 9.x). Für den heutigen Tag erscheint der Eintrag bewusst erst 30 Minuten nach dem laut Stundenplan letzten Unterrichtsende (MissingTeachingTimeTodayDelayMinutes), damit die Erinnerung nicht schon mitten im Unterricht auftaucht. Klick auf "Erfassen" öffnet denselben AddTimeEntryDialog wie der bestehende Tages-Vorschlag, vorbelegt mit Datum, Kategorie "Unterricht" und dem für diesen Tag berechneten Zeitfenster — auch hier bleibt die Bestätigung im Dialog Pflicht, nichts wird automatisch gebucht. Die gleiche Liste steht zusätzlich direkt im Tab Arbeitszeit → Zeiterfassung, damit die Nacherfassung nicht nur über das Dashboard auffindbar ist; offene Tage zählen außerdem im Dashboard-Tagesfokus als Handlungsbedarf.

6.3 Auswertung

  • 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.
  • 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.
  • 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, 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

  • 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.

  • 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.

  • 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.

  • 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.

  • 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

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

  • 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.
  • 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.
  • 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, Format dokumentiert in Kompetenzkatalog-KI-Prompt.md.

8.1 Katalogverwaltung

  • 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.
  • 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.
  • 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.
  • 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

  • 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.
  • 8.2.2 Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4.
  • 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

  • 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.
  • 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.
  • 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. 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

Nachtrag Kachelraster-Bugfix (September 2026): ApplyCardLayout() zählte bislang IsVisible statt EffectiveIsVisible für die Zeilen-/Spaltenzuordnung — eine eingeschaltete, aber gerade leere HideWhenEmpty-Kachel (im Alltag der Normalfall, da meist mehrere Hinweiskacheln leer sind) belegte dadurch weiterhin einen Rasterplatz und riss eine Lücke ins zweispaltige Grid. Zusätzlich hing der Grid-Margin jeder Kachel fest im XAML an einer Spalte (links/rechts), obwohl Spalte und Zeile erst zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet werden — beim Aus-/Einblenden einer Kachel wanderten die Nachbarn in die andere Spalte, der Rinnstein saß dann auf der falschen Seite. DashboardCardOption.Margin leitet den Wert jetzt aus Column ab, das XAML bindet darauf statt fixer Werte, und UpdateDashboardSummary() ruft ApplyCardLayout() erst auf, nachdem alle IsEmpty-Werte für den aktuellen Ladevorgang feststehen.

Nachtrag Handlungsbedarf-Zusammenfassung (September 2026, Nutzer-Feedback): Die sieben Kacheln, die tatsächlich "wo muss ich reagieren" beantworten — Offene Entschuldigungen (9), Fehlzeiten- Warnung (5.2.3), Förderplan-Wiedervorlage (5.3.2), Offene Korrekturen (9.4), Ungeplante Stunden (9.9), Auffälligkeiten (9.5), Unterrichtszeit nacherfassen — sind zu einer Karte "Handlungsbedarf" mit Filter-Chips zusammengefasst. Neues gemeinsames Modell AttentionItem/AttentionGroup/ AttentionAction (AttentionItem.cs) trägt nur Anzeigedaten; die Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx- Methoden, die am Ende ein AttentionItem statt ihrer eigenen Item-Klasse erzeugen. DashboardViewModel.RebuildAttention() gruppiert nach Art (feste Reihenfolge, keine kachel-übergreifende Neusortierung nach Dringlichkeit — das hätte die je Art bewusst gewählte Sortierung, z.B. Fehlzeiten nach Quote absteigend, wieder zerstört) und wendet die Filter-Chips an, ohne die Repos erneut abzufragen. SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel (Kurs-Dashboard) sie weiterhin direkt nutzt. Fünf Navigations-Commands (OpenStudentAttendance, OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) wurden durch ein einziges OpenAttentionItemCommand ersetzt, das das im AttentionItem mitgegebene Navigate- Delegate aufruft. Bewusste Verhaltensänderung: die "Auffälligkeiten"-Kachel zeigte bisher Fehlzeiten-Überschreitungen zusätzlich zu Notenabfall/Versetzungsgefährdung — in der zusammengefassten Karte wäre das dieselbe Zahl doppelt (einmal als eigene Gruppe "Fehlzeiten- Warnung", einmal als "Auffälligkeit"), was vor dem Merge durch zwei getrennte Kacheln nicht auffiel; LoadAlerts erzeugt diese Kopie deshalb nicht mehr. Die Filter-Chips sind bewusst nur Sitzungszustand (keine Persistenz in dashboardsettings.json, das dortige Format ist eine flache Kachel-Liste ohne Platz für Sub-Filter je Art). "Klausurwochen" bleibt weiterhin eine eigene Kachel (Wochen-Aggregat, kein Einzelvorgang zum Abhaken/Anklicken).

Nachtrag Uhrzeit-Wraparound-Bug (September 2026): Zwei MissingTeachingTime-Tests bauten ihr "Unterrichtsende" bisher aus TimeOnly.FromDateTime(DateTime.Now).AddHours(±2). TimeOnly wickelt bei Mitternacht um (kein Datumsanteil) — bei Testausführung zwischen ca. 22:00 und 02:00 Uhr wurde aus "+2 Stunden" dadurch ein Ende, das vor statt nach dem echten Ablauf des 30-Minuten-Fensters lag (oder umgekehrt bei "-2 Stunden"), je nach Ausführungsuhrzeit zufällig rot. DashboardViewModel erhält deshalb einen optionalen Func<DateTime>? now-Konstruktorparameter (Default DateTime.Now, gleiches Muster wie die bestehenden optionalen annualPlanEvents/schoolWeather-Parameter — DI löst unregistrierte optionale Parameter über ihren Default auf) und nutzt ihn in Load() sowie LoadMissingTeachingTime statt direkter DateTime.Now-Aufrufe. Die beiden betroffenen Tests injizieren jetzt einen festen Referenzzeitpunkt (FixedNow, ein Dienstag im September — bewusst gewählt, weil in diesem Monat kein bundesweiter oder länderspezifischer Feiertag liegt) und sind damit unabhängig von der tatsächlichen Ausführungsuhrzeit deterministisch grün.


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

  • 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.
    
  • 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.
    
  • 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).
    
  • 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.
    
  • 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`.
    
  • 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.
    
  • 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).
    
  • 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).
    
  • 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).
    
  • 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.
    
  • 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

  • 10.2.1 Benutzerverwaltung/Registrierung prüfen und absichern (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.
    
  • 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.
    
  • 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/ 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.
    
  • 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

  • 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.
    
  • 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).
    
  • 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.

  • 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

  • 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.

  • 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`).
    
  • 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.
    
  • 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, 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

  • 13.1.1 Testprojekt LehrerApp.Tests angelegt (referenziert LehrerApp.Core, analog zu LehrerApp.Data.Tests für die Datenschicht). Noch ohne Testfälle — folgt mit 13.1.2ff.
  • 13.1.2 Unit-Tests für GradingService (Notenschlüssel-Grenzfälle, gewichteter Schnitt) — 41 Tests in GradingServiceTests.cs, deckt CalculateGrade, ValidateGradingKey, WeightedAverage, RoundToGrade, ParticipationGrade, ValidateGradingScheme und CalculateReportGrade ab.
  • 13.1.3 Unit-Tests für SchoolYearService (Jahreswechsel, Schuljahresgrenze) — 13 Tests in 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.
  • 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: 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.
  • 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. 9 Tests für ParticipationGradeAggregationTests.cs (gewichtetes Mittel, "nicht bewertet" ≠ schlechte Note, Trend, Aspektgewicht 0, Halbjahresfilter, Übernehmen-Update-statt-Duplikat) und 9 für ReportGradeCalculationTests.cs (Schema-Auflösung gruppenspezifisch/Voreinstellung/Fallback, Pflicht-Begründung beim Übersteuern, Festschreiben friert den Stand ein, Rundungsregel).
  • 13.1.6 Tests für ConflictResolver — neues Testprojekt LehrerApp.Sync.Tests, 10 Tests in 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

  • 13.2.1 Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz — 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.
  • 13.2.2 Logging in Datei im App-Datenverzeichnis, mit Rotation — AppLogger.cs, tägliche Datei unter <AppData>/LehrerApp/logs/, Dateien älter als 14 Tage werden beim Start gelöscht. 6 Tests in AppLoggerTests.cs.
  • 13.2.3 Einheitliche Benachrichtigungen (Toast/Snackbar) für Erfolg und Fehler — NotificationService.cs, Overlay unten rechts in 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.
  • 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

  • 13.3.1 Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10) — 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.
  • 13.3.2 Wiederherstellung aus einem Backup über die Einstellungen — neuer Tab "Sicherheit" in SettingsView.axaml listet vorhandene Backups mit Zeitstempel/Größe, "Wiederherstellen" fragt über einen neuen generischen ConfirmDialog 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.
  • 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.
  • 13.3.4 Optionale Verschlüsselung der lokalen Datenbank — 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) 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.
  • 13.3.5 App-Sperre nach Inaktivität — AppLockService.cs (eigenes, gesalzenes PBKDF2-Passwort, unabhängig von einer eventuellen Datenbank-Verschlüsselung) + AppLockViewModel.cs (Inaktivitäts-Timer, Sperrbildschirm-Logik). Maus-/Tastatureingaben im Hauptfenster setzen den Timer zurück (MainWindow.axaml.cs); läuft er ab, blendet ein Overlay in 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 (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.
  • 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

  • 13.4.1 CLAUDE.md mit Projektkonventionen anlegen (/init) — 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.
  • 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-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). - 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.
  • 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.

  • 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 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.

  • 14.11 Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird über MainWindowViewModel.ActiveNavItem gesteuert.

  • 14.12 Tab "Übersicht" im Kurs (GroupDetailView, bislang nur Platzhaltertext) gefüllt — neue 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.