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