88 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 4542f263fc fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:39:45 +02:00
adminandClaude Sonnet 5 86a97d91a7 fix: Anwesenheitskalender/Fehltagesliste nutzen die real deklarierte Boxgröße
CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170)
aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen
Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in
"pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template)
blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer
- sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF.

CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real
deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template
(LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die
Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:27:05 +02:00
adminandClaude Sonnet 5 763d7744be feat: Circle/RoundedRectangle-Zeichenbefehle im Canvas-Backend, Fehlzeitenkalender-Kacheln größer
CI / build-and-test (push) Waiting to run
Neue DrawingCommand-Primitives DrawCircle und DrawRoundedRectangle (SVG-Rendering
plus FLOWDRAWBOX-Paginierung). Anwesenheitskalender nutzt RoundedRectangle für die
Tageskacheln und verkleinert Monatsabstand/Kachel-Innenabstand, damit die Kacheln
den Rasterplatz sichtbar besser ausfüllen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:59:08 +02:00
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
admin 0b5cfc5522 feat: Feldeditor für Autofelder im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-09-15 11:07:37 +02:00
admin 09cbbf1b77 feat: Fehlzeiten Tabelle für Templates 2026-09-15 10:35:55 +02:00
admin 1671796844 feat: Fehlzeiten Calender 2026-09-15 10:02:33 +02:00
admin 70dc78904c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-14 00:54:43 +02:00
adminandClaude Sonnet 5 7b660c7152 feat: Planungsideen-Feld je Stunde + persistierter Materialerstellungs-Prompt
CI / build-and-test (push) Canceled after 0s
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der
Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung
(Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den
beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft,
statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor
ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP
lesbar/schreibbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:41:13 +02:00
admin 3675b70004 feat: exclude group from untis hub
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:58:38 +02:00
admin eb1b2340b7 feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:16:36 +02:00
adminandClaude Sonnet 5 b8193be6ec fix: Untis-Hub-Status ueber Geraete hinweg synchronisieren
CI / build-and-test (push) Canceled after 0s
UntisHubJobState (Faelligkeits-Zeitstempel des Untis-Hubs) lief bisher
ausserhalb des Sync - jedes Geraet fuehrte seine eigene Buchhaltung, wodurch
auf allen Instanzen dieselben Punkte offen blieben und ein bereits erledigter
Abgleich anderswo erneut WebUntis-Traffic ausgeloest haette (Nutzer-Feedback).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:24:03 +02:00
admin 0962845ead fix: startup speed
CI / build-and-test (push) Canceled after 0s
2026-09-13 22:04:07 +02:00
adminandClaude Sonnet 5 42c650518c MCP: TMPDIR-Auseinanderdriften zwischen Desktop und Bridge auf Mac/Linux behoben
LehrerApp.Desktop und LehrerApp.McpBridge emulieren die Named Pipe unter
macOS/Linux über eine Socket-Datei unter TMPDIR. Von Finder/Dock
gestartet bekommt Desktop das reguläre Session-TMPDIR unter
/var/folders/.../T, während die Bridge als Kindprozess von Claude
Desktop oft eine reduzierte Umgebung mit TMPDIR=/tmp erbt - beide
suchten die Pipe-Datei dann an unterschiedlichen Orten und fanden sich
nie ("MCP nicht erreichbar", obwohl beide Prozesse liefen). TMPDIR wird
jetzt in beiden Prozessen vor der ersten Pipe-Instanz hart auf denselben,
von ApplicationData abgeleiteten Ordner gesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:46:04 +02:00
adminandClaude Sonnet 5 2c791258a1 fix: GroupId ueber MCP auflösbar (get_groups + get_untis_hub_status)
CI / build-and-test (push) Canceled after 0s
Nutzer-Feedback aus echtem Live-Test der neuen Untis-MCP-Tools: groupId ist ueberall
Pflichtparameter (get_grades, get_schedule, get_lesson_plans, get_untis_absence_rows, ...), war
aber nirgends ueber MCP auflösbar - ein KI-Client kannte bestenfalls den Klarnamen einer
Lerngruppe, nie ihre Id.

- Neues GroupTools.cs mit get_groups (Read): listet Lerngruppen mit Id/Name/Typ/Schuljahr/
  Klassenstufe/SubjectId/IsActive, optional nach Schuljahr gefiltert.
- UntisHubStatusRowDto liefert zusaetzlich GroupId mit (null bei den drei dashboard-weiten
  Zeilen), damit fuer eine dort gelistete Gruppe nicht zusaetzlich get_groups noetig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:13:13 +02:00
admin c528bd825f fix: mcp timeout
CI / build-and-test (push) Canceled after 0s
2026-09-13 00:38:05 +02:00
adminandClaude Sonnet 5 56ab5067e6 feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
Neue Menü-Kategorie "Formulare" in der Menüleiste: "Elternbrief erzeugen..." öffnet
jetzt einen Schüler-Picker statt nur aus der Schülerdetailansicht erreichbar zu sein,
"Arbeitsblatt personalisieren..." ist aktiv, sobald eine einzelne Lerngruppe geöffnet
ist, und erzeugt aus einer in TemplateDesigner gebauten .lavorlage-Vorlage ein PDF je
aktivem Gruppenmitglied - über eine von den Elternbrief-Vorlagen getrennte
WorksheetTemplateStore-Bibliothek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:08 +02:00
adminandClaude Sonnet 5 bfe214ecf2 feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 22:55:33 +02:00
adminandClaude Sonnet 5 d979a84e7b feat: LehrerApp.McpBridge zu resilientem MCP-Proxy umgebaut
Claude Desktop deaktivierte den ganzen MCP-Server, sobald der Initialize-Handshake beim
Bridge-Start fehlschlug - der Regelfall, wenn LehrerApp.Desktop (z.B. nach Windows-Autostart
von Claude Desktop) noch nicht läuft. Die Bridge ist jetzt selbst ein MCP-Server gegenüber
Claude Desktop und gleichzeitig ein MCP-Client gegenüber dem echten Server in LehrerApp.Desktop:
der Handshake gelingt dadurch immer.

Verbunden spiegelt sie die echte Werkzeugliste 1:1; ohne Verbindung bietet sie nur ein
lehrerapp_status-Werkzeug an, das den Grund erklärt und erneut verbindet. Ein Hintergrund-Loop
versucht unabhängig davon alle 5s zu reconnecten und schaltet per notifications/tools/list_changed
automatisch auf die echten Werkzeuge um, sobald LehrerApp erreichbar ist. Bricht die Verbindung
während eines laufenden Aufrufs ab, kommt ein normales Tool-Fehlerergebnis statt eines
Prozessabsturzes zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:45:03 +02:00
adminandClaude Sonnet 5 3f8813df51 feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)
Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain,
add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und
delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:01:23 +02:00
adminandClaude Sonnet 5 19aa302487 feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.

LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 02:25:45 +02:00
adminandClaude Sonnet 5 a4733c156b feat: prominenterer Bestätigungsdialog + move_lesson/delete_lesson (Nutzer-Feedback)
Nach dem ersten Live-Test mit echtem Bridge-Prozess: der bisherige
ConfirmDialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief
(Normalfall, da der Anstoß vom KI-Client in einem anderen Fenster
kommt). Neuer, eigenständiger McpConfirmDialog statt Änderung am
geteilten ConfirmDialog (hätte alle anderen Aufrufer mitbetroffen):
breiter, auffälliger Kopfbereich, Topmost. AvaloniaMcpConfirmationService
holt das Hauptfenster zusätzlich aus einer möglichen Minimierung und
aktiviert es vor dem Anzeigen.

move_lesson kapselt die bereits vorhandene LessonSchedulingService.Move
(shiftFollowingLessons öffnet eine Lücke für eine neue Stunde, indem
spätere Stunden derselben Einheit mitverschoben werden) - keine neue
Terminlogik, nur Wiederverwendung.

delete_lesson ist eine bewusste, gezielte Ausnahme von "v1 ohne
Lösch-Tools" auf expliziten Nutzerwunsch: eigene
AllowedDestructiveWriteTools-Liste, Destructive=true-Annotation,
Bestätigungstext betont ausdrücklich die fehlende Papierkorb-Deckung
für Lessons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:43:59 +02:00
adminandClaude Sonnet 5 e6c30b0bc7 feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag)
CI / build-and-test (push) Canceled after 0s
download_lesson_attachment deckte bisher nur die Leserichtung ab, der
Nutzer legt Arbeitsblätter aber tatsächlich über die Anhang-Funktion an
Einzelstunden ab. Neues Write-Tool nimmt Dateiname + Base64-Inhalt
entgegen, validiert vor der Bestätigungsnachfrage (leer/ungültig/zu
groß) und lädt erst nach Bestätigung hoch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:45:56 +02:00
adminandClaude Sonnet 5 55fba2cadb feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)
Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen
im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen
(LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit
Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und
render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff).

upload_worksheet/update_worksheet bewusst nicht umgesetzt: das
Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem
visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie
blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik
aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert,
damit Dialog und MCP-Tool nicht auseinanderdriften.

Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude
Desktops claude_desktop_config.json ein (Button in den Einstellungen,
nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne
eine nicht lesbare Konfigurationsdatei zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:53:46 +02:00
adminandClaude Sonnet 5 dd2e1e7c61 feat: lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools)
Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als
ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation
(Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten
ohne Phasen), add/update/remove_lesson_phase (je eine Phase),
download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das
Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für
den Bestätigungsdialog statt eines Objekt-Dumps.

ILessonRepository um GetById ergänzt (fehlte bisher, war aber
Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt
zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie
gezielt referenzieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:52:48 +02:00
adminandClaude Sonnet 5 0c60a54c4d build: LehrerApp.McpBridge ins macOS-App-Bundle aufnehmen
build-macos-app.sh publiziert und platziert jetzt zusätzlich zur
Hauptapp die MCP-Bridge-Binary (Contents/MacOS/LehrerApp.McpBridge),
inklusive Universal-Binary-Handling und eigenständiger Ad-hoc-Signierung
vor dem Bundle-weiten codesign --deep-Schritt. Publish/Lipo/Kopier-Logik
dafür in eine wiederverwendbare place_binary()-Funktion extrahiert.

Nur auf Bash-Syntax geprüft (kein macOS in dieser Session verfügbar) -
noch nicht auf echtem macOS gebaut/verifiziert, siehe TODO.md 4.5.27.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:23:45 +02:00
adminandClaude Sonnet 5 9567d8d616 feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und
update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt
zuerst einen menschenlesbaren Bestätigungsdialog (bestehender
ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread
angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout
gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans.

create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden
Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/
update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt
(siehe TODO.md 4.5.26).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:14:51 +02:00
adminandClaude Sonnet 5 98f5573999 feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)
Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten
Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung.
Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named
Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch
(ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe).
Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab.
Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf
Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed).

Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und
macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:59:47 +02:00
admin 455c61c946 fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

DashboardViewModel erhaelt einen optionalen Func<DateTime>? now-Konstruktorparameter (Default
DateTime.Now, gleiches Muster wie die bereits bestehenden optionalen annualPlanEvents/
schoolWeather-Parameter) und nutzt ihn statt direkter DateTime.Now-Aufrufe in Load() und
LoadMissingTeachingTime. Die beiden betroffenen Tests injizieren jetzt einen festen
Referenzzeitpunkt (ein Feiertags-freier Dienstag im September) und sind damit unabhaengig
von der tatsaechlichen Ausfuehrungsuhrzeit deterministisch gruen.

Details siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 01:19:29 +02:00
admin 6af4bee1f0 refactor: Dashboard-Handlungsbedarf zu einer Karte zusammenfassen
Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten
(Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen,
ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer
Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener
Sichtbarkeits-Schalter.

Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur
Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen
DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer
eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste
Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen.

SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und
DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem
und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie
weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges
OpenAttentionItemCommand ersetzt.

Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen
nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in
derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar).

Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 00:40:58 +02:00
admin c3ce1a7204 fix: Dashboard-Kachelraster - Luecken und falsche Margins bei ausgeblendeten Kacheln
ApplyCardLayout() zaehlte bisher IsVisible statt EffectiveIsVisible, wodurch eine
eingeschaltete aber leere HideWhenEmpty-Kachel weiterhin einen Rasterplatz belegte.
Da im Alltag meist mehrere der sieben HideWhenEmpty-Kacheln leer sind, war das der
Normalfall, nicht die Ausnahme. UpdateDashboardSummary() setzte zudem alle IsEmpty-
Werte, ohne das Layout danach neu zu berechnen.

Zusaetzlich hatte jede Kachel ihren Grid-Margin fest im XAML verdrahtet (links/rechts),
obwohl Spalte und Zeile erst zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet
werden - beim Ausblenden einer Kachel wanderten die Nachbarn in die andere Spalte,
der Rinnstein sass dann auf der falschen Seite. DashboardCardOption.Margin leitet den
Wert jetzt aus Column ab; das XAML bindet darauf statt fixer Werte.

Drei Regressionstests decken beide Faelle ab.
2026-09-11 00:12:10 +02:00
adminandClaude Sonnet 5 ba52109eaa feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s
Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:41:12 +02:00
admin bd0d7e47ea fix: UntisLogo
CI / build-and-test (push) Canceled after 0s
2026-09-09 00:07:39 +02:00
admin 2ec8adac61 Webuntis stunden check
CI / build-and-test (push) Canceled after 0s
2026-09-08 23:59:07 +02:00
admin e86125e5f9 Hausaufgaben feature und Stundeplan fix
CI / build-and-test (push) Canceled after 0s
2026-09-08 18:01:42 +02:00
admin 86e114580f Fix: Sync Log und verwaise Einträge
CI / build-and-test (push) Canceled after 0s
2026-09-07 17:09:38 +02:00
admin 835cccadec ki Backend fix - Answer Size
CI / build-and-test (push) Canceled after 0s
2026-09-07 12:45:50 +02:00
admin 709ea88c2a Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:51:17 +02:00
admin 73453fd88d Add Feature: Monatsübersicht Fehlzeiten Widget
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:27:52 +02:00
admin 265190b3aa UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s
2026-09-05 00:02:28 +02:00
admin fedfceb81d KI-Backend: Antwortretten - Funktion
CI / build-and-test (push) Canceled after 0s
2026-09-04 12:10:42 +02:00
admin 7ef0c95a73 Optimierung des Startvorgangs, lazy load, async im thread pool oder während des Startbildschirms vor dem Mainwindow.
CI / build-and-test (push) Canceled after 0s
2026-09-02 21:46:01 +02:00
adminandClaude Sonnet 5 94946ffefb Stundenplan: verwaiste Stunden per "FixIt"-Button verknüpfen
CI / build-and-test (push) Canceled after 0s
Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer
(kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne
Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer
tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen"
an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog
sucht stattdessen nach einer passenden vorhandenen Stunde und hängt
sie auf den geklickten Termin um.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:27:06 +02:00
admin 3cccb3226c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-02 20:09:24 +02:00
adminandClaude Sonnet 5 988a293c42 Planung: Stunde einer anderen Einheit zuweisen ("Einheit wechseln")
Bisher war die Einheit einer Stunde nach dem Anlegen fix - bei falscher
Auswahl im Erstellen-Dialog blieb nur Löschen+Neuanlegen. Neuer Button
öffnet einen Dialog zur Auswahl der Ziel-Einheit; die Ansicht springt
danach automatisch dorthin mit der Stunde vorausgewählt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:07:53 +02:00
adminandClaude Sonnet 5 92f9a75ec1 E-Mail-Ordner-Triage: eM Client 11 MCP-Support als bevorzugten Weg ergänzen
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 18:59:41 +02:00
adminandClaude Sonnet 5 0a419c4c11 WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:41 +02:00
adminandClaude Sonnet 5 216708e879 Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
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>
2026-09-01 23:52:38 +02:00
adminandClaude Sonnet 5 0d5fb6d146 Vorgänge: verknüpfte Dokumentation direkt zum Nachlesen öffnen
CI / build-and-test (push) Canceled after 0s
"Bearbeiten"-Button an jedem verknüpften bzw. verknüpfbaren Eintrag
öffnet den bestehenden Dokumentations-Dialog, ohne den Vorgänge-Tab zu
verlassen — bisher musste man dafür in den Schülerdatensatz wechseln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:08:59 +02:00
admin 1d9521fc60 SplashScreen
CI / build-and-test (push) Canceled after 0s
2026-09-01 22:51:50 +02:00
admin 4af83c3e68 Update Launch.json 2026-09-01 22:43:10 +02:00
adminandClaude Sonnet 5 15463bc292 "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
CI / build-and-test (push) Canceled after 0s
Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung,
Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene)
WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem
oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per
Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:42:06 +02:00
adminandClaude Sonnet 5 987b49e761 Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s
Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag,
sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter
(Standardansicht) sortiert die Klasse nach einem kumulierten
Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status,
und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein
(Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort
deutlich hoch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:36:34 +02:00
adminandClaude Sonnet 5 fb7a015b42 CalendarDatePicker: DateTimeOffset<->DateTime-Konvertierung per Converter
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist (WPF-Toolkit-Ursprung) DateTime?,
nicht DateTimeOffset? wie beim eingebauten DatePicker - Avalonia bietet
dafuer keine automatische Konvertierung. Ohne Converter blieb das Feld
leer und ein Klick auf einen Kalendertag warf eine InvalidCastException.
Betraf auch die schon bestehenden Bindings in WithdrawStudentDialog und
CreateLetterDialog, nur bislang unbemerkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:09:08 +02:00
adminandClaude Sonnet 5 09e97efb61 CalendarDatePicker: DateTimeOffset-Felder nullbar machen
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist DateTimeOffset?, die per DatePicker-
zu-CalendarDatePicker-Umstellung betroffenen ViewModel-Eigenschaften
waren aber nicht-nullbares DateTimeOffset. Der alte DatePicker tolerierte
das, CalendarDatePickers Bindung nicht - Feld blieb leer und ein
Konvertierungsfehler wurde beim Auswählen geworfen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:19:07 +02:00
adminandClaude Sonnet 5 d297f9f109 Klassenbuch-Tab zum Dokumentations-Hub ausgebaut
CI / build-and-test (push) Canceled after 0s
Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt
Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges
je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der
"Klassenbuch öffnen"-Aktion der Übersicht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:05:13 +02:00
admin de6d4236a2 Merge branch 'claude/epic-murdock-f9aa7b'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	TODO.md
2026-09-01 15:34:41 +02:00
admin edf7510765 Datepicker update
CI / build-and-test (push) Canceled after 0s
2026-09-01 15:31:16 +02:00
adminandClaude Sonnet 5 2ccc3201d2 DatePicker durch CalendarDatePicker ersetzen (Zeitraum-Datumsfelder)
Avalonias Spinner-DatePicker übernimmt Änderungen nur über einen separaten
Häkchen-Button im Flyout und verwirft sie sonst stillschweigend. Betrifft
alle verbliebenen Zeitraum-Datumsfelder mit diesem Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:46:53 +02:00
admin ab1feba0f0 Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
#	LehrerApp.TemplateDesigner/DesignerViewModel.cs
#	LehrerApp.TemplateDesigner/MainWindow.axaml
#	LehrerApp.Templating/LayoutParser.cs
#	LehrerApp.Templating/QuestTemplateRenderer.cs
2026-09-01 10:15:54 +02:00
admin b2d0ccd30d TemplateDesigner: Redesign des TemplateSkripts 2026-09-01 10:07:25 +02:00
admin c6efdab9ef TemplateDesigner: SystemVars
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:47:48 +02:00
admin 11ef73ab1f TemplateDesigner: pdf-Export
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:28:08 +02:00
admin 4b4a746e17 Update im KI-Backend für pdf-import
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:20:19 +02:00
admin e2e2bfb854 feat: add external drawing boxes and paged callbacks
CI / build-and-test (push) Canceled after 0s
2026-08-31 22:10:06 +02:00
admin 3e5f197bdb feat: add flowing templates and PDF import pipeline
CI / build-and-test (push) Canceled after 0s
2026-08-31 19:56:25 +02:00
adminandClaude Sonnet 5 95e3f677e4 Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen
Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum
dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen
liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke
also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als
Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten
Ferienkalender für den Rest des Jahres.

Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene
Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) -
sonst wäre der falsche Wert wieder unbemerkt geblieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:21:16 +02:00
adminandClaude Sonnet 5 cdbed69d8b Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür
nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem
Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option.

Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und
zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach
Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt
jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:35 +02:00
admin 527c186090 Add constant rich text placeholders
CI / build-and-test (push) Canceled after 0s
2026-08-30 23:23:48 +02:00
adminandClaude Sonnet 5 28469bf547 E-Mail-Ordner-Triage als offenen Punkt in TODO.md skizzieren
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:55:57 +02:00
admin 9dce6576e2 TemplateDesigner: UI update
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:47:53 +02:00
admin 9f28081931 Fix Platzhalter Editer im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:30:26 +02:00
admin 6468596bf3 Add designer file menu and package metadata
CI / build-and-test (push) Canceled after 0s
2026-08-30 20:49:13 +02:00
adminandClaude Sonnet 5 23abe7af63 Dashboard-Erinnerung für fehlende Unterrichtszeit-Erfassung
CI / build-and-test (push) Canceled after 0s
Neue Karte "Unterrichtszeit nacherfassen" schaut 14 Tage zurück und
listet Schultage mit Unterricht laut Stundenplan ohne zugehörigen
"Unterricht"-Zeiteintrag (Ferien/Feiertage/komplett ausgefallene Tage
ausgenommen). Für heute erscheint der Eintrag erst 30 Minuten nach dem
laut Stundenplan letzten Unterrichtsende. Klick auf "Erfassen" öffnet
den bestehenden Nacherfassen-Dialog vorbelegt mit Datum, Kategorie
"Unterricht" und berechnetem Zeitfenster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:38:57 +02:00
admin 3f71f7f371 Add visual template coordinate editor 2026-08-30 19:29:06 +02:00
admin 2f2761caf8 QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s
2026-08-30 15:37:22 +02:00
adminandClaude Sonnet 5 a088823b30 Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit
CI / build-and-test (push) Canceled after 0s
Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den
betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und
Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen
jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:24:16 +02:00
adminandClaude Sonnet 5 1215d4ac22 Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
CI / build-and-test (push) Canceled after 0s
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag /  Schlechter Tag)
  je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit
  Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für
  Lerngruppen ohne Sitzplan.
- Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren,
  Mitarbeit- und sonstige Noten eines Schülers samt berechneter
  Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet
  "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert
  eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im
  selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je
  Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten.
  Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:43:47 +02:00
adminandClaude Sonnet 5 84c03962ef Klausurschnitt-Vorjahresvergleich beim Hochstufen
CI / build-and-test (push) Canceled after 0s
LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch
gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den
gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort
Ergebnisse vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:33:07 +02:00
admin b7c7d46ee4 Doppelstunden im Stundenplan korrekt anzeigen 2026-08-30 02:29:50 +02:00
adminandClaude Sonnet 5 da4b88c93d Persönliche Klausurlast pro Woche, Mitarbeit-Reset und Sitzplan-Strichliste
CI / build-and-test (push) Canceled after 0s
- Klausuren: neuer ExamWeekLoadService zählt eigene Klausuren je ISO-Woche
  über alle Kurse hinweg (Klassen-Kollisionen deckt bereits der externe
  Klausurplaner ab). Hinweis direkt im ExamDialog beim Datum, zusätzlich
  neue Dashboard-Karte "Klausurwochen" für die 60-Tage-Vorausschau.
- Mitarbeit: Schnelleingabe-Dialog kann eine Bewertung jetzt per Entf auf
  "nicht bewertet" zurücksetzen (bisher nur im Sitzplatz-Dialog möglich).
- Sitzplan: Strichliste "gemeldet"/"gemeldet und drangekommen" direkt auf
  der Sitzplatz-Kachel (ParticipationEntry.RaisedHandCount/CalledOnCount);
  schlägt im Sitzplatz-Dialog eine Quantitätsbewertung vor (ein Klick zum
  Übernehmen, nie automatisch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:11:20 +02:00
adminandClaude Sonnet 5 538ad65a3a Dashboard-Kalender: Tageszahlen zentrieren, Karte nicht mehr strecken
Wochentag-Header waren zentriert, die Tageszahlen darunter aber
faktisch linksbündig (Button ohne HorizontalAlignment wurde in der
UniformGrid-Spalte gestretcht). Zusätzlich wurde die Kalenderkarte bei
breiten Fenstern über die ganze 2*-Spalte gezogen; MaxWidth+Left-Align
begrenzt sie auf ihre Inhaltsbreite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:06:23 +02:00
282 changed files with 23818 additions and 1508 deletions
+21 -6
View File
@@ -5,7 +5,7 @@
"name": "Desktop App starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Desktop bauen",
"preLaunchTask": "TemplateDesigner bauen",
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
"args": [],
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
@@ -15,6 +15,20 @@
"DOTNET_ENVIRONMENT": "Development"
}
},
{
"name": "TemplateDesigner App starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Desktop bauen",
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
"args": [],
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
"stopAtEntry": false,
"console": "internalConsole",
"env": {
"DOTNET_ENVIRONMENT": "Development"
}
},
{
"name": "API Server starten",
"type": "coreclr",
@@ -33,11 +47,12 @@
}
},
{
"name": "Desktop + API (gleichzeitig)",
"configurations": [
"Desktop App starten",
"API Server starten"
]
"name": "API Tests starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "API bauen",
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
"args": [],
}
],
"compounds": [
+13
View File
@@ -27,6 +27,19 @@
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "TemplateDesigner bauen",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
"--configuration", "Debug",
"--verbosity", "minimal"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "Solution bauen",
"command": "dotnet",
+6 -2
View File
@@ -17,13 +17,17 @@
<!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
<PackageVersion Include="PdfPig" Version="0.1.13" />
<!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<!-- MCP-Server (lokal, siehe TODO.md) -->
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
<!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -76,6 +76,9 @@ public class AiLesson
public List<AiPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; }
public string? Reflection { get; set; }
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
public string? PlanningIdeas { get; set; }
}
public class AiPhaseStep
@@ -100,6 +103,10 @@ public class AiPlanningResponse
{
public List<AiLesson> Lessons { get; set; } = [];
public string? Summary { get; set; }
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
// weder angezeigt noch gespeichert.
public string? RawResponse { get; set; }
}
/// <summary>
@@ -174,3 +181,54 @@ public class AiSubstanceResearchResponse
public string ActivityRestriction { get; set; } = "";
public string Source { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
/// </summary>
public class AiUntisStatusRequest
{
public List<AiUntisStatusRow> Rows { get; set; } = [];
}
public class AiUntisStatusRow
{
public string Id { get; set; } = "";
public string ReasonText { get; set; } = "";
public int AbsentMinutes { get; set; }
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
public bool HandledOn { get; set; }
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
public bool? ExternKeyInParentheses { get; set; }
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
public string CurrentGuess { get; set; } = "";
}
/// <summary>
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
/// </summary>
public class AiUntisStatusResponse
{
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
}
public class AiUntisStatusSuggestion
{
public string Id { get; set; } = "";
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
public string Status { get; set; } = "";
}
@@ -95,6 +95,7 @@ public interface IUnitRepository
}
public interface ILessonRepository
{
Lesson? GetById(Guid id);
List<Lesson> GetByUnit(Guid unitId);
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
@@ -170,6 +171,14 @@ public interface IUntisCacheFetchStateRepository
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state);
}
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
/// den dashboard-weiten Jobs.
public interface IUntisHubJobStateRepository
{
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
List<UntisHubJobState> GetAll();
void Save(UntisHubJobState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
@@ -200,6 +209,15 @@ public interface IDocumentationRepository
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
void HardDelete(Guid id);
}
public interface IVorgangRepository
{
List<Vorgang> GetAll();
List<Vorgang> GetByStudent(Guid studentId);
Vorgang? GetById(Guid id);
void Save(Vorgang vorgang);
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
void Delete(Guid id);
}
public interface IWorkTaskRepository
{
List<WorkTask> GetByStatus(WorkTaskStatus status);
@@ -267,6 +285,10 @@ public interface ISubjectRepository
public interface ICompetencyDomainRepository
{
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
List<CompetencyDomain> GetBySubject(Guid subjectId);
CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain);
void Delete(Guid id);
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Mcp;
/// <summary>
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
/// </summary>
public static class McpPipeConstants
{
public const string PipeName = "LehrerApp.Mcp";
/// <summary>
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
/// Socket-Datei).
/// </summary>
public static void EnsureStableUnixSocketDirectory()
{
if (OperatingSystem.IsWindows()) return;
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp", "ipc");
Directory.CreateDirectory(dir);
Environment.SetEnvironmentVariable("TMPDIR", dir);
}
}
+15
View File
@@ -26,6 +26,21 @@ public class LearningGroup
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary>
public int? WebUntisLessonId { get; set; }
/// <summary>
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
/// verhalten sich unverändert wie bisher).
/// </summary>
public bool ExcludedFromUntisHub { get; set; }
/// <summary>
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
/// </summary>
public Guid? PreviousGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+41
View File
@@ -24,9 +24,26 @@ public class ParticipationEntry
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { get; set; }
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
// ParticipationCountSuggestion.
public int RaisedHandCount { get; set; }
public int CalledOnCount { get; set; }
/// <summary>
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
/// </summary>
public DayHighlightKind? DayHighlight { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public enum DayHighlightKind { Standout, Sleepy, Rough }
/// <summary>
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
_ => value,
};
}
/// <summary>
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
/// sinnvolle, unmissverständliche Abbildung.
/// </summary>
public static class ParticipationCountSuggestion
{
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
{
if (type != AspectValueType.Scale5) return null;
return raisedHandCount switch
{
0 => -2,
1 => -1,
2 or 3 => 0,
4 or 5 => 1,
_ => 2,
};
}
}
+40 -1
View File
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
public TimeOnly? StartTime { get; set; }
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
public string? PlanningIdeas { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = [];
public TeachingTimelineState? TeachingTimeline { get; set; }
public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
@@ -63,6 +71,11 @@ public class Lesson : IHasAttachments
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; }
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<string> Competencies { get; set; } = [];
public LessonStatus Status { get; set; } = LessonStatus.Planned;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
@@ -89,6 +102,14 @@ public class LessonPhaseStep
public string Activity { get; set; } = "";
public string Material { get; set; } = "";
public string Shorthand { get; set; } = "";
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
public string? MaterialPrompt { get; set; }
/// <summary>
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
@@ -103,7 +124,7 @@ public enum UnitStatus { Planned, Active, Completed }
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
/// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
@@ -239,3 +260,21 @@ public class ReportGrade
public bool IsLocked { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>Live timing is separate from the original lesson plan.</summary>
public class TeachingTimelineState
{
public DateTime StartUtc { get; set; }
public DateTime EndUtc { get; set; }
public DateTime? HeldSinceUtc { get; set; }
public Guid? HeldPhaseId { get; set; }
public List<TeachingPhaseTiming> Phases { get; set; } = [];
public List<Guid> TransferredPhaseIds { get; set; } = [];
}
public class TeachingPhaseTiming
{
public Guid PhaseId { get; set; }
public double Minutes { get; set; }
public bool ExplicitlyStarted { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
namespace LehrerApp.Core.Models;
public enum UntisHubJobKind
{
FehlzeitenKurz,
FehlzeitenLang,
OffenePeriods,
Klassenbuchabgleich,
Hausaufgabenabgleich,
}
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
public class UntisHubJobState
{
public Guid Id { get; set; } = Guid.NewGuid();
public UntisHubJobKind Kind { get; set; }
public Guid? GroupId { get; set; }
public DateTime? LastRunAt { get; set; }
public string? LastResultSummary { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
/// </summary>
public class Vorgang
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public List<Guid> StudentIds { get; set; } = [];
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
public List<string> Tags { get; set; } = [];
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
public List<Guid> DocumentationIds { get; set; } = [];
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}
public enum VorgangStatus { Open, Closed }
/// <summary>
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
/// </summary>
public class VorgangClassRegisterEntry
{
public DateOnly Date { get; set; }
public string StudentName { get; set; } = "";
public string? Subject { get; set; }
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
}
@@ -12,10 +12,13 @@ public sealed class DashboardCardSetting
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
public sealed class DashboardSettingsService
{
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
"today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
];
private readonly string _configPath;
@@ -0,0 +1,54 @@
using System.Globalization;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
/// </summary>
public static class ExamWeekLoadService
{
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
/// normal, eine dritte ist die eigentliche Häufung.
public const int WarningThreshold = 3;
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
{
var (year, week) = IsoWeek(date);
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
}
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
exams
.GroupBy(e => IsoWeek(e.Date))
.Where(g => g.Count() >= WarningThreshold)
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
.OrderBy(w => w.WeekStart)
.ToList();
private static (int Year, int Week) IsoWeek(DateOnly date)
{
var dt = date.ToDateTime(TimeOnly.MinValue);
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
}
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
}
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
{
public DateOnly WeekEnd => WeekStart.AddDays(6);
}
+17 -1
View File
@@ -77,13 +77,29 @@ public class GradingService
public double WeightedAverage(List<(string Grade, double Weight)> grades)
{
var numeric = grades
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
.Where(g => g.Value.HasValue).ToList();
if (numeric.Count == 0) return 0;
var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
}
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
private static double? ParseGradeValue(string grade)
{
var trimmed = grade.Trim();
if (trimmed.Length == 0) return null;
var tendency = 0.0;
var core = trimmed;
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
return int.TryParse(core, out var n) ? n + tendency : null;
}
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
@@ -60,6 +60,7 @@ public sealed class GroupRolloverService(
IsActive = true,
IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated,
PreviousGroupId = source.Id,
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
};
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
{
foreach (var other in lessons.GetByUnit(lesson.UnitId))
{
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
continue;
other.Date = other.Date.AddDays(delta);
lessons.Save(other);
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
HomeworkChecked = source.HomeworkChecked,
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
Reflection = source.Reflection,
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
};
source.Homework = null;
source.HomeworkChecked = false;
@@ -1,316 +0,0 @@
using System.IO.Compression;
using System.Text.Json;
using System.Xml.Linq;
namespace LehrerApp.Core.Services;
public enum TemplateIssueSeverity
{
Warning,
StrongWarning,
Error,
}
public sealed record LetterPlaceholder(string Tag, string Description);
public sealed record TemplateValidationIssue(
TemplateIssueSeverity Severity,
string Message,
string? Tag = null,
string? SuggestedTag = null);
public sealed record TemplateValidationResult(
IReadOnlyList<string> Tags,
IReadOnlyList<TemplateValidationIssue> Issues)
{
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
}
public sealed class LetterTemplateInfo
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string OriginalFileName { get; set; } = "";
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
/// Die Originalvorlage wird nie verändert.
/// </summary>
public sealed class LetterTemplateService
{
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
[
new("Student.FirstName", "Vorname des Schülers"),
new("Student.LastName", "Nachname des Schülers"),
new("Contact.Name", "Name des ausgewählten Kontakts"),
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
new("Contact.Street", "Straße und Hausnummer"),
new("Contact.PostalCode", "Postleitzahl"),
new("Contact.City", "Ort"),
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
new("Group.Name", "Ausgewählte Lerngruppe"),
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
new("CurrentDate", "Gewähltes Briefdatum"),
];
private readonly string _templateDirectory;
private readonly string _indexPath;
public LetterTemplateService(string appDataPath)
{
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
_indexPath = Path.Combine(_templateDirectory, "templates.json");
Directory.CreateDirectory(_templateDirectory);
}
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
public string GetTemplatePath(LetterTemplateInfo template) =>
Path.Combine(_templateDirectory, template.StoredFileName);
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
{
var validation = Validate(sourcePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var templates = LoadIndex();
var info = new LetterTemplateInfo
{
Name = string.IsNullOrWhiteSpace(displayName)
? Path.GetFileNameWithoutExtension(sourcePath)
: displayName.Trim(),
OriginalFileName = Path.GetFileName(sourcePath),
};
info.StoredFileName = $"{info.Id:N}.docx";
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
templates.Add(info);
SaveIndex(templates);
return info;
}
public void Delete(Guid id)
{
var templates = LoadIndex();
var template = templates.FirstOrDefault(t => t.Id == id);
if (template is null) return;
var path = GetTemplatePath(template);
if (File.Exists(path)) File.Delete(path);
templates.Remove(template);
SaveIndex(templates);
}
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
Validate(GetTemplatePath(template));
public TemplateValidationResult Validate(string path)
{
var tags = new List<string>();
var issues = new List<TemplateValidationIssue>();
if (!File.Exists(path))
return Error("Die Vorlagendatei wurde nicht gefunden.");
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
return Error("Die Vorlage muss eine DOCX-Datei sein.");
try
{
using var archive = ZipFile.OpenRead(path);
var xmlEntries = WordXmlEntries(archive).ToList();
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
foreach (var entry in xmlEntries)
{
using var stream = entry.Open();
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt"))
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (string.IsNullOrWhiteSpace(tag))
{
issues.Add(new(TemplateIssueSeverity.StrongWarning,
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
continue;
}
tags.Add(tag);
}
}
}
catch (InvalidDataException)
{
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
}
catch (System.Xml.XmlException)
{
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
}
catch (IOException ex)
{
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
}
if (tags.Count == 0 && issues.Count == 0)
issues.Add(new(TemplateIssueSeverity.Warning,
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
{
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
var suggestion = FindSuggestion(tag);
issues.Add(suggestion is null
? new(TemplateIssueSeverity.Warning,
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
: new(TemplateIssueSeverity.StrongWarning,
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
}
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
TemplateValidationResult Error(string message) =>
new([], [new(TemplateIssueSeverity.Error, message)]);
}
public void Generate(string templatePath, string outputPath,
IReadOnlyDictionary<string, string?> values)
{
var validation = Validate(templatePath);
if (!validation.CanGenerate)
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
var outputDirectory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
using var source = ZipFile.OpenRead(templatePath);
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
foreach (var entry in source.Entries)
{
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
targetEntry.LastWriteTime = entry.LastWriteTime;
using var input = entry.Open();
using var output = targetEntry.Open();
if (!IsWordXmlEntry(entry))
{
input.CopyTo(output);
continue;
}
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
foreach (var control in document.Descendants(W + "sdt").ToList())
{
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
}
document.Save(output, SaveOptions.DisableFormatting);
}
}
private static void ReplaceContent(XElement? content, string value)
{
if (content is null) return;
var paragraph = content.Elements(W + "p").FirstOrDefault();
XElement run;
if (paragraph is not null)
{
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
var paragraphProperties = paragraph.Element(W + "pPr");
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
paragraph.RemoveNodes();
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
paragraph.Add(run);
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
}
else
{
var firstRun = content.Descendants(W + "r").FirstOrDefault();
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
content.RemoveNodes();
run = new XElement(W + "r");
if (runProperties is not null) run.Add(runProperties);
content.Add(run);
}
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) run.Add(new XElement(W + "br"));
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
}
}
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
archive.Entries.Where(IsWordXmlEntry);
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
private static string PartLabel(string path) => path switch
{
"word/document.xml" => "Dokumenttext",
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
};
private static string? FindSuggestion(string unknown)
{
var candidate = SupportedPlaceholders
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
.OrderBy(x => x.Distance)
.ThenBy(x => x.Tag)
.First();
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
return candidate.Distance <= threshold ? candidate.Tag : null;
}
private static int Levenshtein(string left, string right)
{
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
for (var i = 1; i <= left.Length; i++)
{
var current = new int[right.Length + 1];
current[0] = i;
for (var j = 1; j <= right.Length; j++)
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
previous = current;
}
return previous[right.Length];
}
private List<LetterTemplateInfo> LoadIndex()
{
if (!File.Exists(_indexPath)) return [];
try
{
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private void SaveIndex(List<LetterTemplateInfo> templates)
{
var temporaryPath = _indexPath + ".tmp";
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
File.Move(temporaryPath, _indexPath, overwrite: true);
}
}
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
public sealed class PatternScoreWeights
{
public double UnexcusedDayWeight { get; set; } = 3.0;
public double ExcusedDayWeight { get; set; } = 1.0;
public double LateDayWeight { get; set; } = 0.5;
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
public double ConcerningKeywordBonus { get; set; } = 1.5;
public double SuspensionKeywordWeight { get; set; } = 15.0;
}
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
/// DashboardSettingsService.</summary>
public sealed class PatternScoreSettingsService
{
private readonly string _configPath;
public PatternScoreSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
public PatternScoreWeights Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
?? new PatternScoreWeights();
}
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
return new PatternScoreWeights();
}
public void Save(PatternScoreWeights weights) =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
}
@@ -0,0 +1,48 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
/// gesuchte/unbekannte ist.
public enum GradeBucketKind { Exams, Participation, Other }
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
/// <summary>
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
/// </summary>
public static class ReportGradeTargetCalculator
{
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
double? examsAverage, double? participationAverage, double? otherAverage,
GradingScheme scheme, GradingSystem system)
{
var solvePercent = solveFor switch
{
GradeBucketKind.Exams => scheme.ExamsPercent,
GradeBucketKind.Participation => scheme.ParticipationPercent,
_ => scheme.OtherPercent,
};
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
// keine Zielnoten-Frage für ihn beantwortbar.
if (solvePercent <= 0) return null;
var known = new List<(double Avg, double Percent)>();
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
var knownContribution = known.Sum(k => k.Avg * k.Percent);
var required = (target * totalPercent - knownContribution) / solvePercent;
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
return new TargetGradeResult(required, required >= min && required <= max);
}
}
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
var call = Assert.Single(calls);
Assert.Equal((nameof(Documentation), "Delete"), call);
}
[Fact]
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Konflikt" };
repo.Save(vorgang);
var calls = new List<(string EntityType, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
repo.Delete(vorgang.Id);
var call = Assert.Single(calls);
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
Assert.True(((Vorgang)call.Payload!).IsDeleted);
}
}
+20
View File
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
Assert.Null(exception);
}
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
[Fact]
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
{
using var db = NewInMemoryContext();
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
var repo = new UntisHubJobStateRepository(db);
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
repo.Save(state);
var call = Assert.Single(calls);
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
Assert.Equal(state.Id.ToString(), call.EntityId);
Assert.Equal("Save", call.Operation);
Assert.Same(state, call.Payload);
}
}
+61
View File
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
Assert.Null(storage.OpenRead(attachmentId));
}
// ── VorgangRepository ─────────────────────────────────────────────────────
[Fact]
public void VorgangRepository_Save_SetztUpdatedAt()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
repo.Save(vorgang);
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
}
[Fact]
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var other = Guid.NewGuid();
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
repo.Save(shared);
repo.Save(unrelated);
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
}
[Fact]
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
repo.Save(vorgang);
repo.Delete(vorgang.Id);
Assert.Empty(repo.GetAll());
Assert.Null(repo.GetById(vorgang.Id));
var raw = db.Vorgaenge.FindById(vorgang.Id);
Assert.NotNull(raw);
Assert.True(raw!.IsDeleted);
Assert.NotNull(raw.DeletedAt);
}
[Fact]
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
{
using var db = NewInMemoryContext();
var repo = new VorgangRepository(db);
Assert.Null(repo.GetById(Guid.NewGuid()));
}
// ── LessonRepository ──────────────────────────────────────────────────────
[Fact]
+2
View File
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
@@ -71,6 +72,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public void Save(Unit u)
{
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
// kündigt diese automatische Ablösung vorher sichtbar an.
if (u.Status == UnitStatus.Active)
{
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
x.Status == UnitStatus.Active && x.Id != u.Id))
{
previous.Status = UnitStatus.Completed;
previous.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(previous);
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
}
}
u.UpdatedAt = DateTime.UtcNow;
db.Units.Upsert(u);
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
@@ -372,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
public class LessonRepository(LiteDbContext db) : ILessonRepository
{
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
public List<Lesson> GetByUnit(Guid id) =>
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
@@ -431,6 +446,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
}
}
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
{
public List<Vorgang> GetAll() =>
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
public List<Vorgang> GetByStudent(Guid studentId) =>
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
public Vorgang? GetById(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
}
public void Save(Vorgang vorgang)
{
vorgang.UpdatedAt = DateTime.UtcNow;
db.Vorgaenge.Upsert(vorgang);
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
}
public void Delete(Guid id)
{
var vorgang = db.Vorgaenge.FindById(id);
if (vorgang is null) return;
vorgang.IsDeleted = true;
vorgang.DeletedAt = DateTime.UtcNow;
db.Vorgaenge.Update(vorgang);
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
// (gleiches Muster wie DocumentationRepository.Delete).
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
}
}
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
{
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
@@ -854,6 +900,20 @@ public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetch
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
{
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
public void Save(UntisHubJobState state)
{
db.UntisHubJobStates.Upsert(state);
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
}
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -861,6 +921,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder)
.ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
db.CompetencyDomains
.Find(d => d.SubjectId == subjectId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
.ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d)
{
@@ -1,6 +1,9 @@
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Net;
using System.Text;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -15,6 +18,196 @@ public sealed class AiPlanningServiceTests
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
[Fact]
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
{
const string json = """
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
""";
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
Assert.Equal("Redox", lesson.Topic);
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
}
[Fact]
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
{
const string json = """
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
""";
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
Assert.Equal("Nur diese Stunde", lesson.Topic);
Assert.Single(lesson.Phases);
}
[Fact]
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
{
var error = Assert.Throws<AiBackendException>(() =>
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
}
[Fact]
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
{
const string body = """
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
""";
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
}
[Fact]
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
{
const string body = """
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
}
[Fact]
public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung()
{
var http = new HttpClient(new CanceledResponseHandler())
{
BaseAddress = new Uri("https://example.invalid/"),
};
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
service.RequestPlanAsync(new Unit(), "", "token"));
Assert.Contains("3½ Minuten", error.Message);
Assert.Contains("Webserver-Proxys", error.Message);
}
[Fact]
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var lessons = new FakeLessons();
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
vm.ImportAsNewCommand.Execute(null);
Assert.True(vm.Result);
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
}
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[]
{
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
};
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Equal("Excused", suggestions["0"]);
Assert.Equal("Unexcused", suggestions["1"]);
}
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[]
{
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
};
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Empty(suggestions);
}
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
{
const string body = """
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
""";
var service = BuildWithResponse(HttpStatusCode.OK, body);
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
Assert.Empty(suggestions);
}
[Fact]
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
{
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
Assert.Empty(suggestions);
}
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
{
var http = new HttpClient(new StaticResponseHandler(status, body))
{
BaseAddress = new Uri("https://example.invalid/"),
};
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
}
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
private sealed class CanceledResponseHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
new CancellationToken(canceled: true));
}
[Fact]
public void BuildContext_FuelltGruppenUndFachKontext()
{
@@ -1,12 +1,177 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test",
"Störung", "Negativ", "Unterricht massiv gestört"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false);
Assert.Equal(0, days.Count % 5);
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true);
Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H");
}
[Fact]
public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test",
"Störung", "Negativ", "Unterricht gestört"),
new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test",
"Hausaufgaben", "Negativ", "Hausaufgaben fehlen"),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register,
new DateOnly(2026, 9, 3));
Assert.Equal(30, days.Count);
Assert.False(days[0].HasSignal);
Assert.Equal("1", days[0].DayNumber);
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
Assert.True(days[2].IsToday);
Assert.Contains("Ada Müller", days[2].Tooltip);
Assert.Equal("E", days[3].SignalCode);
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
}
[Fact]
public void Kompaktkalender_KannAufEinzelnenSchuelerEingeschraenktWerden()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
"Ada Müller");
Assert.Equal("U", days[2].SignalCode);
Assert.False(days[3].HasSignal);
}
[Fact]
public void ElternbriefKalender_LiefertPortablenDrawingPlatzhalterFuerSchueler()
{
var month = new DateOnly(2026, 9, 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90,
["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller", month, absences, []);
Assert.True(drawing.ContentHeight > 0);
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U");
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
}
[Fact]
public void ElternbriefKalender_UnterstuetztEinBisDreiMonateUndGroessen()
{
var start = new DateOnly(2026, 9, 18);
var small = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Small), [], []);
var large = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var labels = large.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("September 2026", labels);
Assert.Contains("Oktober 2026", labels);
Assert.Contains("November 2026", labels);
Assert.True(large.ContentHeight > small.ContentHeight);
}
// Regression: DRAWBOX bricht anders als FLOWDRAWBOX nicht automatisch um - Inhalt, der die von
// der Vorlage deklarierte Höhe überschreitet, wird von QuestTemplateRenderer stillschweigend am
// unteren Rand abgeschnitten (kein Fehler, keine Warnung). "Größe" muss deshalb automatisch so
// weit verkleinert werden, dass alle gewählten Monate innerhalb von contentHeight Platz finden.
[Fact]
public void ElternbriefKalender_VerkleinertGroesseAutomatischUmDieVorlagenHoeheEinzuhalten()
{
var start = new DateOnly(2026, 9, 18);
var uneingeschraenkt = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], []);
var engeBox = StudentAttendanceCalendarDrawingBuilder.Build("Ada Müller",
new AttendanceCalendarOptions(start, 3, AttendanceCalendarSize.Large), [], [],
contentHeight: 60);
Assert.True(uneingeschraenkt.ContentHeight > 60,
"Testvoraussetzung: 3 Monate in 'Groß' brauchen ohne Deckelung mehr als 60 Einheiten Höhe.");
Assert.True(engeBox.ContentHeight <= 60 + 0.01f);
}
[Fact]
public void ElternbriefFehltage_ListetDatumUmfangUndEntschuldigungsstatus()
{
var options = new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1);
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["entsch."], ["Krank"], null, null, true),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 8), "Müller Ada", 1, 2, 90,
["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 9), "Schmidt Ben", 2, 4, 180,
["Eng"], [1, 2, 3, 4], ["entsch."], ["Krank"], null, null, true),
};
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Ada Müller", options, absences);
var labels = drawing.Commands.OfType<DrawStringEx>().Select(c => c.Text).ToList();
Assert.Contains("03.09.2026", labels);
Assert.Contains("Ganzer Fehltag", labels);
Assert.Contains("08.09.2026", labels);
Assert.Contains("Fehlzeit · 2 Std.", labels);
Assert.Contains("Entschuldigt", labels);
Assert.Contains("Unentschuldigt", labels);
Assert.DoesNotContain("09.09.2026", labels);
}
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
@@ -184,7 +349,8 @@ public sealed class ClassTeacherViewModelsTests
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20));
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
termStart: new DateOnly(2026, 8, 1)));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
@@ -192,9 +358,29 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
{
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
termStart: new DateOnly(2026, 8, 1)));
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{
@@ -207,6 +393,198 @@ public sealed class ClassTeacherViewModelsTests
Assert.Null(row.YearSummaryTooltip);
}
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
[Fact]
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
{
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
// treiben, taucht aber weiterhin als eigene Zahl auf.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = Enumerable.Range(0, 3)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(0, row.YearAbsenceDayCount);
Assert.Equal(3, row.YearLateDayCount);
Assert.Equal(0, row.YearAbsenceRatePercent);
Assert.True(row.HasYearSummary);
Assert.Contains("verspätet", row.YearSummaryLabel);
Assert.DoesNotContain("%", row.YearSummaryLabel);
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
{
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(1, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(0, row.YearLateDayCount);
}
[Fact]
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
{
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
var lateStudent = Student(1001, "Verpennt Timo");
var truantStudent = Student(1002, "Geschwaenzt Cem");
var yearAbsences = Enumerable.Range(0, 10)
.SelectMany(i => new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
})
.ToArray();
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
Assert.Equal(5.0, timo.PatternScore);
Assert.Equal(30.0, cem.PatternScore);
Assert.True(cem.PatternScore > timo.PatternScore * 5);
}
[Fact]
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
{
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
var student = Student(1001, "Ada Müller");
var yearAbsences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
.ToArray();
// Kein todayAbsences-Eintrag: heute anwesend.
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.False(row.HasAbsenceToday);
Assert.True(row.PatternScore > 0);
}
[Fact]
public void PatternScore_IstNullOhneJahresdaten()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(0.0, row.PatternScore);
}
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
[Fact]
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
{
var student = Student(1001, "Ada Müller");
var yearRegister = Enumerable.Range(0, 4)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
"Unterrichtsstörung", "Negativ", "Gestört"))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister));
Assert.Equal(4, row.YearNegativeClassRegisterCount);
Assert.Equal(4.0, row.PatternScore);
Assert.True(row.HasYearSummary);
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
}
[Fact]
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
{
var withKeyword = Student(1001, "Keyword Kim");
var plain = Student(1002, "Plain Priya");
var yearRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
Assert.True(kim.PatternScore > priya.PatternScore);
}
[Fact]
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
{
var suspended = Student(1001, "Suspendiert Sam");
var chronic = Student(1002, "Viele Vera");
var yearRegister = new List<UntisForeignClassRegisterEventDto>
{
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
"Vom Unterricht suspendiert"),
};
yearRegister.AddRange(Enumerable.Range(0, 6)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
"Sonstiges", "Negativ", "Gestört")));
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
Assert.True(sam.HasSuspensionEntry);
Assert.True(sam.PatternScore > vera.PatternScore);
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
}
[Fact]
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
{
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
// Einträgen bekommen statt stillschweigend leer auszugehen.
var student = Student(1001, "Ada Müller");
var entries = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
new DateOnly(2026, 8, 26)));
Assert.True(row.PatternScore > 0);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
@@ -264,6 +642,79 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
}
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
[Fact]
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
{
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
// nötig statt CountSchoolWeekdays allein.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
}
[Fact]
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
{
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
}
[Fact]
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
{
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
var termStart = new DateOnly(2026, 8, 13);
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
Assert.Equal(13, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
@@ -290,6 +741,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(rows[1].IsOverdue);
}
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]
@@ -409,4 +941,325 @@ public sealed class ClassTeacherViewModelsTests
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
// ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ─────────────
[Fact]
public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum()
{
var ada = Guid.NewGuid();
var fremd = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") };
var docs = new List<Documentation>
{
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" },
new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" },
new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" },
new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst()
{
var id = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") };
var docs = new List<Documentation>
{
new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" },
new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true },
new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "");
Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge()
{
// Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe
// RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der
// Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht.
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
{
(ada, "Ada Müller"), (ben, "Ben Schmidt"),
};
var docs = new List<Documentation>
{
new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" },
new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" },
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches,
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada");
Assert.Equal(["Ada"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
{
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
{
(Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
};
var orphan = new Documentation
{
StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
Title = "Vorfall", Participants = ["Karim", "Saleh"],
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
Assert.Same(orphan, Assert.Single(result));
}
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
[Fact]
public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile()
{
var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben",
"mueller", "Fehlende HA", "Negativ", "Buch vergessen");
var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row);
Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date);
Assert.Equal("Schmidt Ben", snapshot.StudentName);
Assert.Equal("Deu", snapshot.Subject);
Assert.Equal("mueller", snapshot.TeacherUsername);
Assert.Equal("Fehlende HA", snapshot.CategoryName);
Assert.Equal("Negativ", snapshot.CategoryGroup);
Assert.Equal("Buch vergessen", snapshot.Text);
}
[Fact]
public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler()
{
var ada = Guid.NewGuid();
var ben = Guid.NewGuid();
var fremd = Guid.NewGuid();
var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" };
var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" };
var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" };
var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" };
var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] };
var allDocs = new List<Documentation> { linked, unlinked, otherStudentInCase, unrelated };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang);
Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title));
}
[Fact]
public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege()
{
var ada = Guid.NewGuid();
var doc = new Documentation { StudentId = ada, Title = "Verknüpft" };
var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] };
var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang);
Assert.Empty(result);
}
// ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ─────────
[Fact]
public void MatchDomains_ErkenntBereicheAnSchluesselwoertern()
{
Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"]));
Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"]));
Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"]));
}
[Fact]
public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche()
{
// Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein
// Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann.
var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]);
Assert.Equal(3, domains.Count);
Assert.Contains(VorgangScoreDomain.Attendance, domains);
Assert.Contains(VorgangScoreDomain.Lateness, domains);
Assert.Contains(VorgangScoreDomain.Classbook, domains);
}
[Fact]
public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum()
{
var closed = new List<Vorgang>
{
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) },
new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) },
new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) },
};
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]);
Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]);
Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook));
}
[Fact]
public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum()
{
var closed = new List<Vorgang> { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } };
var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed);
Assert.Empty(cutoffs);
}
[Fact]
public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 };
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
weights: customWeights));
Assert.Equal(10.0, row.PatternScore);
}
[Fact]
public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
// Vor dem Abschluss: darf für den Score nicht mehr zählen.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach dem Abschluss: zählt weiter.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
// 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5.
Assert.Equal(3.5, row.PatternScore);
// Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter.
Assert.Equal(2, row.YearUnexcusedDayCount);
Assert.Equal(2, row.YearAbsenceDayCount);
}
[Fact]
public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.PatternScore);
}
[Fact]
public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(0.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
}
[Fact]
public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss()
{
var student = Student(1001, "Ada Müller");
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
// Nach Abschluss erneut unentschuldigt gefehlt.
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var closedVorgaengeByNameKey = new Dictionary<string, IReadOnlyList<Vorgang>>
{
[UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang
{
Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed,
ClosedAt = new DateTime(2026, 8, 12),
}],
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
closedVorgaengeByNameKey: closedVorgaengeByNameKey));
Assert.Equal(3.0, row.RegressionScore);
Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle);
// Quote weiterhin unangetastet.
Assert.Equal(2, row.YearUnexcusedDayCount);
}
}
@@ -1,7 +1,7 @@
using System.IO.Compression;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -9,16 +9,12 @@ namespace LehrerApp.Desktop.Tests;
public sealed class CreateLetterDialogViewModelTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact]
public void OhneVorlage_KannKeinenBriefErzeugen()
{
var student = StudentWithContact("Sehr geehrte Frau Muster,");
var vm = Build(student, new LetterTemplateService(_directory));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
}
@@ -26,83 +22,313 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
[Fact]
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
{
var service = ServiceWithTemplate("Letter.Salutation");
var vm = Build(StudentWithContact(null), service);
var vm = Build(StudentWithContact(null), StoreWithTemplate(
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
public void VollstaendigeDaten_ErzeugenFlachesPdf()
{
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
var output = Path.Combine(_directory, "Brief.docx");
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var output = Path.Combine(_directory, "Brief.pdf");
var generated = vm.Generate(output);
Assert.True(generated);
Assert.True(File.Exists(output));
using var archive = ZipFile.OpenRead(output);
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
var xml = reader.ReadToEnd();
Assert.Contains("Sehr geehrte Frau Muster,", xml);
Assert.Contains("Lena", xml);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
{
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
var output = Path.Combine(_directory, "Konstant.pdf");
vm.SaveCommand.Execute(null);
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
new(student, service, new FakeMemberships([]), new FakeGroups([]));
private LetterTemplateService ServiceWithTemplate(params string[] tags)
[Fact]
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing);
var output = Path.Combine(_directory, "Kalender.pdf");
Assert.True(vm.UsesAttendanceCalendar);
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
// Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen,
// unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war -
// bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der
// eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft"
// rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an
// den Builder weiterreichen.
[Fact]
public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite()
{
var manifest = new TemplateManifest
{
var entry = archive.CreateEntry("word/document.xml");
using var writer = new StreamWriter(entry.Open());
var controls = string.Join("", tags.Select(tag =>
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
$"<w:body>{controls}</w:body></w:document>");
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
};
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
TemplatePackage.Create(source, manifest,
$"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
float? receivedWidth = null; float? receivedMillimeterScale = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(250, receivedWidth);
Assert.Equal(1, receivedMillimeterScale);
}
// Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in
// "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur
// rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt
// als ~2.83pt gerendert).
[Fact]
public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm()
{
var manifest = new TemplateManifest
{
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
};
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
TemplatePackage.Create(source, manifest,
$"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
float? receivedWidth = null; float? receivedMillimeterScale = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
Assert.True(vm.UsesAttendanceCalendar);
Assert.Equal(487, receivedWidth);
Assert.NotNull(receivedMillimeterScale);
Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
AttendanceCalendarOptions? requested = null;
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
{ requested = options; return Task.CompletedTask; });
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
Assert.NotNull(requested);
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
Assert.Equal(2, requested.NormalizedMonthCount);
Assert.False(vm.IsRefreshingAttendanceData);
Assert.Equal("", vm.AttendanceRefreshError);
}
[Fact]
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
(_, _, _, _) => new DrawingValue([], 0),
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
Assert.True(vm.AttendanceCalendarConfigured);
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
Assert.False(vm.IsRefreshingAttendanceData);
}
[Fact]
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
{
var store = StoreWithTemplate(new PlaceholderDefinition(
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
Assert.False(vm.UsesAttendanceCalendar);
Assert.True(vm.UsesAbsenceDayList);
Assert.True(vm.UsesAttendanceAdvancedContent);
Assert.True(vm.CanGenerate);
}
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
[Fact]
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Empty(vm.CustomPlaceholders);
Assert.True(vm.CanGenerate);
var output = Path.Combine(_directory, "StudentName.pdf");
Assert.True(vm.Generate(output));
}
[Fact]
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var vm = Build(StudentWithContact(null), store);
vm.LetterText = "Dies ist der Inhalt.";
Assert.Equal("", vm.Anrede);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
vm.Anrede = "Sehr geehrte Familie Beispiel,";
var output = Path.Combine(_directory, "AnredeManuell.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
}
[Fact]
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
{
var student = new Student
{
FirstName = "Lena", LastName = "Beispiel",
Contacts =
[
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
],
};
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.Equal(2, vm.Contacts.Count);
Assert.Contains("Frau Beispiel", vm.AddressPreview);
Assert.Contains("Erste Str. 1", vm.AddressPreview);
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
Assert.Contains("Herr Beispiel", vm.AddressPreview);
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
}
[Fact]
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
{
var contacts = new List<LetterContactChoice>
{
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
};
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
Assert.Same(contacts[1], dialogVm.SelectedContact);
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
dialogVm.SelectedContact = contacts[0];
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
var lines = new List<string> { "PAGE 210 297 mm" };
var y = 20;
foreach (var definition in definitions)
{
var element = definition.Type switch
{
PlaceholderType.Multiline => "TEXTBOX",
PlaceholderType.Drawing => "DRAWBOX",
_ => "TEXT",
};
lines.Add(element switch
{
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
_ => $"TEXT 20 {y} ${definition.Name}",
});
y += 20;
}
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
service.Import(source, "Elternbrief");
return service;
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
}
private static Student StudentWithContact(string? salutation) => new()
{
FirstName = "Lena",
LastName = "Beispiel",
Contacts =
[
new Contact
{
Name = "Frau Muster",
Relation = "Mutter",
LetterSalutation = salutation,
Street = "Hauptstraße 1",
PostalCode = "12345",
City = "Musterstadt",
},
],
FirstName = "Lena", LastName = "Beispiel",
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
};
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using System.Globalization;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -19,14 +20,75 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.False(vm.ExcusesCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.False(vm.AttentionCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible);
}
/// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine
/// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das
/// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind.
[Fact]
public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen.
Assert.False(vm.AttentionCard.EffectiveIsVisible);
var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList();
Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze);
}
[Fact]
public void KachelMargin_FolgtDerBerechnetenSpalte()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible))
Assert.Equal(card.Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8), card.Margin);
}
/// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden
/// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite.
[Fact]
public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
Assert.Equal(1, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin);
vm.TodayCard.IsVisible = false;
Assert.Equal(0, vm.TasksCard.Column);
Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin);
}
/// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur
/// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen
/// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art.
private static IEnumerable<AttentionItem> Items(DashboardViewModel vm, AttentionKind kind) =>
vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items);
/// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der
/// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter
/// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt).
private static DateOnly MissingTimeDate(AttentionItem item) =>
((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date;
private static PeriodScheduleService NewPeriodSchedule()
{
var tempPath = System.IO.Path.Combine(
@@ -59,7 +121,8 @@ public sealed class DashboardViewModelTests
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null)
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
{
lessons ??= new FakeLessons();
lessons.Add(lesson);
@@ -72,7 +135,196 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
annualPlanSync: null, schoolWeather: null, now: now);
}
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
private static DateOnly NextIsoWeekMonday()
{
var reference = DateTime.Today.AddDays(7);
return DateOnly.FromDateTime(
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
}
[Fact]
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
var entry = Assert.Single(vm.ExamWeekLoads);
Assert.Equal(3, entry.ExamCount);
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
}
[Fact]
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
Assert.Empty(vm.ExamWeekLoads);
}
[Fact]
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var monday = NextIsoWeekMonday();
var exams = new FakeExams([
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
Assert.Empty(vm.ExamWeekLoads);
}
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
Assert.True(vm.AttentionCard.EffectiveIsVisible);
}
[Fact]
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var timeEntries = new FakeTimeEntries();
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
}
[Fact]
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
}
[Fact]
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var pastDay = today.AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
}
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
}
[Fact]
@@ -199,10 +451,9 @@ public sealed class DashboardViewModelTests
exams: new FakeExams([exam]), results: results, memberships: memberships,
students: new FakeStudents([anna, ben]));
var correction = Assert.Single(vm.OpenCorrections);
Assert.Equal(1, correction.Completed);
Assert.Equal(2, correction.Total);
Assert.Equal(50, correction.Percent);
var correction = Assert.Single(Items(vm, AttentionKind.Correction));
Assert.Equal(50, correction.ProgressPercent);
Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
}
[Fact]
@@ -223,7 +474,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
memberships: memberships, students: new FakeStudents([student]));
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall");
}
[Fact]
@@ -382,9 +633,8 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons);
Assert.Equal(group.Id, item.GroupId);
Assert.Equal(1, item.PeriodNumber);
var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
Assert.Equal("Heute", item.DateDisplay);
}
@@ -399,7 +649,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
Assert.Empty(Items(vm, AttentionKind.Unplanned));
}
[Fact]
@@ -413,7 +663,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
Assert.Empty(Items(vm, AttentionKind.Unplanned));
}
[Fact]
@@ -429,7 +679,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons);
Assert.Empty(Items(vm, AttentionKind.Unplanned));
}
[Fact]
@@ -444,7 +694,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
Assert.Empty(Items(vm, AttentionKind.Unplanned));
}
[Fact]
@@ -459,9 +709,10 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
Assert.Equal(2, unplanned.Count);
Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
}
[Fact]
@@ -479,7 +730,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
Assert.Empty(vm.UnplannedLessons);
Assert.Empty(Items(vm, AttentionKind.Unplanned));
}
[Fact]
@@ -502,7 +753,7 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings);
Assert.Empty(Items(vm, AttentionKind.Attendance));
}
[Fact]
@@ -526,9 +777,9 @@ public sealed class DashboardViewModelTests
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings);
Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent);
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
Assert.Equal(student.FullName, item.Title);
Assert.Equal("30 %", item.TrailingText);
}
[Fact]
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.StudentError);
}
[Fact]
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
{
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Contains("noch nicht geladen", vm.StudentError);
}
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
@@ -0,0 +1,64 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ExamDialogViewModelTests
{
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
Exam? editingExam = null)
{
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
vm.DateText = dateText;
return vm;
}
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
[Fact]
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
{
var monday = new DateOnly(2026, 3, 16);
var existing = new List<Exam> { MakeExam(monday) };
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
Assert.Equal("", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
{
var monday = new DateOnly(2026, 3, 16);
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
{
var monday = new DateOnly(2026, 3, 16);
var editing = MakeExam(monday);
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
Assert.Equal("", vm.WeekLoadHint);
}
[Fact]
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
{
var vm = BuildViewModel([], "keindatum");
Assert.Equal("", vm.WeekLoadHint);
}
}
+74 -3
View File
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
@@ -31,6 +32,17 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static McpSettingsService BuildMcpSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new McpSettingsService(tempPath);
}
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
@@ -39,6 +51,14 @@ public static class TestSupport
return new WebUntisSettingsService(tempPath);
}
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
new(new HttpClient(), BuildWebUntisSettingsService());
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{
@@ -338,6 +358,7 @@ public class FakeLessons : ILessonRepository
{
private readonly List<Lesson> _all = [];
public void Add(Lesson l) => _all.Add(l);
public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id);
public List<Lesson> GetByUnit(Guid unitId) =>
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
@@ -352,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
{
public List<Subject> GetAll() => all;
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name);
public void Save(Subject subject) { }
public void Delete(Guid id) { }
public Subject? GetByName(string name) => all.FirstOrDefault(s =>
string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
public void Save(Subject subject)
{
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
var duplicate = GetByName(subject.Name);
if (duplicate is not null && duplicate.Id != subject.Id)
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
all.RemoveAll(s => s.Id == subject.Id);
all.Add(subject);
}
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
}
public class FakeCompetencyDomains : ICompetencyDomainRepository
@@ -364,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder).ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
_all.Where(d => d.SubjectId == subjectId)
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
@@ -525,6 +558,19 @@ public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
}
}
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
{
private readonly List<UntisHubJobState> _all = [];
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
public List<UntisHubJobState> GetAll() => _all.ToList();
public void Save(UntisHubJobState state)
{
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository
{
private readonly List<WorkTask> _all = [];
@@ -565,3 +611,28 @@ public class FakeReportGrades : IReportGradeRepository
}
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
}
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
public class FakeMcpConfirmation : IMcpConfirmationService
{
public bool Response { get; set; } = true;
public string? LastTitle { get; private set; }
public string? LastMessage { get; private set; }
public int CallCount { get; private set; }
public string? LastOperationKey { get; private set; }
public bool? LastAllowSessionTrust { get; private set; }
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
{
CallCount++;
LastTitle = title;
LastMessage = message;
LastOperationKey = operationKey;
LastAllowSessionTrust = allowSessionTrust;
return Task.FromResult(Response);
}
}
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
@@ -10,12 +10,42 @@ namespace LehrerApp.Desktop.Tests;
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests
{
[Fact]
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
{
var group = new LearningGroup { IsActive = true };
var today = DateOnly.FromDateTime(DateTime.Today);
var lessons = new FakeLessons();
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
lessons.Add(first);
lessons.Add(second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
vm.Initialize(group.Id, group.Name);
Assert.True(vm.HasTodayLessons);
Assert.Equal(2, vm.TodayLessons.Count);
Lesson? opened = null;
vm.OnOpenTeachingMode = lesson => opened = lesson;
vm.SelectedTeachingLesson = second;
vm.StartTeachingModeCommand.Execute(null);
Assert.Same(second, opened);
group.IsActive = false;
vm.Refresh();
Assert.False(vm.HasTodayLessons);
}
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
FakeResults? results = null, FakeGroups? groups = null) =>
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
sessions ?? new FakeSessions([]),
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
new GradingService(), new SchoolYearService());
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -76,6 +106,86 @@ public sealed class GroupOverviewViewModelTests
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
}
[Fact]
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
{
var groupId = Guid.NewGuid();
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var vm = NewVm(groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasYearComparison);
}
[Fact]
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
var exams = new FakeExams([previousExam, currentExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
}
[Fact]
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
{
var previousId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var groups = new FakeGroups([
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
]);
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
var exams = new FakeExams([previousExam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
var vm = NewVm(exams: exams, results: results, groups: groups);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasYearComparison);
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
}
[Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
{
@@ -0,0 +1,94 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
public class LessonFixItSearchTests
{
[Fact]
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
lessons.Add(orphan);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal(orphan.Id, found.Id);
}
[Fact]
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
lessons.Add(wrongPeriod);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal(wrongPeriod.Id, found.Id);
}
[Fact]
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
lessons.Add(near); lessons.Add(exact);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
Assert.Equal(2, candidates.Count);
Assert.Equal(exact.Id, candidates[0].Id);
Assert.Equal(near.Id, candidates[1].Id);
}
[Fact]
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
// Auswahldialog, ob sie passt.
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
var found = Assert.Single(candidates);
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
}
[Fact]
public void FindCandidates_LeerOhnePassendeStunde()
{
var lessons = new FakeLessons();
var groupId = Guid.NewGuid();
var date = new DateOnly(2026, 9, 8);
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
Assert.Empty(candidates);
}
}
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
}
[Fact]
public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
var repo = new FakeLessons();
repo.Add(moved); repo.Add(cancelledFollowing);
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
}
[Fact]
public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
{
var source = new Lesson
{
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
Status = LessonStatus.Cancelled,
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
};
var repo = new FakeLessons();
repo.Add(source);
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
new(2026, 9, 3), 1, null);
Assert.Equal(LessonStatus.Draft, continuation.Status);
}
[Fact]
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
{
@@ -0,0 +1,38 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class LessonStatusDisplayTests
{
[Theory]
[InlineData(LessonStatus.Draft, "Entwurf")]
[InlineData(LessonStatus.Ready, "Bereit")]
[InlineData(LessonStatus.Conducted, "Durchgeführt")]
[InlineData(LessonStatus.Cancelled, "Ausgefallen")]
[InlineData(LessonStatus.Planned, "Geplant")]
public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
{
Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
}
[Fact]
public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
{
var unit = new Unit { Title = "Optik" };
var lessons = new List<Lesson>
{
new() { Status = LessonStatus.Conducted },
new() { Status = LessonStatus.Cancelled },
new() { Status = LessonStatus.Ready },
};
var summary = new UnitSummary(unit, lessons);
Assert.Equal(2, summary.TotalCount);
Assert.Equal(1, summary.ConductedCount);
Assert.Equal(0.5, summary.ProgressFraction);
}
}
@@ -0,0 +1,132 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class LetterTemplateToolsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
var lines = new List<string> { "PAGE 210 297 mm" };
var y = 20;
foreach (var definition in definitions)
{
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
y += 20;
}
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
store.Import(source);
return store;
}
private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
{
FirstName = "Lena", LastName = "Beispiel",
Contacts = [new Contact
{
Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
}],
};
[Fact]
public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var tool = BuildTool(store);
var result = Assert.Single(tool.ListLetterTemplates());
Assert.Equal("Elternbrief", result.Name);
Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
}
[Fact]
public void RenderLetter_UnbekannteVorlage_LiefertFehler()
{
var student = StudentWithContact();
var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
Assert.False(result.Success);
Assert.Null(result.Base64Pdf);
}
[Fact]
public void RenderLetter_UnbekannterSchueler_LiefertFehler()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var tool = BuildTool(store);
var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
Assert.False(result.Success);
}
[Fact]
public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
{
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
Assert.False(result.Success);
Assert.Contains("Anrede", result.Message);
Assert.Null(result.Base64Pdf);
}
[Fact]
public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
{
var store = StoreWithTemplate("Elternbrief",
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact();
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
Assert.True(result.Success);
Assert.NotNull(result.Base64Pdf);
var bytes = Convert.FromBase64String(result.Base64Pdf!);
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
}
[Fact]
public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
{
var store = StoreWithTemplate("Elternbrief",
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var installed = Assert.Single(store.GetTemplates());
var student = StudentWithContact();
var tool = BuildTool(store, new FakeStudents([student]));
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
extraValues: new Dictionary<string, string> { ["Betreff"] = "Elternabend" });
Assert.True(result.Success);
Assert.NotNull(result.Base64Pdf);
}
}
@@ -0,0 +1,96 @@
using System.Text.Json.Nodes;
using LehrerApp.Desktop.Services.Mcp;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class McpClientRegistrationServiceTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
private string BuildFakeBridge()
{
var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
File.WriteAllText(bridgePath, "fake");
return bridgePath;
}
[Fact]
public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
{
var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
var result = service.Register();
Assert.False(result.Success);
Assert.False(File.Exists(ConfigPath));
}
[Fact]
public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
{
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.True(result.Success);
Assert.True(service.IsRegistered());
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
}
[Fact]
public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
{
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.True(result.Success);
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue<string>());
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
}
[Fact]
public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
{
File.WriteAllText(ConfigPath, "{ das ist kein json");
var bridgePath = BuildFakeBridge();
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
var result = service.Register();
Assert.False(result.Success);
Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
}
[Fact]
public void Unregister_EntferntNurEigenenEintrag()
{
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
var result = service.Unregister();
Assert.True(result.Success);
Assert.False(service.IsRegistered());
Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
}
[Fact]
public void IsRegistered_OhneDatei_IstFalse()
{
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
Assert.False(service.IsRegistered());
}
}
+901
View File
@@ -0,0 +1,901 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class McpToolsTests
{
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
[Fact]
public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools()
{
Assert.Equal(
new[]
{
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
"get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule",
"get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows",
"get_untis_hub_status", "list_letter_templates", "render_letter",
},
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
{
Assert.Equal(
new[]
{
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
"update_subject", "update_unit",
},
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
{
Assert.Equal(
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
}
[Fact]
public void AllowedTools_EnthaeltKeineDokumentationstypen()
{
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
Assert.DoesNotContain(allNames, n =>
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
}
// ── GroupTools ───────────────────────────────────────────────────────────────────────────
[Fact]
public void GetGroups_LiefertIdNameUndSchuljahrJeGruppe()
{
var group = new LearningGroup
{
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
};
var tool = new GroupTools(new FakeGroups([group]));
var result = Assert.Single(tool.GetGroups());
Assert.Equal(group.Id, result.Id);
Assert.Equal("9a", result.Name);
Assert.Equal("2025/26", result.SchoolYear);
Assert.Equal(9, result.GradeLevel);
Assert.True(result.IsActive);
}
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
[Fact]
public void GetStudents_OhneFilter_LiefertNurAktiveSchueler()
{
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
var tool = new StudentTools(new FakeStudents([active, inactive]));
var result = tool.GetStudents();
Assert.Single(result);
Assert.Equal(active.Id, result[0].Id);
}
[Fact]
public void GetStudents_IncludeInactive_LiefertAuchInaktive()
{
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
var tool = new StudentTools(new FakeStudents([active, inactive]));
var result = tool.GetStudents(includeInactive: true);
Assert.Equal(2, result.Count);
}
// ── ExamTools ────────────────────────────────────────────────────────────────────────────
[Fact]
public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse()
{
var groupId = Guid.NewGuid();
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 });
var tool = new ExamTools(new FakeExams([exam]), results);
var dto = Assert.Single(tool.GetExams(groupId));
Assert.Null(dto.Results);
}
[Fact]
public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler()
{
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" });
var tool = new ExamTools(new FakeExams([exam]), results);
var dto = Assert.Single(tool.GetExams(groupId, includeResults: true));
var result = Assert.Single(dto.Results!);
Assert.Equal(studentId, result.StudentId);
Assert.Equal("2+", result.Grade);
}
// ── GradeTools ───────────────────────────────────────────────────────────────────────────
[Fact]
public void GetGrades_MitStudentId_FiltertAufEinenSchueler()
{
var groupId = Guid.NewGuid();
var studentA = Guid.NewGuid();
var studentB = Guid.NewGuid();
var grades = new FakeGrades();
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
Assert.Equal(studentA, dto.StudentId);
}
[Fact]
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.True(result.Applied);
Assert.NotNull(result.Id);
Assert.Single(grades.GetByGroup(groupId));
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
Assert.Contains("Anna", confirmation.LastMessage);
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
}
[Fact]
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var grades = new FakeGrades();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
var groupId = Guid.NewGuid();
var result = await tool.CreateGradeEntry(
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
Assert.False(result.Applied);
Assert.Empty(grades.GetByGroup(groupId));
Assert.Equal(1, confirmation.CallCount);
}
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
[Fact]
public void GetSchedule_MitGroupId_FiltertNachGruppe()
{
var groupId = Guid.NewGuid();
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 });
var tool = new ScheduleTools(slots);
var dto = Assert.Single(tool.GetSchedule(groupId));
Assert.Equal(groupId, dto.GroupId);
}
// ── TimeEntryTools ───────────────────────────────────────────────────────────────────────
[Fact]
public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum()
{
var entries = new FakeTimeEntries();
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
Assert.Equal(30, dto.DurationMinutes);
}
[Fact]
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.True(result.Applied);
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
[Fact]
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
{
var entries = new FakeTimeEntries();
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
Assert.False(result.Applied);
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
}
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
private static LessonPlanTools BuildLessonPlanTools(
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
[Fact]
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
var tool = BuildLessonPlanTools(units, lessons);
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
Assert.Single(result.Units);
var lesson = Assert.Single(result.Lessons);
Assert.Equal("Brechung", lesson.Topic);
}
[Fact]
public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit()
{
var group = new LearningGroup { Name = "7a" };
var units = new FakeUnits();
var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group]));
var result = await tool.CreateUnit(group.Id, "Optik");
Assert.True(result.Applied);
Assert.Single(units.GetByGroup(group.Id));
}
[Fact]
public async Task UpdateUnit_OhneAenderung_FragtNichtNach()
{
var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned };
var units = new FakeUnits();
units.Add(unit);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(units: units, confirmation: confirmation);
var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned);
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit()
{
var groupId = Guid.NewGuid();
var unit = new Unit { GroupId = groupId, Title = "Optik" };
var units = new FakeUnits();
units.Add(unit);
var lessons = new FakeLessons();
var tool = BuildLessonPlanTools(units, lessons);
var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung");
Assert.True(result.Applied);
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
Assert.Equal(groupId, lesson.GroupId);
}
[Fact]
public async Task UpdateLesson_AendertNurAngegebeneFelder()
{
var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II");
Assert.True(result.Applied);
var updated = lessons.GetById(lesson.Id)!;
Assert.Equal("Brechung II", updated.Topic);
Assert.Equal("S. 12", updated.Homework);
}
[Fact]
public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
Assert.True(result.Applied);
var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases);
Assert.Equal("Einstieg", phase.Name);
Assert.Equal(result.Id, phase.Id);
}
[Fact]
public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
{
// Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
// tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
// nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
// vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
// konkreten IMcpConfirmationService-Implementierung.
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
}
[Fact]
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
{
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" };
var lesson = new Lesson { Topic = "Brechung" };
lesson.Phases.Add(phase);
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15);
Assert.True(result.Applied);
var updated = lessons.GetById(lesson.Id)!.Phases.Single();
Assert.Equal(15, updated.DurationMinutes);
Assert.Equal("Folie", updated.Material);
}
[Fact]
public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase()
{
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 };
var lesson = new Lesson { Topic = "Brechung" };
lesson.Phases.Add(phase);
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id);
Assert.True(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Phases);
}
[Fact]
public void DownloadLessonAttachment_LiefertBase64Inhalt()
{
var storage = new FakeAttachmentStorage();
var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray()));
var lesson = new Lesson { Topic = "Brechung" };
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 });
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
var result = tool.DownloadLessonAttachment(lesson.Id, storageId);
Assert.Equal("blatt.pdf", result.FileName);
Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content)));
}
[Fact]
public void DownloadLessonAttachment_ZuGross_WirftFehler()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Attachments.Add(new DocumentAttachment
{
StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1,
});
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
Assert.Throws<InvalidOperationException>(() => tool.DownloadLessonAttachment(lesson.Id, "big"));
}
[Fact]
public async Task AddLessonAttachment_NutzerBestaetigt_LaedtHochUndHaengtAn()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var storage = new FakeAttachmentStorage();
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
var content = Convert.ToBase64String("Arbeitsblatt-Inhalt"u8.ToArray());
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
Assert.True(result.Applied);
var attachment = Assert.Single(lessons.GetById(lesson.Id)!.Attachments);
Assert.Equal("arbeitsblatt.pdf", attachment.FileName);
using var stream = storage.OpenRead(attachment.StorageId)!;
using var reader = new StreamReader(stream);
Assert.Equal("Arbeitsblatt-Inhalt", reader.ReadToEnd());
}
[Fact]
public async Task AddLessonAttachment_NutzerLehntAb_SpeichertNichts()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var content = Convert.ToBase64String("Inhalt"u8.ToArray());
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
Assert.False(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Attachments);
}
[Fact]
public async Task AddLessonAttachment_UngueltigesBase64_LiefertFehlerOhneNachfrage()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", "nicht-base64!!!");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task AddLessonAttachment_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.AddLessonAttachment(Guid.NewGuid(), "x.pdf", Convert.ToBase64String("x"u8.ToArray()));
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum()
{
var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12));
Assert.True(result.Applied);
Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date);
}
[Fact]
public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit()
{
var unitId = Guid.NewGuid();
var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) };
var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) };
var lessons = new FakeLessons();
lessons.Add(moved); lessons.Add(later); lessons.Add(earlier);
var tool = BuildLessonPlanTools(lessons: lessons);
// moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht.
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true);
Assert.True(result.Applied);
Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date);
Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date);
}
[Fact]
public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung()
{
var groupId = Guid.NewGuid();
var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 };
var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 };
var lessons = new FakeLessons();
lessons.Add(moved); lessons.Add(occupying);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1);
Assert.False(result.Applied);
Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern.
Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert
}
[Fact]
public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12));
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.DeleteLesson(lesson.Id);
Assert.True(result.Applied);
Assert.Null(lessons.GetById(lesson.Id));
}
[Fact]
public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation { Response = false };
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.DeleteLesson(lesson.Id);
Assert.False(result.Applied);
Assert.NotNull(lessons.GetById(lesson.Id));
}
[Fact]
public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(confirmation: confirmation);
var result = await tool.DeleteLesson(Guid.NewGuid());
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var memberships = new FakeMemberships([]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
Assert.True(result.Applied);
var membership = Assert.Single(memberships.GetByStudent(student.Id));
Assert.Equal(Niveau.E, membership.Niveau);
Assert.Contains("Anna", confirmation.LastMessage);
}
[Fact]
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
{
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
var group = new LearningGroup { Name = "7a" };
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
var memberships = new FakeMemberships([existing]);
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
{
var group = new LearningGroup { Name = "7a" };
var confirmation = new FakeMcpConfirmation { Response = true };
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
[Fact]
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Competencies.Add("PH.9.1");
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Equal(0, confirmation.CallCount);
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
{
var lesson = new Lesson { Topic = "Brechung" };
lesson.Competencies.Add("PH.9.1");
var lessons = new FakeLessons();
lessons.Add(lesson);
var tool = BuildLessonPlanTools(lessons: lessons);
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
Assert.True(result.Applied);
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
}
[Fact]
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
{
var lesson = new Lesson { Topic = "Brechung" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var confirmation = new FakeMcpConfirmation();
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
[Fact]
public void GetSubjects_LiefertAlleFaecher()
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
var dto = Assert.Single(tool.GetSubjects());
Assert.Equal("Mathematik", dto.Name);
}
[Fact]
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
{
var subjects = new FakeSubjects([]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik", "Ma");
Assert.True(result.Applied);
var subject = Assert.Single(subjects.GetAll());
Assert.Equal("Mathematik", subject.Name);
}
[Fact]
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
{
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.CreateSubject("Mathematik");
Assert.False(result.Applied);
Assert.Single(subjects.GetAll());
}
[Fact]
public async Task UpdateSubject_AendertNurAngegebeneFelder()
{
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
Assert.True(result.Applied);
var updated = subjects.GetById(subject.Id)!;
Assert.Equal("Mathematik", updated.Name);
Assert.Equal("M", updated.ShortName);
}
[Fact]
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
{
var subject = new Subject { Name = "Mathematik" };
var subjects = new FakeSubjects([subject]);
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
var result = await tool.DeleteSubject(subject.Id);
Assert.True(result.Applied);
Assert.Empty(subjects.GetAll());
}
[Fact]
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
{
var confirmation = new FakeMcpConfirmation();
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
var result = await tool.DeleteSubject(Guid.NewGuid());
Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount);
}
[Fact]
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
{
var subjectId = Guid.NewGuid();
var domains = new FakeCompetencyDomains();
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = tool.GetCompetencyCatalog(subjectId);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
{
var subject = new Subject { Name = "Mathematik" };
var domains = new FakeCompetencyDomains();
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
Assert.True(result.Applied);
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
Assert.Equal("Funktionen", domain.Name);
}
[Fact]
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
{
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.DeleteCompetencyDomain(domain.Id);
Assert.True(result.Applied);
Assert.Null(domains.GetById(domain.Id));
}
[Fact]
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
{
var domain = new CompetencyDomain { Name = "Funktionen" };
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
Assert.True(result.Applied);
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
Assert.Equal("M.9.1", item.Code);
}
[Fact]
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
{
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
Assert.True(result.Applied);
var updated = domains.GetById(domain.Id)!.Items.Single();
Assert.Equal("M.9.1", updated.Code);
Assert.Equal("neu", updated.Description);
}
[Fact]
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
{
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
var domain = new CompetencyDomain { Name = "Funktionen" };
domain.Items.Add(item);
var domains = new FakeCompetencyDomains();
domains.Add(domain);
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
Assert.True(result.Applied);
Assert.Empty(domains.GetById(domain.Id)!.Items);
}
}
@@ -0,0 +1,76 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact]
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Equal(2, vm.Results.Count);
Assert.All(vm.Results, r => Assert.True(r.Success));
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
}
[Fact]
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = Build(group, [anna, ben], StoreWithTemplate());
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Single(vm.Results);
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
}
[Fact]
public void OhneVorlage_GenerateTutNichts()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
var output = Path.Combine(_directory, "output");
vm.Generate(output);
Assert.Empty(vm.Results);
}
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
private WorksheetTemplateStore StoreWithTemplate()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
var manifest = new TemplateManifest
{
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
};
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
store.Store.Import(source);
return store;
}
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
}
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
Assert.Contains("2 / 3", summary.ProgressText);
}
[Fact]
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
{
var (vm, units, _, groupId) = BuildScenario();
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
StartDate = new DateOnly(2025, 8, 1) });
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
StartDate = new DateOnly(2025, 9, 1) };
units.Add(active);
vm.Initialize(groupId);
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
}
[Fact]
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
{
@@ -93,6 +108,30 @@ public class PlanningTabViewModelTests
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
}
[Fact]
public async Task ChangeLessonUnit_OrdnetStundeDerZieleinheitZuUndWaehltSieAus()
{
var (vm, units, lessons, groupId) = BuildScenario();
var unitA = new Unit { GroupId = groupId, Title = "Mechanik" };
var unitB = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unitA); units.Add(unitB);
var l1 = new Lesson { UnitId = unitA.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" };
lessons.Add(l1);
vm.Initialize(groupId);
vm.SelectedUnit = vm.Units.Single(u => u.Id == unitA.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id);
vm.OnPickUnitChange = _ => Task.FromResult<Unit?>(unitB);
await vm.ChangeLessonUnitCommand.ExecuteAsync(null);
Assert.Empty(lessons.GetByUnit(unitA.Id));
var moved = Assert.Single(lessons.GetByUnit(unitB.Id));
Assert.Equal(l1.Id, moved.Id);
Assert.Equal(unitB.Id, vm.SelectedUnit?.Id);
Assert.Equal(l1.Id, vm.SelectedLesson?.Id);
}
[Fact]
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
{
@@ -294,4 +333,44 @@ public class PlanningTabViewModelTests
Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
}
[Fact]
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
{
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
var groupId = Guid.NewGuid();
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
var groups = new FakeGroups([group]);
var units = new FakeUnits();
var lessons = new FakeLessons();
var sessions = new FakeSessions([]);
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
vm.Initialize(groupId);
var unit = new Unit { GroupId = groupId, Title = "Optik" };
units.Add(unit);
var date = new DateOnly(2025, 9, 1);
var doubleLesson = new Lesson
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
var thirdLesson = new Lesson
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
lessons.Add(doubleLesson);
lessons.Add(thirdLesson);
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
var created = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(doubleLesson.Id, created.LessonId);
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
}
}
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
}
[Fact]
public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben
vm.ClearRating();
Assert.Null(vm.AspectRows[0].Value);
Assert.Null(rows[0].GetRating("quality"));
}
[Fact]
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
vm.SetDayHighlight(DayHighlightKind.Standout);
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
}
[Fact]
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
vm.NextStudent(); // zu Ben, keine Flagge
vm.PreviousStudent(); // zurück zu Anna
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
}
[Fact]
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
{
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
Assert.Single(sessions.GetByGroup(groupId));
}
[Fact]
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
{
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
var groupId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.Today);
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
var existingSession = new ParticipationSession
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
var sessions = new FakeSessions([existingSession]);
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.SelectOrCreateSessionForLesson(thirdLesson);
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
Assert.Single(sessions.GetByGroup(groupId));
}
[Fact]
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
{
@@ -472,6 +496,7 @@ public sealed class SeatingPlanViewModelTests
vm.SetRatingByNumber(5);
vm.ApplyAttendanceShortcut(1, clear: false);
vm.ApplyHomeworkShortcut(7, clear: false);
vm.ApplyDayHighlightShortcut(1, clear: false);
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
@@ -481,6 +506,56 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
}
[Fact]
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
{
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
groupId, studentId, "Beispiel, Anna", canEdit: true);
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
vm.ApplyDayHighlightShortcut(null, clear: true);
var session = Assert.Single(sessions.GetByGroup(groupId));
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
Assert.Null(entry.DayHighlight);
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
}
[Fact]
public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor()
{
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.Today);
var session = new ParticipationSession { GroupId = groupId, Date = today };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 });
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
groupId, studentId, "Beispiel, Anna", canEdit: true);
var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity");
Assert.True(quantityRow.HasSuggestion);
Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "" laut ParticipationCountSuggestion
Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel);
var qualityRow = vm.AspectRows.Single(r => r.Key == "quality");
Assert.False(qualityRow.HasSuggestion);
quantityRow.ApplySuggestionCommand.Execute(null);
Assert.Equal(0, quantityRow.Value);
var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!;
Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value);
}
[Fact]
@@ -517,6 +592,60 @@ public sealed class SeatingPlanViewModelTests
Assert.Null(vm.SelectedSession);
}
[Fact]
public void TallyRaisedHand_ErhoehtNurGemeldetZaehler()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
sessions, entries, new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
var seat = vm.Seats.Single();
seat.TallyRaisedHandCommand.Execute(null);
seat.TallyRaisedHandCommand.Execute(null);
Assert.Equal(2, seat.RaisedHandCount);
Assert.Equal(0, seat.CalledOnCount);
var session = Assert.Single(sessions.GetByGroup(groupId));
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(2, entry.RaisedHandCount);
Assert.Equal(0, entry.CalledOnCount);
}
[Fact]
public void TallyCalledOn_ErhoehtBeideZaehler()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
sessions, entries, new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
var seat = vm.Seats.Single();
seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen
seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen
Assert.Equal(2, seat.RaisedHandCount);
Assert.Equal(1, seat.CalledOnCount);
}
[Fact]
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
{
@@ -0,0 +1,81 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingQuickCheckTests
{
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
{
var group = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
var entries = new FakeEntries();
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
new FakeSessions([session]), entries, new FakeAspects());
vm.Initialize(group, readOnly);
return (vm, entries, session, student);
}
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
vm.CheckAttendanceCommand.Execute(null);
var seat = vm.Seats[0];
Assert.True(seat.ShowQuickCheck);
Assert.False(vm.Seats[1].ShowQuickCheck);
seat.QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.Equal(3, entry.RaisedHandCount);
Assert.Equal(2, entry.CalledOnCount);
Assert.Equal(1, seat.DisplayOpacity);
seat.QuickPositiveCommand.Execute(null);
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
}
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
{
var (vm, entries, session, student) = Build();
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing);
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
vm.Seats[0].QuickPositiveCommand.Execute(null);
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
Assert.False(entry.HomeworkMissing);
vm.EndQuickCheckCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.True(vm.Seats[0].ShowNormalActions);
}
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
{
var (vm, _, _, _) = Build();
var calls = 0;
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
vm.CheckAttendanceCommand.Execute(null);
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
Assert.Equal(1, calls);
}
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
{
var (vm, entries, session, _) = Build(readOnly: true);
vm.CheckHomeworkCommand.Execute(null);
vm.Seats[0].QuickNegativeCommand.Execute(null);
vm.Seats[1].QuickPositiveCommand.Execute(null);
Assert.False(vm.Seats[0].ShowQuickCheck);
Assert.Empty(entries.GetBySession(session.Id));
}
}
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Models;
using System.Linq;
using Xunit;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Tests;
@@ -34,7 +35,9 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
@@ -218,6 +221,25 @@ public sealed class SettingsViewModelTests
Assert.Empty(queue.GetUnreviewed());
}
[Fact]
public void ClearSyncConflicts_LeertProtokollUndQueue()
{
var queue = TestSupport.BuildEventQueue();
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
queue.AddConflict(new ConflictEntry
{
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
Resolution = resolution,
});
var vm = BuildViewModel(eventQueue: queue);
vm.ClearSyncConflictsCommand.Execute(null);
Assert.Empty(vm.SyncConflicts);
Assert.Empty(queue.GetUnreviewed());
}
[Fact]
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{
@@ -320,7 +342,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -348,7 +372,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -380,7 +406,9 @@ public sealed class SettingsViewModelTests
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildMcpSettingsService(),
TestSupport.BuildMcpClientRegistrationService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
@@ -0,0 +1,146 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
public sealed class StudentPerformanceOverviewViewModelTests
{
private static readonly Guid GroupId = Guid.NewGuid();
private static readonly Guid StudentId = Guid.NewGuid();
private static readonly Guid OtherStudentId = Guid.NewGuid();
private static StudentPerformanceOverviewViewModel BuildViewModel(
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
{
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
var schemesRepo = schemes ?? new FakeSchemes();
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
return new StudentPerformanceOverviewViewModel(
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
new FakeMemberships([]), schemesRepo, new GradingService(),
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
}
[Fact]
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
{
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
var exams = new FakeExams([exam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
var vm = BuildViewModel(exams, results);
var row = Assert.Single(vm.ExamRows);
Assert.Equal("2", row.OwnGradeDisplay);
Assert.Equal("3.0", row.ClassAverageDisplay);
}
[Fact]
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
{
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
var exams = new FakeExams([exam1, exam2]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
var vm = BuildViewModel(exams, results);
Assert.Equal(2, vm.ExamHistory.Points.Count);
Assert.False(vm.ExamHistory.Points[0].IsWarning);
Assert.True(vm.ExamHistory.Points[1].IsWarning);
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
}
[Fact]
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
{
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
var exams = new FakeExams([exam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
var grades = new FakeGrades();
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
Date = new DateOnly(2025, 9, 5), Value = "3" });
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
Date = new DateOnly(2025, 9, 5), Value = "1" });
var vm = BuildViewModel(exams, results, grades);
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
}
[Fact]
public void Zielnote_BerechnetNoetigenKlausurschnitt()
{
var grades = new FakeGrades();
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
Date = new DateOnly(2025, 9, 5), Value = "3" });
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
Date = new DateOnly(2025, 9, 5), Value = "2" });
var vm = BuildViewModel(grades: grades);
vm.TargetGradeText = "2";
vm.TargetBucketName = "Klausuren";
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
Assert.Contains("1.2", vm.TargetResultDisplay);
}
[Fact]
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
{
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
var exams = new FakeExams([exam]);
var results = new FakeResults();
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
var grades = new FakeGrades();
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
Date = new DateOnly(2025, 9, 5), Value = "2" });
var vm = BuildViewModel(exams, results, grades);
vm.WhatIfExamGradeText = "6";
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
Assert.Contains("3", vm.WhatIfResultDisplay);
}
[Fact]
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
{
var grades = new FakeGrades();
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
Date = new DateOnly(2025, 9, 5), Value = "3" };
grades.Add(grade);
var vm = BuildViewModel(grades: grades);
var row = Assert.Single(vm.ParticipationRows);
row.Value = "1";
row.SaveCommand.Execute(null);
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
}
[Fact]
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
{
var vm = BuildViewModel();
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
}
}
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class StudentPickerDialogViewModelTests
{
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
{
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
return (vm, anna, ben);
}
[Fact]
public void SearchText_FiltertNachTeilstring()
{
var (vm, anna, _) = Build();
vm.SearchText = "ann";
Assert.Single(vm.Students);
Assert.Equal(anna.FullName, vm.Students[0].FullName);
}
[Fact]
public void Select_OhneAuswahl_SetztValidationMessage()
{
var (vm, _, _) = Build();
vm.SelectCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.ValidationMessage);
}
[Fact]
public void Select_MitAuswahl_LiefertVollenSchueler()
{
var (vm, anna, _) = Build();
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
vm.SelectCommand.Execute(null);
Assert.Equal(anna.Id, vm.Result?.Id);
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingTimelineViewModelTests
{
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
private DateTime _now;
private readonly FakeLessons _lessons = new();
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
{
_now = _start.AddMinutes(5);
var local = _start.ToLocalTime();
var lesson = new Lesson
{
Date = DateOnly.FromDateTime(local),
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
new() { Name = "Arbeit", DurationMinutes = 10 },
new() { Name = "Sicherung", DurationMinutes = 10 }]
};
_lessons.Add(lesson);
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
}
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
{
var (_, vm) = Build();
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(50, vm.Phases[0].Progress);
_now = _start.AddMinutes(10);
vm.Refresh();
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Single(vm.Phases, p => p.IsActive);
}
[Fact] public void NoStartTime_RequiresExplicitStart()
{
var (_, vm) = Build(scheduled: false);
Assert.True(vm.NeedsStart);
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
vm.StartNowCommand.Execute(null);
Assert.False(vm.NeedsStart);
Assert.True(vm.Phases[0].IsActive);
Assert.Equal(_now, vm.Phases[0].StartUtc);
}
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
Assert.True(vm.Phases[2].IsOverflow);
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
}
[Fact] public void FinishEarly_StartsNextPhaseNow()
{
var (_, vm) = Build();
vm.Phases[0].FinishCommand.Execute(null);
Assert.True(vm.Phases[0].IsCompleted);
Assert.True(vm.Phases[1].IsActive);
Assert.Equal(_now, vm.Phases[1].StartUtc);
}
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
{
var (lesson, vm) = Build();
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(65);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsHeld);
Assert.True(reopened.Phases[0].IsActive);
Assert.Single(reopened.Phases, p => p.IsActive);
reopened.Phases[0].FinishCommand.Execute(null);
Assert.True(reopened.Phases[1].IsActive);
Assert.False(reopened.Phases[0].IsHeld);
Assert.Equal(_now, reopened.Phases[1].StartUtc);
}
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
{
var (_, vm) = Build();
var chosen = vm.Phases[2];
chosen.BringForwardCommand.Execute(null);
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
Assert.True(chosen.IsActive);
Assert.Equal(_now, chosen.StartUtc);
Assert.False(vm.Phases[2].IsCompleted);
}
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendTenCommand.Execute(null);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.True(remainder.IsOverflow);
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now, remainder.StartUtc);
}
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
{
var (lesson, _) = Build();
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
_lessons.Add(target);
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
vm.Phases[0].ExtendTenCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
vm.TransferRemainderCommand.Execute(null);
var copy = Assert.Single(target.Phases);
Assert.Equal("Sicherung", copy.Name);
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
Assert.Equal(3, lesson.Phases.Count);
Assert.True(vm.Phases[2].IsTransferred);
}
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
{
var (lesson, vm) = Build();
vm.Phases[0].ExtendFiveCommand.Execute(null);
_now = _start.AddMinutes(28);
vm.Refresh();
Assert.True(vm.Phases[2].IsActive);
_now = _start.AddDays(1);
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
var remainder = reopened.Phases[2];
Assert.False(remainder.IsCompleted);
Assert.False(remainder.IsActive);
Assert.True(reopened.HasRemainder);
Assert.Equal(5, remainder.RemainingMinutes);
remainder.BringForwardCommand.Execute(null);
Assert.True(remainder.IsActive);
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
}
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
{
var (lesson, _) = Build(scheduled: false);
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
vm.StartNowCommand.Execute(null);
Assert.Null(lesson.TeachingTimeline);
Assert.True(vm.NeedsStart);
}
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
{
var (lesson, _) = Build();
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
Assert.Equal(3, vm.Phases.Count);
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
}
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
{
using var stream = new MemoryStream();
using var db = new LehrerApp.Data.LiteDbContext(stream);
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
var (lesson, _) = Build();
repository.Save(lesson);
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
vm.Phases[0].HoldCommand.Execute(null);
_now = _start.AddMinutes(40);
var loaded = repository.GetById(lesson.Id)!;
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
Assert.True(reopened.Phases[0].IsActive);
Assert.True(reopened.Phases[0].IsHeld);
Assert.Equal(_start, reopened.Phases[0].StartUtc);
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
}
}
@@ -712,6 +712,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("1", period2.BadgeText);
}
[Fact]
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
{
var date = DateInCurrentWeek(DayOfWeek.Monday);
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson
{
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
Phases = [],
};
var lessons = new FakeLessons();
lessons.Add(lesson);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
Assert.Equal("Werkstoffprüfung", first.Topic);
Assert.Equal("Werkstoffprüfung", second.Topic);
Assert.Equal(lesson.Id, second.Lesson?.Id);
}
[Fact]
public void Load_KeineAnstehendenFerien_KeinBadge()
{
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
}
[Fact]
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
{
var groupId = Guid.NewGuid();
var units = new FakeUnits();
var previous = new LehrerApp.Core.Models.Unit
{
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
};
units.Add(previous);
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
{
Title = "Prozentrechnung",
StatusName = "Laufend",
};
Assert.Contains("Bruchrechnung", vm.StatusNotice);
vm.SaveCommand.Execute(null);
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
}
}
@@ -0,0 +1,72 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp.Tools;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
public sealed class UntisComparisonToolsTests
{
private static UntisComparisonTools Build(
List<LearningGroup>? groups = null, List<Student>? students = null,
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
[Fact]
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
{
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
var tool = Build([group]);
var rows = tool.GetUntisHubStatus();
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
&& r.DueState == nameof(UntisHubDueState.Overdue));
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
}
[Fact]
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
{
var tool = Build();
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
Assert.False(result.Applied);
Assert.Contains("Status", result.Message);
}
[Fact]
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
{
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
var tool = Build();
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
Assert.False(result.Applied);
}
[Fact]
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
{
var confirmation = new FakeMcpConfirmation();
var tool = Build(confirmation: confirmation);
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
Assert.False(result.Applied);
Assert.Contains("row-id", result.Message);
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
}
}
@@ -0,0 +1,142 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisHubServiceTests
{
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
[Fact]
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
{
var group = Group("9a", 42);
var rows = UntisHubService.BuildRows([group], [], UtcNow);
Assert.Equal(5, rows.Count);
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
}
[Fact]
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
{
var rows = UntisHubService.BuildRows([], [], UtcNow);
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
}
[Fact]
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Ok, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Due, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
{
var group = Group("9a", 42);
var state = new UntisHubJobState
{
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
};
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
}
[Fact]
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
{
var group = Group("9a", 42);
var lastRunAt = UtcNow.AddDays(-40);
var states = new List<UntisHubJobState>
{
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
};
var rows = UntisHubService.BuildRows([group], states, UtcNow);
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
}
[Fact]
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
{
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
var eligible = new List<LearningGroup> { Group("9a", 42) };
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
Assert.Equal(2, rows.Count(r => r.GroupId != null));
}
[Fact]
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
{
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
// als fällig stehen.
var group = Group("9a", 42);
var jobStates = new FakeUntisHubJobStates();
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
Assert.NotNull(kurzState?.LastRunAt);
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
}
[Fact]
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
{
var group = Group("9a", 42);
var jobStates = new FakeUntisHubJobStates();
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
}
}
@@ -18,4 +18,18 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
}
}
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
@@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests
Assert.False(called);
}
[Fact]
public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt()
{
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var vm = new TimeTrackingViewModel(
new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule);
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.Equal(new TimeOnly(7, 45), gap.WindowStart);
Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd);
}
[Fact]
public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe()
{
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = TestSupport.BuildPeriodScheduleService();
periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
var entries = new FakeTimeEntries();
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
vm.OnAddMissingTeachingTime = item => Task.FromResult<TimeEntry?>(new TimeEntry
{
Date = item.Date,
Category = TaskCategoryDisplay.Label(TaskCategory.Teaching),
StartTime = item.WindowStart,
EndTime = item.WindowEnd,
DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes,
});
await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap);
Assert.Single(entries.GetByDate(pastDay));
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
}
public sealed class AddTimeEntryDialogViewModelTests
@@ -0,0 +1,40 @@
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class _TempFlowCheck2
{
[Fact]
public void MarkerTest()
{
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
Directory.CreateDirectory(outDir);
var commands = new List<DrawingCommand>
{
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
};
var drawing = new DrawingValue(commands, 200);
var manifest = new TemplateManifest
{
Id = "marker", Name = "Marker",
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
};
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
var loadedLayout = new LayoutParser().Parse(layout);
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
for (var i = 0; i < pngs.Count; i++)
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
}
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
}
+2
View File
@@ -16,6 +16,8 @@
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
<!-- Klassenbuch mit Ausrufezeichen für noch offene Einträge. -->
<StreamGeometry x:Key="IconOpenPeriods">M5,2 H19 A2,2 0 0 1 21,4 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V4 A2,2 0 0 1 5,2 Z M7,4 V20 H19 V4 Z M11,7 H14 V13 H11 Z M11,15 H14 V18 H11 Z</StreamGeometry>
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
</ResourceDictionary>
+111 -31
View File
@@ -1,8 +1,11 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
@@ -23,6 +26,8 @@ public class App : Application
public static IServiceProvider Services { get; private set; } = null!;
private static ServiceProvider? _serviceProvider;
private static bool _exitHandlerAttached;
private static Task _initialPolls = Task.CompletedTask;
private static readonly CancellationTokenSource StartupCancellation = new();
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -37,41 +42,64 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
// (und damit BuildServices, das sie öffnet) angefasst wird.
if (AppBootstrapper.IsDbEncrypted())
{
var promptVm = new DbPasswordPromptViewModel(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
promptVm.OnUnlocked = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop, showImmediately: true);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
}
else
{
StartMainApp(desktop, showImmediately: false);
}
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
var splash = new SplashWindow();
desktop.MainWindow = splash;
Dispatcher.UIThread.Post(
() => ContinueStartup(desktop, splash),
DispatcherPriority.Background);
}
base.OnFrameworkInitializationCompleted();
}
private static void StartMainApp(
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
private static async void ContinueStartup(
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
{
_serviceProvider = AppBootstrapper.BuildServices();
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
// (und damit BuildServices, das sie öffnet) angefasst wird.
if (AppBootstrapper.IsDbEncrypted())
{
var promptVm = new DbPasswordPromptViewModel(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
promptVm.OnUnlocked = async password =>
{
AppBootstrapper.DbPassword = password;
var unlockedSplash = new SplashWindow();
desktop.MainWindow = unlockedSplash;
unlockedSplash.Show();
promptWindow.Close();
await StartMainAppAsync(desktop, unlockedSplash);
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
splash.Close();
return;
}
await StartMainAppAsync(desktop, splash);
}
private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{
var splash = windowToClose as SplashWindow;
var timer = System.Diagnostics.Stopwatch.StartNew();
var progress = new Progress<(int Value, string Text)>(step =>
splash?.SetProgress(step.Value, step.Text));
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
Services = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
// soll, kein dauerhaftes Archiv.
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
splash?.SetProgress(60, "Papierkorb aufräumen …");
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
if (!_exitHandlerAttached)
{
@@ -79,23 +107,49 @@ public class App : Application
_exitHandlerAttached = true;
}
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
splash?.SetProgress(75, "Übersicht vorbereiten …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
// übernimmt sein stündlicher Timer.
if (Services.GetService<UntisSyncService>() is { } untisSync)
_ = untisSync.PollAsync();
splash?.SetProgress(90, "Hauptfenster öffnen …");
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
if (Services.GetService<SyncEngine>() is { } syncEngine)
main.EnableFinalSync(syncEngine);
desktop.MainWindow = main;
if (showImmediately) main.Show();
main.Show();
splash?.SetProgress(100, "Bereit");
windowToClose?.Close();
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
var untis = Services.GetService<UntisSyncService>();
var annualPlan = Services.GetService<AnnualPlanSyncService>();
if (untis is not null)
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
{
Services.GetRequiredService<TimetableViewModel>().Load();
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
});
_initialPolls = Task.Run(async () =>
{
try
{
await Task.WhenAll(
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
}
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
});
}
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
@@ -116,6 +170,8 @@ public class App : Application
try
{
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
@@ -153,6 +209,7 @@ public class App : Application
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
@@ -208,6 +265,29 @@ public class App : Application
Services.GetRequiredService<WorkTaskListViewModel>().Load();
}
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
var vm = new AddTimeEntryDialogViewModel(tasks)
{
DateText = item.Date.ToString("dd.MM.yyyy"),
SelectedCategory = "Unterricht",
StartTimeText = item.WindowStart.ToString("HH:mm"),
EndTimeText = item.WindowEnd.ToString("HH:mm"),
};
var dialog = new AddTimeEntryDialog { DataContext = vm };
await dialog.ShowDialog<bool>(owner);
if (vm.Result is null) return;
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<TimeTrackingViewModel>().Load();
}
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
+42 -3
View File
@@ -4,6 +4,8 @@ using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Services.Mcp;
using LehrerApp.Desktop.Services.Mcp.Tools;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
@@ -16,6 +18,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop;
@@ -98,7 +101,7 @@ public static class AppBootstrapper
Environment.Exit(0);
}
public static ServiceProvider BuildServices()
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
{
var services = new ServiceCollection();
@@ -117,13 +120,19 @@ public static class AppBootstrapper
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
// ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backupSettings = new BackupSettingsService(appData);
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
reportProgress?.Invoke(10, "Sicherung erstellen …");
var backupPath = backup.CreateBackup(DbPath);
reportProgress?.Invoke(25, "Sicherung prüfen …");
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
@@ -158,6 +167,7 @@ public static class AppBootstrapper
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IVorgangRepository, VorgangRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
@@ -180,6 +190,7 @@ public static class AppBootstrapper
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -191,16 +202,40 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
services.AddSingleton(_ => new AiSettingsService(appData));
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
services.AddSingleton(_ => new HttpClient
{
BaseAddress = new Uri(AiBackendUrl),
// Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client
// bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt.
Timeout = TimeSpan.FromSeconds(210),
});
services.AddSingleton<AiPlanningService>();
// ── MCP-Server (lokal, Phase 1 siehe Planungsdokument, optional per Opt-in) ─────────
services.AddSingleton(_ => new McpSettingsService(appData));
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
services.AddSingleton<StudentTools>();
services.AddSingleton<ExamTools>();
services.AddSingleton<GradeTools>();
services.AddSingleton<ScheduleTools>();
services.AddSingleton<TimeEntryTools>();
services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<StudentAttendanceCalendarService>();
services.AddSingleton<CompetencyTools>();
services.AddSingleton<GroupTools>();
services.AddSingleton<UntisComparisonTools>();
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
@@ -237,6 +272,7 @@ public static class AppBootstrapper
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
services.AddSingleton<UntisHubService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -312,6 +348,7 @@ public static class AppBootstrapper
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherCasesViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
@@ -329,7 +366,9 @@ public static class AppBootstrapper
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
var provider = services.BuildServiceProvider();
provider.GetRequiredService<LiteDbContext>();
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace LehrerApp.Desktop.Converters;
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
/// Zurückschreiben eine `InvalidCastException`.</summary>
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
{
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is DateTimeOffset offset ? offset.DateTime : null;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
}
@@ -10,6 +10,7 @@
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
@@ -20,6 +21,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="ModelContextProtocol.Core" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
+5
View File
@@ -1,4 +1,5 @@
using Avalonia;
using LehrerApp.Core.Mcp;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop;
@@ -8,6 +9,10 @@ class Program
[STAThread]
public static void Main(string[] args)
{
// Muss vor der ersten NamedPipeServerStream-Instanz laufen (siehe
// McpServerHostedService), sonst löst TMPDIR-Auseinanderdriften zwischen diesem Prozess
// und LehrerApp.McpBridge auf macOS/Linux "MCP nicht erreichbar" aus - siehe Doku dort.
McpPipeConstants.EnsureStableUnixSocketDirectory();
var logger = AppBootstrapper.EnsureLogger();
GlobalExceptionHandler.Install(logger);
+138 -6
View File
@@ -10,7 +10,14 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.Services;
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
public class AiBackendException(string userMessage) : Exception(userMessage);
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
{
/// <summary>
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
/// </summary>
public string? RawResponse { get; } = rawResponse;
}
/// <summary>
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
{
string? backendReason = null;
string? rawResponse = null;
try
{
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
var responseText = await resp.Content.ReadAsStringAsync();
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
}
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
return new AiBackendException(backendReason is null
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
}
public async Task<string> LoginAsync(string username, string password)
@@ -178,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = l.StartTime,
Homework = l.Homework,
Reflection = l.Reflection,
PlanningIdeas = l.PlanningIdeas,
Phases = ToAiPhases(l.Phases, pathNames),
})
.ToList();
@@ -260,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert");
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
diffs.Add("Planungsideen geändert");
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases))
@@ -340,6 +354,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (TaskCanceledException)
{
throw new AiBackendException(
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
}
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
@@ -352,14 +372,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp);
var responseText = await resp.Content.ReadAsStringAsync();
try
{
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
}
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
string? rawModelResponse = null;
try
{
using var envelope = JsonDocument.Parse(responseText);
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
&& rawElement.ValueKind == JsonValueKind.String)
rawModelResponse = rawElement.GetString();
}
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
throw new AiBackendException(
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
}
}
/// <summary>
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
/// der Rohantwort markiert werden kann.
/// </summary>
public static List<AiLesson> ParsePlanningLessons(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
List<AiLesson>? parsed = root.ValueKind switch
{
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
JsonValueKind.Object =>
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
?? throw new JsonException("Leere Stunde")],
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
_ => null,
};
if (parsed is null || parsed.Count == 0)
throw new JsonException("Keine Stunde enthalten");
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
return parsed;
}
catch (JsonException ex)
{
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
}
}
@@ -520,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime,
Homework = ai.Homework,
Reflection = ai.Reflection,
PlanningIdeas = ai.PlanningIdeas,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
@@ -531,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
? null : BuildMaterialPrompt(unit, ai, p),
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
? pathId : null,
}).ToList(),
@@ -539,7 +614,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
return result;
}
/// <summary>
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
/// regelbasierten Status bei.
/// </summary>
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
IReadOnlyList<AiUntisStatusRow> rows, string token)
{
if (rows.Count == 0) return new Dictionary<string, string>();
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
{
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
};
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (resp.StatusCode == (HttpStatusCode)402)
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp);
AiUntisStatusResponse? result;
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
if (result is null)
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
var sentIds = rows.Select(r => r.Id).ToHashSet();
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|| !sentIds.SetEquals(receivedIds))
return new Dictionary<string, string>();
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
}
private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult { public string? Error { get; set; } }
private class BackendErrorResult
{
public string? Error { get; set; }
public string? RawResponse { get; set; }
}
}
@@ -36,9 +36,9 @@ public sealed class AnnualPlanSyncService : IDisposable
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0)) return;
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
{
var url = _settings.GetIcalUrl();
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -59,7 +59,7 @@ public sealed class AnnualPlanSyncService : IDisposable
AnnualPlanPollResult result;
try
{
result = ProcessIcsText(icsText);
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -0,0 +1,49 @@
using Avalonia.Controls;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views.Students;
using LehrerApp.Templating;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
/// erst per Picker gewählt) kommt.</summary>
public static class LetterDialogs
{
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
{
var vm = new CreateLetterDialogViewModel(student,
App.Services.GetRequiredService<TemplateStore>(),
App.Services.GetRequiredService<ITemplateRenderer>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.Build(student, options, contentWidth, millimeterScale, contentHeight),
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
RefreshAttendanceDataAsync);
var dialog = new CreateLetterDialog { DataContext = vm };
var path = await dialog.ShowDialog<string?>(owner);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
{
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return;
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
await cache.GetAbsencesAsync(className, start, end, token: token);
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
}
}
@@ -0,0 +1,84 @@
using LehrerApp.Core.Models;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
/// </summary>
public static class LetterPlaceholderBuilder
{
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
Student student, Contact? contact, LearningGroup? group, DateOnly date,
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
DrawingValue? absenceDays = null)
{
var address = FormatAddress(contact);
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
{
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
["Student.Name"] = new TextValue(student.FullName),
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
};
}
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
public static string FormatAddress(Contact? contact)
{
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
}
public static bool IsEmpty(PlaceholderValue value) => value switch
{
TextValue x => string.IsNullOrWhiteSpace(x.Value),
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
_ => false,
};
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
{
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
.Select(e => e switch
{
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
_ => null,
})
.FirstOrDefault(b => b is not null);
if (box is null) return null;
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
}
}
@@ -0,0 +1,98 @@
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Views.Mcp;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
/// <see cref="Dispatcher.UIThread"/>.
///
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
/// läuft `Topmost`.
///
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
///
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
/// Änderung als abgelehnt gewertet.
/// </summary>
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
{
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
private readonly object _trustLock = new();
private readonly HashSet<string> _trustedOperations = [];
private bool _trustAll;
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
{
if (allowSessionTrust)
{
bool alreadyTrusted;
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
if (alreadyTrusted)
{
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
return true;
}
}
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
return false;
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
// sicher aufgerufen werden kann.
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
{
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
owner.Activate();
var dialog = new McpConfirmDialog
{
DataContext = new McpConfirmDialogInfo
{
Title = title, Message = message, ConfirmText = "Übernehmen",
AllowSessionTrust = allowSessionTrust,
},
};
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
var timeoutTask = Task.Delay(Timeout, ct);
var completed = await Task.WhenAny(dialogTask, timeoutTask);
if (completed != dialogTask)
{
dialog.Close(McpConfirmDialogResult.Rejected);
return McpConfirmDialogResult.Rejected;
}
return await dialogTask;
});
if (result.Approved && allowSessionTrust)
{
lock (_trustLock)
{
if (result.TrustAll) _trustAll = true;
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
}
}
return result.Approved;
}
}
@@ -0,0 +1,30 @@
using System.Runtime.CompilerServices;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
/// </summary>
public interface IMcpConfirmationService
{
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</param>
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
}
@@ -0,0 +1,157 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace LehrerApp.Desktop.Services.Mcp;
public record McpRegistrationResult(bool Success, string Message);
/// <summary>
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
/// unterstützen ließe.
///
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
///
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
/// </summary>
public class McpClientRegistrationService
{
private const string ServerName = "lehrerapp";
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
private readonly string? _bridgePath;
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
public McpClientRegistrationService(string? configPath, string? bridgePath)
{
ConfigPath = configPath;
_bridgePath = bridgePath;
}
public string? ConfigPath { get; }
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
public bool IsRegistered() =>
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
public McpRegistrationResult Register()
{
if (ConfigPath is null)
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
var bridgePath = _bridgePath;
if (bridgePath is null || !File.Exists(bridgePath))
return new(false,
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
if (!TryLoadRoot(out var root, out var error))
return new(false, error!);
if (root["mcpServers"] is not JsonObject servers)
{
servers = new JsonObject();
root["mcpServers"] = servers;
}
servers[ServerName] = new JsonObject
{
["command"] = bridgePath,
["args"] = new JsonArray(),
};
var detectedBefore = IsClaudeDesktopDetected;
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
if (result.Success && !detectedBefore)
return result with
{
Message = result.Message +
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
"greift erst, sobald Claude Desktop installiert ist.",
};
return result;
}
public McpRegistrationResult Unregister()
{
if (ConfigPath is null || !File.Exists(ConfigPath))
return new(true, "Nichts einzutragen gefunden.");
if (!TryLoadRoot(out var root, out var error))
return new(false, error!);
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
return new(true, "War nicht eingetragen.");
return WriteRoot(root, "Eintrag entfernt.");
}
private bool TryLoadRoot(out JsonObject root, out string? error)
{
error = null;
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
try
{
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
return true;
}
catch (JsonException ex)
{
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
root = new JsonObject();
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
$"Bitte manuell prüfen: {ConfigPath}";
return false;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
root = new JsonObject();
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
return false;
}
}
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
return new(true, successMessage);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
}
}
private static string? ResolveBridgePath()
{
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
}
private static string? ResolveConfigPath()
{
if (OperatingSystem.IsWindows())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Claude", "claude_desktop_config.json");
if (OperatingSystem.IsMacOS())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library", "Application Support", "Claude", "claude_desktop_config.json");
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
}
}
@@ -0,0 +1,254 @@
using System.IO.Pipes;
using LehrerApp.Core.Mcp;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services.Mcp.Tools;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
///
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
/// alle Sessions zu teilen.
/// </summary>
public sealed class McpServerHostedService : IAsyncDisposable
{
private readonly McpSettingsService _settings;
private readonly AppLogger _logger;
private readonly McpServerOptions _serverOptions;
private CancellationTokenSource? _cts;
private Task? _acceptLoop;
public McpServerHostedService(
McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
_settings = settings;
_logger = logger;
_serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
}
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
public void Start()
{
if (!_settings.Enabled || _cts is not null) return;
_cts = new CancellationTokenSource();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
}
private async Task AcceptLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var pipe = new NamedPipeServerStream(
McpPipeConstants.PipeName, PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
try
{
await pipe.WaitForConnectionAsync(ct);
}
catch (OperationCanceledException)
{
await pipe.DisposeAsync();
break;
}
catch (Exception ex)
{
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
await pipe.DisposeAsync();
continue;
}
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
_ = RunSessionAsync(pipe, ct);
}
}
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
{
try
{
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
await server.RunAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
}
finally
{
await pipe.DisposeAsync();
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null) return;
await _cts.CancelAsync();
if (_acceptLoop is not null)
{
try { await _acceptLoop; }
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
}
_cts.Dispose();
}
private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
{
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
void AddReadTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = true,
}));
}
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
// Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
void AddWriteTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = false,
Destructive = false,
}));
}
void AddDestructiveWriteTool(Delegate handler, string name, string description)
{
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
{
Name = name,
Description = description,
ReadOnly = false,
Destructive = true,
}));
}
AddReadTool(studentTools.GetStudents, "get_students",
"Listet Schüler, optional gefiltert nach Lerngruppe.");
AddReadTool(groupTools.GetGroups, "get_groups",
"Listet Lerngruppen (Klassen/Kurse) mit ihrer Id, optional gefiltert nach Schuljahr.");
AddReadTool(examTools.GetExams, "get_exams",
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
AddReadTool(gradeTools.GetGrades, "get_grades",
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
AddReadTool(competencyTools.GetSubjects, "get_subjects",
"Listet alle Fächer.");
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.CreateSubject, "create_subject",
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
System.Diagnostics.Debug.Assert(
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools)
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
"Registrierte MCP-Tools weichen von McpToolScope ab.");
return new McpServerOptions
{
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
ToolCollection = toolCollection,
};
}
}
@@ -0,0 +1,81 @@
namespace LehrerApp.Desktop.Services.Mcp;
/// <summary>
/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie
/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle
/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
///
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
/// </summary>
public static class McpToolScope
{
public static readonly IReadOnlyCollection<string> AllowedReadTools =
[
"get_students",
"get_groups",
"get_exams",
"get_grades",
"get_schedule",
"get_time_entries",
"get_lesson_plans",
"download_lesson_attachment",
"list_letter_templates",
"render_letter",
"get_subjects",
"get_competency_catalog",
"get_untis_hub_status",
"get_untis_absence_rows",
"get_named_untis_absence_pattern",
];
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig
/// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase)
/// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe
/// Begründung in LessonPlanTools.</summary>
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
[
"create_time_entry",
"create_grade_entry",
"update_student_group_assignment",
"create_unit",
"update_unit",
"create_lesson",
"update_lesson",
"add_lesson_phase",
"update_lesson_phase",
"remove_lesson_phase",
"add_lesson_attachment",
"move_lesson",
"add_lesson_competency",
"remove_lesson_competency",
"create_subject",
"update_subject",
"create_competency_domain",
"update_competency_domain",
"add_competency_item",
"update_competency_item",
"remove_competency_item",
"apply_untis_absence_status",
];
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
[
"delete_lesson",
"delete_subject",
"delete_competency_domain",
];
}
@@ -0,0 +1,252 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
///
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
public class CompetencyTools(
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
{
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet alle Fächer.")]
public List<SubjectDto> GetSubjects() =>
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
public List<CompetencyDomainDto> GetCompetencyCatalog(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
{
var result = gradeLevel is not null
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
: domains.GetBySubject(subjectId);
return result.Select(ToDto).ToList();
}
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateSubject(
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
var message = $"Neues Fach „{name.Trim()}“" +
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var subject = new Subject { Name = name, ShortName = shortName };
try { subjects.Save(subject); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
}
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateSubject(
[Description("Fach-ID.")] Guid subjectId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
var changes = new StringBuilder();
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
if (changes.Length == 0)
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { subjects.Save(subject); }
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
}
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteSubject(
[Description("Fach-ID.")] Guid subjectId,
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try { subjects.Delete(subjectId); }
catch (InvalidOperationException ex)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
}
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateCompetencyDomain(
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
[Description("Klassenstufe.")] int gradeLevel,
[Description("Name des Bereichs.")] string name,
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
CancellationToken ct = default)
{
var subject = subjects.GetById(subjectId);
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var domain = new CompetencyDomain
{
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
};
domains.Save(domain);
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
}
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateCompetencyDomain(
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var changes = new StringBuilder();
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
if (changes.Length == 0)
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Save(domain);
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
}
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteCompetencyDomain(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Delete(domainId);
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
}
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddCompetencyItem(
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
[Description("Beschreibung der Kompetenz.")] string description,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
domain.Items.Add(item);
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
}
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateCompetencyItem(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
var changes = new StringBuilder();
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
if (changes.Length == 0)
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
}
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveCompetencyItem(
[Description("ID des Kompetenzbereichs.")] Guid domainId,
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
CancellationToken ct = default)
{
var domain = domains.GetById(domainId);
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
domain.Items.Remove(item);
domains.Save(domain);
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
}
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
d.Items.OrderBy(i => i.SortOrder)
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
}
+114
View File
@@ -0,0 +1,114 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein
// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt
// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist.
public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive);
public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent);
public record ExamDto(
Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau,
List<ExamResultDto>? Results);
public record GradeDto(
Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date,
double Weight, string? Note);
public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room);
public record TimeEntryDto(
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
public record LessonPhaseDto(
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
string? MaterialPrompt);
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht;
/// stattdessen deckelt <see cref="LessonPlanTools.MaxInlineAttachmentBytes"/> die Größe.</summary>
public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content);
public record UnitDto(
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
UnitStatus Status, List<string> Competencies);
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
public record SubjectDto(Guid Id, string Name, string ShortName);
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
public record CompetencyDomainDto(
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
List<CompetencyItemDto> Items);
public record GroupMembershipDto(
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
public record WriteResultDto(bool Applied, Guid? Id, string Message);
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann. GroupId ist bei
/// den drei dashboard-weiten Zeilen (offene Stunden, Klassenbuch-/Hausaufgabenabgleich) null - siehe
/// UntisHubJobRow. Enthalten, damit ein KI-Client die Id nicht erst über get_groups nachschlagen
/// muss, um get_untis_absence_rows/get_named_untis_absence_pattern für eine hier gelistete Gruppe
/// aufzurufen (Nutzer-Feedback: die GroupId war nirgends exponiert).</summary>
public record UntisHubStatusRowDto(
string Kind, Guid? GroupId, string GroupName, string DueState, string DueLabel,
DateTime? LastRunAt, string? LastResultSummary);
/// <summary>Lerngruppe (Klasse oder Kurs) - siehe GroupTools.GetGroups. Bewusst kein Verweis auf
/// SubjectId->Name aufgelöst (dafür get_subjects), um nicht bei jeder Gruppe implizit einen ganzen
/// Fach-Datensatz mitzuschleppen.</summary>
public record GroupDto(
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
Guid? SubjectId, bool IsActive);
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
public record UntisAbsenceRowDto(
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
public record NamedUntisAbsenceRowDto(
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
string CurrentLocalStatus, string CurrentGuessStatus);
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
{
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
public List<ExamDto> GetExams(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
[Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false)
{
var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll();
return list.Select(e => new ExamDto(
e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau,
includeResults
? examResults.GetByExam(e.Id)
.Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent))
.ToList()
: null)).ToList();
}
}
@@ -0,0 +1,70 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
/// Planungsdokument.</summary>
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
{
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
public List<GradeDto> GetGrades(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null)
{
var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId);
return list.Select(g => new GradeDto(
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
}
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
public async Task<WriteResultDto> CreateGradeEntry(
[Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
[Description("Optionale Notiz.")] string? note = null,
CancellationToken ct = default)
{
var student = students.GetById(studentId);
if (student is null)
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
var message =
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
$", am {date:dd.MM.yyyy}" +
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var grade = new Grade
{
StudentId = studentId,
GroupId = groupId,
Category = category,
Value = value,
Date = date,
Weight = weight,
Note = note,
};
grades.Save(grade);
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
}
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
{
GradeCategory.Oral => "Mündlich",
GradeCategory.Homework => "Hausaufgaben",
GradeCategory.Participation => "Mitarbeit",
GradeCategory.Project => "Projekt",
GradeCategory.Other => "Sonstiges",
_ => c.ToString(),
};
}
@@ -0,0 +1,69 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
public class GroupMembershipTools(
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
IMcpConfirmationService confirmation)
{
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
[Description("Schüler-ID.")] Guid studentId,
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
CancellationToken ct = default)
{
var student = students.GetById(studentId);
var group = groups.GetById(groupId);
if (student is null || group is null)
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
var target = existing is null
? new GroupMembership { StudentId = studentId, GroupId = groupId }
: Clone(existing);
var changes = new StringBuilder();
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
if (existing is not null && changes.Length == 0)
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
var message = $"{student.FullName} — {group.Name}" +
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
if (!await confirmation.ConfirmAsync(title, message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
memberships.Save(target);
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
}
private static GroupMembership Clone(GroupMembership m) => new()
{
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
};
private static string NiveauName(Niveau? n) => n switch
{
Core.Models.Niveau.E => "E-Niveau",
Core.Models.Niveau.G => "G-Niveau",
Core.Models.Niveau.Foerder => "Förderniveau",
_ => "",
};
}
@@ -0,0 +1,23 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_groups" (Nutzer-Nachtrag: die GroupId, die praktisch jedes andere
/// Tool als Pflichtparameter verlangt - get_grades, get_schedule, get_lesson_plans,
/// get_untis_absence_rows usw. - war nirgends über MCP auflösbar; ein KI-Client kannte bestenfalls
/// den Klarnamen einer Lerngruppe aus dem Gespräch, nie ihre Id). Reiner Lesezugriff auf das
/// bestehende Repository, keine eigene Datenzugriffslogik.</summary>
public class GroupTools(IGroupRepository groups)
{
[Description("Listet Lerngruppen (Klassen/Kurse) mit ihrer Id - Voraussetzung, um andere Tools (z.B. get_grades, get_schedule, get_untis_absence_rows) für eine bestimmte Gruppe aufzurufen, wenn nur ihr Name bekannt ist. Ohne schoolYear werden alle Schuljahre zurückgegeben.")]
public List<GroupDto> GetGroups(
[Description("Optionales Schuljahr zum Filtern, Format \"2025/26\". Ohne Angabe alle Schuljahre.")] string? schoolYear = null,
[Description("Auch inaktive/archivierte Gruppen einbeziehen.")] bool includeInactive = false)
{
var list = schoolYear is null
? groups.GetAll(includeInactive)
: groups.GetBySchoolYear(schoolYear, includeInactive);
return list.Select(g => new GroupDto(g.Id, g.Name, g.Type, g.SchoolYear, g.GradeLevel, g.SubjectId, g.IsActive)).ToList();
}
}
@@ -0,0 +1,420 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools rund um Unterrichtseinheiten (<see cref="Unit"/>) und Einzelstunden
/// (<see cref="Lesson"/>) — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument).
///
/// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl.
/// Verlaufsplan als ein großes JSON-Objekt tauscht: <see cref="Lesson.Phases"/> ist zwar technisch
/// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein
/// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen
/// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.
///
/// "delete_lesson" ist (Stand Nutzer-Nachtrag) das einzige Lösch-Tool im gesamten MCP-Katalog —
/// eine bewusste, gezielte Ausnahme von der sonst geltenden "v1 ohne Lösch-Tools"-Regel (siehe
/// Planungsdokument), nicht deren Aufhebung. Entsprechend als "Destructive" annotiert
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
public class LessonPlanTools(
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
{
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (App-weites
/// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument
/// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien".</summary>
public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024;
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")]
public LessonPlanResultDto GetLessonPlans(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Startdatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly from,
[Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to)
{
var unitDtos = units.GetByGroup(groupId)
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies))
.ToList();
var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to)
.Select(ToDto)
.ToList();
return new LessonPlanResultDto(unitDtos, lessonDtos);
}
[Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")]
public AttachmentContentDto DownloadLessonAttachment(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId)
{
var lesson = lessons.GetById(lessonId) ?? throw new InvalidOperationException("Unbekannte Stunden-ID.");
var attachment = lesson.Attachments.FirstOrDefault(a => a.StorageId == storageId)
?? throw new InvalidOperationException("Kein Anhang mit dieser Speicher-ID an dieser Stunde.");
if (attachment.SizeBytes > MaxInlineAttachmentBytes)
throw new InvalidOperationException(
$"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB).");
using var stream = attachments.OpenRead(storageId)
?? throw new InvalidOperationException("Anhang-Inhalt nicht auffindbar (Speicher inkonsistent).");
using var buffer = new MemoryStream();
stream.CopyTo(buffer);
return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray()));
}
[Description("Fügt einer Einzelstunde ein neues Material (z.B. ein von der KI erzeugtes Arbeitsblatt) als Anhang hinzu, Base64-kodiert. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonAttachment(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Dateiname inkl. Endung, z.B. \"arbeitsblatt.pdf\".")] string fileName,
[Description("Dateiinhalt, Base64-kodiert.")] string base64Content,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
byte[] bytes;
try { bytes = Convert.FromBase64String(base64Content); }
catch (FormatException) { return new WriteResultDto(false, null, "Ungültiger Base64-Inhalt."); }
if (bytes.Length == 0) return new WriteResultDto(false, null, "Leerer Dateiinhalt.");
if (bytes.Length > IAttachmentStorage.MaxSizeBytes)
return new WriteResultDto(false, null,
$"Datei ist mit {bytes.Length / 1024 / 1024} MB zu groß (Limit {IAttachmentStorage.MaxSizeBytes / 1024 / 1024} MB).");
var sizeDisplay = bytes.Length >= 1024 * 1024
? $"{bytes.Length / 1024 / 1024} MB" : $"{Math.Max(1, bytes.Length / 1024)} KB";
var message = $"„{fileName}“ ({sizeDisplay}) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Material anhängen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
string storageId;
using (var stream = new MemoryStream(bytes)) storageId = attachments.Upload(fileName, stream);
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = fileName, SizeBytes = bytes.Length });
lessons.Save(lesson);
// DocumentAttachment hat keine eigene Guid-Id (nur StorageId, ein string) - deshalb Id hier
// null, storageId steht stattdessen in der Nachricht (für einen unmittelbaren Folgeaufruf,
// z.B. download_lesson_attachment zur Bestätigung, ohne erst get_lesson_plans erneut aufzurufen).
return new WriteResultDto(true, null, $"Anhang gespeichert (storageId={storageId}).");
}
// ── Unterrichtseinheiten (Unit) ──────────────────────────────────────────────────────────
[Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task<WriteResultDto> CreateUnit(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Titel der Einheit.")] string title,
[Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly? startDate = null,
[Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly? endDate = null,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID.");
var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" +
(startDate is not null || endDate is not null
? $"\nZeitraum: {startDate:dd.MM.yyyy} {endDate:dd.MM.yyyy}" : "");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var unit = new Unit { GroupId = groupId, Title = title, StartDate = startDate, EndDate = endDate };
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
[Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateUnit(
[Description("ID der Unterrichtseinheit.")] Guid unitId,
[Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null,
[Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? startDate = null,
[Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? endDate = null,
[Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus? status = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var changes = new StringBuilder();
if (title is not null && title != unit.Title) { changes.AppendLine($"Titel: „{unit.Title}“ → „{title}“"); unit.Title = title; }
if (startDate is not null && startDate != unit.StartDate) { changes.AppendLine($"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}"); unit.StartDate = startDate; }
if (endDate is not null && endDate != unit.EndDate) { changes.AppendLine($"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}"); unit.EndDate = endDate; }
if (status is not null && status != unit.Status) { changes.AppendLine($"Status: {unit.Status} → {status}"); unit.Status = status.Value; }
if (changes.Length == 0)
return new WriteResultDto(true, unit.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Unterrichtseinheit ändern?", $"„{unit.Title}“\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
units.Save(unit);
return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert.");
}
// ── Einzelstunden (Lesson) — Metadaten ───────────────────────────────────────────────────
[Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> CreateLesson(
[Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Thema der Stunde.")] string topic,
[Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null,
[Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly? startTime = null,
CancellationToken ct = default)
{
var unit = units.GetById(unitId);
if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID.");
var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?";
if (!await confirmation.ConfirmAsync("Einzelstunde anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var lesson = new Lesson
{
UnitId = unitId,
GroupId = unit.GroupId,
Date = date,
Topic = topic,
LessonNumber = lessonNumber,
StartTime = startTime,
};
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Planungsideen, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLesson(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
[Description("Neue Planungsideen (grober Entwurf vor der Feinplanung). Unverändert lassen: weglassen.")] string? planningIdeas = null,
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var changes = new StringBuilder();
if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; }
if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; }
if (planningIdeas is not null && planningIdeas != lesson.PlanningIdeas) { changes.AppendLine($"Planungsideen: „{lesson.PlanningIdeas}“ → „{planningIdeas}“"); lesson.PlanningIdeas = planningIdeas; }
if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; }
if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; }
if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; }
if (changes.Length == 0)
return new WriteResultDto(true, lesson.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Einzelstunde ändern?", $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert.");
}
// ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ─────────────────────────────────────────
[Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name,
[Description("Dauer in Minuten.")] int durationMinutes,
[Description("Tätigkeit/Sozialform.")] string activity = "",
[Description("Material.")] string material = "",
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "",
[Description("Optionaler, vollständiger Prompt zur Materialerstellung für diese Phase (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt) - z.B. wenn eine externe KI-Sitzung ihn selbst formuliert hat und er zur Wiederverwendung gespeichert werden soll.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Phase hinzufügen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var phase = new LessonPhaseStep
{
Name = name, DurationMinutes = durationMinutes, Activity = activity,
Material = material, Shorthand = shorthand, MaterialPrompt = materialPrompt,
};
lesson.Phases.Add(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase hinzugefügt.");
}
[Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")]
public async Task<WriteResultDto> UpdateLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
[Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null,
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null,
[Description("Neuer Prompt zur Materialerstellung (siehe get_lesson_plans, LessonPhaseDto.MaterialPrompt). Unverändert lassen: weglassen.")] string? materialPrompt = null,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var changes = new StringBuilder();
if (name is not null && name != phase.Name) { changes.AppendLine($"Name: „{phase.Name}“ → „{name}“"); phase.Name = name; }
if (durationMinutes is not null && durationMinutes != phase.DurationMinutes) { changes.AppendLine($"Dauer: {phase.DurationMinutes} → {durationMinutes} Min."); phase.DurationMinutes = durationMinutes.Value; }
if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; }
if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; }
if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; }
if (materialPrompt is not null && materialPrompt != phase.MaterialPrompt) { changes.AppendLine("Materialerstellungs-Prompt geändert."); phase.MaterialPrompt = materialPrompt; }
if (changes.Length == 0)
return new WriteResultDto(true, phase.Id, "Keine Änderung nötig.");
if (!await confirmation.ConfirmAsync("Phase ändern?", $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase gespeichert.");
}
[Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveLessonPhase(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId);
if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde.");
var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
if (!await confirmation.ConfirmAsync("Phase entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Phases.Remove(phase);
lessons.Save(lesson);
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
}
// ── Einzelstunden (Lesson) — Kompetenzzuordnung ──────────────────────────────────────────
// Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte
// add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe
// Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) -
// ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste.
[Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> AddLessonCompetency(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
code = code.Trim();
if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode.");
if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase))
return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet.");
var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Competencies.Add(code);
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet.");
}
[Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> RemoveLessonCompetency(
[Description("ID der Einzelstunde.")] Guid lessonId,
[Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase));
if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet.");
var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lesson.Competencies.Remove(existing);
lessons.Save(lesson);
return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt.");
}
// ── Einzelstunden (Lesson) — Verschieben & Löschen ───────────────────────────────────────
[Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> MoveLesson(
[Description("ID der zu verschiebenden Einzelstunde.")] Guid lessonId,
[Description("Neues Datum, Format YYYY-MM-DD.")] DateOnly newDate,
[Description("Neue Stundennummer im Tagesraster. Unverändert lassen: weglassen.")] int? newPeriod = null,
[Description("Alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mitverschieben.")] bool shiftFollowingLessons = false,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var oldDate = lesson.Date;
// Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
// weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
var affectedCount = shiftFollowingLessons
? lessons.GetByUnit(lesson.UnitId).Count(l =>
l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
: 0;
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
(newPeriod is not null ? $"\nNeue Stundennummer: {newPeriod}." : "") +
(shiftFollowingLessons
? affectedCount > 0
? $"\n{affectedCount} spätere Stunde(n) derselben Einheit verschieben sich um denselben Versatz mit."
: "\nKeine späteren, noch offenen Stunden derselben Einheit betroffen."
: "");
if (!await confirmation.ConfirmAsync("Stunde verschieben?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
try
{
new LessonSchedulingService(lessons).Move(lesson, newDate, newPeriod, shiftFollowingLessons);
}
catch (InvalidOperationException ex)
{
return new WriteResultDto(false, null, ex.Message);
}
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
}
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
public async Task<WriteResultDto> DeleteLesson(
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
CancellationToken ct = default)
{
var lesson = lessons.GetById(lessonId);
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
var message = $"Die Stunde „{lesson.Topic}“ vom {lesson.Date:dd.MM.yyyy} wird endgültig gelöscht. " +
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Stunden).";
if (!await confirmation.ConfirmAsync("Stunde endgültig löschen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
lessons.Delete(lessonId);
return new WriteResultDto(true, lessonId, "Stunde gelöscht.");
}
private static LessonDto ToDto(Lesson l) => new(
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies,
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(),
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
}
@@ -0,0 +1,145 @@
using System.ComponentModel;
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt
/// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor —
/// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
/// (<c>CreateLetterDialogViewModel</c>). Bewusst NICHT umgesetzt: "upload_worksheet"/
/// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine
/// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich
/// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind
/// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den
/// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen.</summary>
public class LetterTemplateTools(
TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer,
IStudentRepository students, IGroupRepository groups,
StudentAttendanceCalendarService? attendanceCalendars = null)
{
[Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")]
public List<LetterTemplateDto> ListLetterTemplates()
{
var result = new List<LetterTemplateDto>();
foreach (var installed in templates.GetTemplates())
{
try
{
var loaded = templates.Load(installed);
result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description,
loaded.Manifest.Placeholders
.Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant))
.ToList()));
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{
// Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt
// auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen.
}
}
return result;
}
[Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")]
public LetterRenderResultDto RenderLetter(
[Description("Vorlagen-ID, aus list_letter_templates.")] string templateId,
[Description("Schüler-ID.")] Guid studentId,
[Description("Brieftext (Fließtext).")] string letterText,
[Description("Name der unterschreibenden Lehrkraft.")] string teacherName,
[Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null,
[Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null,
[Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null,
[Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")]
Dictionary<string, string>? extraValues = null)
{
var student = students.GetById(studentId);
if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null);
var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId);
if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null);
LoadedTemplate loaded;
try { loaded = templates.Load(installed); }
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException)
{ return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); }
var contact = contactId is { } cid
? student.Contacts.FirstOrDefault(c => c.Id == cid)
: student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue);
var group = groupId is { } gid ? groups.GetById(gid) : null;
var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today);
var calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1),
absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
foreach (var (name, raw) in extraValues ?? [])
{
var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name);
if (definition is null || definition.IsConstant) continue;
if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted;
}
var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values);
var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type));
var missing = loaded.Manifest.Placeholders
.Where(p => !p.IsConstant && p.Required &&
(!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v)))
.Select(p => p.Name).ToList();
if (!validation.IsValid || missing.Count > 0)
{
var issues = validation.Issues.Select(i => i.Message)
.Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt."));
return new LetterRenderResultDto(false,
"Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null);
}
byte[] pdf;
try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); }
catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException)
{ return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); }
var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf");
return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName);
}
private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch
{
PlaceholderType.Text => new TextValue(raw),
PlaceholderType.Multiline => new MultilineValue(raw),
PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null,
PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null,
// Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar.
_ => null,
};
private static DateOnly? TryParseDate(string raw) =>
DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ||
DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null;
private static decimal? TryParseNumber(string raw) =>
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) ||
decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null;
private static string SanitizeFileName(string value)
{
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
return value;
}
private sealed class DictionaryDataProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
}
@@ -0,0 +1,16 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_schedule" (Phase 1, siehe Planungsdokument).</summary>
public class ScheduleTools(ITimetableSlotRepository slots)
{
[Description("Listet Stundenplan-Einträge (Wochenraster), optional gefiltert nach Lerngruppe.")]
public List<TimetableSlotDto> GetSchedule(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null)
{
var list = groupId is { } id ? slots.GetByGroup(id) : slots.GetAll();
return list.Select(s => new TimetableSlotDto(s.Id, s.GroupId, s.Weekday, s.PeriodNumber, s.Room)).ToList();
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Read-Tool "get_students" (Phase 1, siehe Planungsdokument). Reine Lesezugriffe auf
/// die bestehenden Repositories, keine eigene Datenzugriffslogik.</summary>
public class StudentTools(IStudentRepository students)
{
[Description("Listet Schüler, optional gefiltert nach Lerngruppe. Enthält standardmäßig nur aktive Schüler.")]
public List<StudentDto> GetStudents(
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
[Description("Auch inaktive/ausgeschiedene Schüler einbeziehen.")] bool includeInactive = false)
{
var list = groupId is { } id ? students.GetByGroup(id) : students.GetAll(includeInactive);
if (groupId is not null && !includeInactive)
list = list.Where(s => s.IsActive).ToList();
return list.Select(s => new StudentDto(s.Id, s.FirstName, s.LastName, s.IsActive)).ToList();
}
}
@@ -0,0 +1,49 @@
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
{
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
public List<TimeEntryDto> GetTimeEntries(
[Description("Startdatum (einschließlich), Format YYYY-MM-DD.")] DateOnly from,
[Description("Enddatum (einschließlich), Format YYYY-MM-DD.")] DateOnly to) =>
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
t.DurationMinutes, t.Description)).ToList();
[Description("Schlägt einen neuen Zeiterfassungs-Eintrag vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor er gespeichert wird.")]
public async Task<WriteResultDto> CreateTimeEntry(
[Description("Kategorie, z.B. \"Unterricht\", \"Korrektur\", \"Vorbereitung\".")] string category,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
[Description("Dauer in Minuten.")] int durationMinutes,
[Description("Optionale Lerngruppen-ID.")] Guid? groupId = null,
[Description("Optionale Beschreibung.")] string? description = null,
CancellationToken ct = default)
{
var groupName = groupId is { } gid ? groups.GetById(gid)?.Name : null;
var message =
$"Neuer Zeiteintrag: {category}, {durationMinutes} Min. am {date:dd.MM.yyyy}" +
(groupName is not null ? $", Gruppe {groupName}" : "") +
(string.IsNullOrWhiteSpace(description) ? "" : $"\n„{description}“");
if (!await confirmation.ConfirmAsync("Zeiteintrag anlegen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var entry = new TimeEntry
{
Category = category,
Date = date,
DurationMinutes = durationMinutes,
GroupId = groupId,
Description = description,
};
timeEntries.Save(entry);
return new WriteResultDto(true, entry.Id, "Zeiteintrag gespeichert.");
}
}
@@ -0,0 +1,208 @@
using System.Collections.Concurrent;
using System.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
/// wie vom Nutzer entschieden:
///
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
///
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
/// </summary>
public class UntisComparisonTools(
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
IMcpConfirmationService confirmation)
{
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
// (siehe GradeTools).
private static readonly AttendanceStatus[] SelectableStatuses =
[
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
];
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
hub.GetRows().Select(r => new UntisHubStatusRowDto(
r.Kind.ToString(), r.GroupId, r.GroupName, r.DueState.ToString(), r.DueLabel,
r.LastRunAt, r.LastResultSummary)).ToList();
[Description("""
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
""")]
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId) return [];
var courseStudents = students.GetByGroup(groupId);
var byExternKey = courseStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List<UntisAbsenceRowDto>();
foreach (var absence in absences)
{
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
var rowId = Guid.NewGuid().ToString("N");
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
rows.Add(new UntisAbsenceRowDto(
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
!string.IsNullOrWhiteSpace(absence.HandledOn),
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
LocalStatusLabel(entry?.Attendance), guess.ToString()));
}
return rows;
}
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
[Description("row-id aus get_untis_absence_rows.")] string rowId,
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
CancellationToken ct = default)
{
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
if (!_pendingRows.TryGetValue(rowId, out var row))
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
entry.Attendance = target;
entry.UpdatedAt = DateTime.UtcNow;
participation.Save(entry);
_pendingRows.TryRemove(rowId, out _);
return new WriteResultDto(true, entry.Id, "Status übernommen.");
}
[Description("""
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
""")]
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
[Description("Lerngruppen-ID.")] Guid groupId,
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
CancellationToken ct = default)
{
var group = groups.GetById(groupId);
if (group?.WebUntisLessonId is not { } lessonId)
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
var resolvedStudents = studentIds.Distinct()
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
if (resolvedStudents.Count == 0)
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
$"Zeitraum: {startDate:dd.MM.yyyy}{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
var byExternKey = resolvedStudents
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
.Where(x => x.Key is not null)
.ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = resolvedStudents
.SelectMany(s => new[]
{
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
}.Select(key => (Key: key, Student: s)))
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(g => g.Key, g => g.First().Student);
var localSessions = sessions.GetByGroup(groupId)
.Where(s => s.Date >= startDate && s.Date <= endDate)
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
var rows = new List<NamedUntisAbsenceRowDto>();
foreach (var absence in absences)
{
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
if (!TryParseDate(absence.Date, out var date)) continue;
var entry = localSessions.TryGetValue(date, out var session)
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
rows.Add(new NamedUntisAbsenceRowDto(
match.Id, match.FullName, date, absence.Reason ?? "",
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
}
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
private static bool TryParseDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
{
null => "kein Status erfasst",
AttendanceStatus.Present => "Anwesend",
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
AttendanceStatus.Excused => "Krank, entschuldigt",
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
AttendanceStatus.Late => "Verspätet",
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
_ => s.ToString()!,
};
}
@@ -0,0 +1,48 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class McpSettingsConfig
{
public bool Enabled { get; set; }
}
/// <summary>
/// Opt-in-Schalter für den lokalen MCP-Server (siehe Planungsdokument, Phase 1). Anders als
/// <see cref="AiSettingsService"/> braucht Phase 1 kein Login/Token — die Named Pipe selbst ist die
/// Vertrauensgrenze (lokaler Prozess, gleiche Windows-Session bzw. Unix-Dateirechte), siehe
/// Begründung im Planungsdokument.
/// </summary>
public class McpSettingsService
{
private readonly string _configPath;
private McpSettingsConfig _config;
public bool Enabled => _config.Enabled;
public McpSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "mcp-settings.json");
_config = Load();
}
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
private McpSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<McpSettingsConfig>(File.ReadAllText(_configPath))
?? new McpSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new McpSettingsConfig();
}
}
@@ -0,0 +1,301 @@
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services;
public enum AttendanceCalendarSize { Small, Medium, Large }
public sealed record AttendanceCalendarOptions(DateOnly StartMonth, int MonthCount,
AttendanceCalendarSize Size = AttendanceCalendarSize.Medium)
{
public DateOnly NormalizedStartMonth => new(StartMonth.Year, StartMonth.Month, 1);
public int NormalizedMonthCount => Math.Clamp(MonthCount, 1, 3);
}
/// <summary>Erzeugt den portablen Advanced-Content-Platzhalter für Elternbriefe aus derselben
/// priorisierten Monatsansicht, die im Klassenlehrer-Sidebar-Widget verwendet wird.</summary>
public static class StudentAttendanceCalendarDrawingBuilder
{
public const string PlaceholderName = "Student.AttendanceCalendar";
public static DrawingValue Build(string studentName, DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries) =>
Build(studentName, new AttendanceCalendarOptions(month, 1), absences, registerEntries);
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, float contentWidth = 170,
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{
var requestedScale = options.Size switch
{
AttendanceCalendarSize.Small => .7f,
AttendanceCalendarSize.Large => 1f,
_ => .85f,
};
// contentWidth/contentHeight/millimeterScale kommen aus der tatsächlichen DRAWBOX/
// FLOWDRAWBOX-Deklaration der jeweiligen Vorlage (siehe
// LetterPlaceholderBuilder.FindDeclaredDrawingBox). Das Raster ist in Millimetern entworfen;
// millimeterScale rechnet das in die Koordinaten-Einheit der Vorlage um (1, wenn die Vorlage
// schon "mm" nutzt; ≈2.83 bei "pt") - ohne das würde das Raster bei einer in "pt" deklarierten
// Vorlage nur rund ein Drittel der vorgesehenen Größe erreichen. Schriftgrößen (FontSize) sind
// davon unabhängig immer echte Punktgrößen. contentWidth ist unabhängig von der Größenwahl
// fest - nur Schrift/Zellenhöhe skalieren mit "Größe". Würde die Breite mit skalieren, würde
// "Groß" (scale=1) exakt die Skript-Box ausfüllen, "Klein"/"Normal" aber nur einen Teil davon
// - und eine größere Skalierung als 1 liefe über die Box hinaus und würde am rechten Rand
// abgeschnitten (SVG overflow="hidden"). Die Defaults (170/1/unendlich) greifen nur, wenn die
// Vorlage nicht ermittelt werden kann (z.B. Vorschau ohne Kontext).
var first = options.NormalizedStartMonth;
var monthCount = options.NormalizedMonthCount;
// Wochenanzahl je Monat vorab ermitteln, unabhängig von "Größe" - nötig, um VOR dem
// eigentlichen Zeichnen zu wissen, wie viel Höhe das Raster braucht. Eine DRAWBOX bricht
// anders als FLOWDRAWBOX nicht automatisch auf Folgeseiten um (siehe
// DrawingElementRenderer.RenderFixed/Slice): Inhalt, der contentHeight überschreitet, wird
// von QuestTemplateRenderer am unteren Rand stillschweigend abgeschnitten. "Größe" wird
// deshalb nötigenfalls automatisch verkleinert, statt die von der Vorlage vorgegebene
// Boxhöhe zu verletzen.
var maxWeeks = 0;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
maxWeeks = Math.Max(maxWeeks, (int)Math.Ceiling((offset + days.Count) / 7d));
}
// Muss mit der Höhenformel am Ende dieser Methode übereinstimmen (dort als "y" berechnet):
// Titel+Name+Abstand (26) + Monats-/Wochentagskopf (20) + maxWeeks Wochenzeilen (10 je Woche)
// + Legende/Nachlauf (9), alles mit scale*millimeterScale skaliert, plus der einmalige,
// größenunabhängige monthGapX-Abstand (6*millimeterScale).
float RequiredHeight(float s) => s * millimeterScale * (55 + 10 * maxWeeks) + 6f * millimeterScale;
var scale = requestedScale;
if (float.IsFinite(contentHeight) && RequiredHeight(requestedScale) > contentHeight)
{
var perScaleUnit = RequiredHeight(1) - RequiredHeight(0);
scale = perScaleUnit > 0
? Math.Clamp((contentHeight - RequiredHeight(0)) / perScaleUnit, .35f, requestedScale)
: requestedScale;
}
var cellHeight = 10 * scale * millimeterScale;
// War 10mm - bei 3 Monaten nebeneinander größer als eine einzelne Tageskachel und zog damit
// spürbar Platz von den Kacheln ab, ohne selbst als Inhalt wahrgenommen zu werden.
var monthGapX = 6f * millimeterScale;
var monthWidth = (contentWidth - monthGapX * (monthCount - 1)) / monthCount;
var cellWidth = monthWidth / 7;
// Kachel-Zwischenraum (Trennung zu Nachbarzellen) deutlich knapper als vorher (war "scale"
// mm, also bis zu 1mm auf jeder Seite - bei einer ~7mm breiten Zelle ein gutes Viertel
// reiner Leerraum). Die Kachel selbst füllt dadurch ihren Rasterplatz sichtbar besser aus.
var cellInset = scale * .4f * millimeterScale;
var cellCornerRadius = Math.Min(cellWidth, cellHeight) * .2f;
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Anwesenheit",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
var weekdays = new[] { "Mo", "Di", "Mi", "Do", "Fr", "Sa", "So" };
var gridStartY = y;
for (var monthIndex = 0; monthIndex < monthCount; monthIndex++)
{
var current = first.AddMonths(monthIndex);
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(current, absences, registerEntries,
DateOnly.FromDateTime(DateTime.Today), studentName);
var offset = ((int)current.DayOfWeek + 6) % 7;
var xOffset = monthIndex * (monthWidth + monthGapX);
var monthY = gridStartY;
commands.Add(new DrawStringEx(xOffset, monthY, 11 * scale * millimeterScale, monthWidth,
current.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("de-DE")),
DrawingTextAlignment.AlignLeft, 9 * scale, Color: "#374151", Bold: true));
monthY += 11 * scale * millimeterScale;
for (var column = 0; column < 7; column++)
commands.Add(new DrawStringEx(xOffset + column * cellWidth, monthY, 9 * scale * millimeterScale,
cellWidth, weekdays[column], DrawingTextAlignment.AlignCenter, 7 * scale, Color: "#6B7280",
Bold: true));
monthY += 9 * scale * millimeterScale;
foreach (var day in days)
{
var index = offset + day.Date.Day - 1;
var column = index % 7;
var row = index / 7;
var x = xOffset + column * cellWidth;
var cellY = monthY + row * cellHeight;
commands.Add(new DrawRoundedRectangle(x + cellInset, cellY, cellWidth - 2 * cellInset,
cellHeight - cellInset, cellCornerRadius, "#D1D5DB", .35f,
day.HasSignal ? day.SignalColorHex : "#FFFFFF"));
commands.Add(new DrawStringEx(x, cellY + scale * millimeterScale,
cellHeight - 2 * scale * millimeterScale, cellWidth,
day.HasSignal ? day.SignalCode : day.DayNumber, DrawingTextAlignment.AlignCenter, 7 * scale,
Color: day.HasSignal ? "#FFFFFF" : "#374151", Bold: day.HasSignal));
}
}
y = gridStartY + 11 * scale * millimeterScale + 9 * scale * millimeterScale + maxWeeks * cellHeight + monthGapX;
commands.Add(new DrawStringEx(0, y, 8 * scale * millimeterScale, contentWidth,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch",
DrawingTextAlignment.AlignLeft, 6.5f * scale, Color: "#6B7280"));
return new DrawingValue(commands, y + 9 * scale * millimeterScale);
}
}
/// <summary>Chronologische, portable Fehlzeitenliste für Elternbriefvorlagen.</summary>
public static class StudentAbsenceDayListDrawingBuilder
{
public const string PlaceholderName = "Student.AbsenceDays";
public static DrawingValue Build(string studentName, AttendanceCalendarOptions options,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences, float contentWidth = 170,
float millimeterScale = 1)
{
var scale = options.Size switch
{
AttendanceCalendarSize.Small => .75f,
AttendanceCalendarSize.Large => 1f,
_ => .88f,
};
// contentWidth/millimeterScale: siehe StudentAttendanceCalendarDrawingBuilder - contentWidth
// bleibt an die tatsächliche DRAWBOX/FLOWDRAWBOX-Deklaration im Layout-Skript gebunden,
// millimeterScale rechnet das mm-entworfene Raster in die Koordinaten-Einheit der Vorlage um.
var rowHeight = 12 * scale * millimeterScale;
var start = options.NormalizedStartMonth;
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var rows = absences
.Where(a => a.Date >= start && a.Date <= end &&
UntisNameMatching.NamesMatch(a.StudentName, studentName))
.OrderBy(a => a.Date)
.ToList();
var commands = new List<DrawingCommand>();
var y = 0f;
commands.Add(new DrawStringEx(0, y, 14 * scale * millimeterScale, contentWidth, "Fehltage",
DrawingTextAlignment.AlignLeft, 11 * scale, Color: "#1F2937", Bold: true));
y += 14 * scale * millimeterScale;
commands.Add(new DrawStringEx(0, y, 10 * scale * millimeterScale, contentWidth, studentName,
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280"));
y += 10 * scale * millimeterScale + 2 * scale * millimeterScale;
// Spaltenbreiten so gewählt, dass auch die Datenzeilen (nicht nur die kurzen Kopfzeilen-
// Labels) hineinpassen - "Datum" als Kopfzeile ist kürzer als "dd.MM.yyyy" und wurde bei
// 31 zu schmal bemessen, wodurch das Datum am rechten Rand abgeschnitten wurde.
var dateColumnX = 2 * millimeterScale; var dateColumnWidth = 38 * millimeterScale;
var extentColumnX = 44 * millimeterScale; var extentColumnWidth = 66 * millimeterScale;
var statusColumnX = 114 * millimeterScale; var statusColumnWidth = 54 * millimeterScale;
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#CBD5E1", .4f, "#F3F4F6"));
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
dateColumnWidth, "Datum", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
extentColumnWidth, "Umfang", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale, rowHeight - scale * millimeterScale,
statusColumnWidth, "Status", DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151", Bold: true));
y += rowHeight;
if (rows.Count == 0)
{
commands.Add(new DrawStringEx(2 * millimeterScale, y + 2 * scale * millimeterScale, rowHeight,
contentWidth - 4 * millimeterScale, "Keine Fehltage im gewählten Zeitraum",
DrawingTextAlignment.AlignLeft, 8 * scale, Color: "#6B7280", Italic: true));
y += rowHeight + 3 * scale * millimeterScale;
}
else
{
for (var index = 0; index < rows.Count; index++)
{
var row = rows[index];
var fill = index % 2 == 0 ? "#FFFFFF" : "#F9FAFB";
var extent = row.CountsAsFullDay
? "Ganzer Fehltag"
: row.TotalAbsentPeriods > 0
? $"Fehlzeit · {row.TotalAbsentPeriods} Std."
: $"Fehlzeit · {row.TotalAbsentMinutes} Min.";
var status = row.IsUnexcused
? "Unentschuldigt"
: row.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? "Entschuldigt"
: row.FriendlyStatusLabel;
var statusColor = row.IsUnexcused ? "#C62828" : "#2E7D32";
commands.Add(new DrawRectangle(0, y, contentWidth, rowHeight, "#E5E7EB", .3f, fill));
commands.Add(new DrawStringEx(dateColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, dateColumnWidth,
row.Date.ToString("dd.MM.yyyy"), DrawingTextAlignment.AlignLeft, 7.5f * scale,
Color: "#374151"));
commands.Add(new DrawStringEx(extentColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, extentColumnWidth,
extent, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: "#374151"));
commands.Add(new DrawStringEx(statusColumnX, y + scale * millimeterScale,
rowHeight - scale * millimeterScale, statusColumnWidth,
status, DrawingTextAlignment.AlignLeft, 7.5f * scale, Color: statusColor,
Bold: row.IsUnexcused));
y += rowHeight;
}
}
return new DrawingValue(commands, y);
}
}
/// <summary>Liest ausschließlich den lokalen WebUntis-Cache. Das Öffnen oder Rendern eines
/// Elternbriefs löst dadurch keinen unerwarteten Netzwerkabruf aus.</summary>
public sealed class StudentAttendanceCalendarService(
WebUntisSettingsService settings,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository registerCache,
IUntisStudentRosterCacheRepository rosterCache)
{
public DrawingValue Build(Student student, DateOnly month) =>
Build(student, new AttendanceCalendarOptions(month, 1));
public DrawingValue Build(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1, float contentHeight = float.PositiveInfinity)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay))
.ToList();
var register = registerCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisForeignClassRegisterEventDto(e.ClassName, e.Date, e.Subject, e.StudentName,
e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text))
.ToList();
return StudentAttendanceCalendarDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), register, contentWidth, millimeterScale,
contentHeight);
}
public DrawingValue BuildAbsenceDayList(Student student, AttendanceCalendarOptions options, float contentWidth = 170,
float millimeterScale = 1)
{
var className = settings.HomeroomClassName;
if (string.IsNullOrWhiteSpace(className)) return new DrawingValue([], 0);
var first = options.NormalizedStartMonth;
var last = first.AddMonths(options.NormalizedMonthCount).AddDays(-1);
var matchName = $"{student.FirstName} {student.LastName}";
var rosterName = rosterCache.GetByClass(className)
.FirstOrDefault(r => UntisNameMatching.NamesMatch(r.DisplayName, matchName))?.DisplayName
?? matchName;
var start = first.Year * 10000 + first.Month * 100 + first.Day;
var end = last.Year * 10000 + last.Month * 100 + last.Day;
var absences = absenceCache.GetByClassAndRange(className, start, end)
.Select(e => new UntisClassAbsenceEntryDto(e.StudentName, e.ExternKey, e.ClassName, e.Date,
e.AbsentPeriods, e.AbsentMinutes, e.TeacherUsernames, e.Subject, e.AbsenceReason, e.Note,
e.EntryId, e.HandledOn, e.Counts, e.ExcuseNote, e.PeriodNumber, e.Status, e.CountsAsFullDay));
return StudentAbsenceDayListDrawingBuilder.Build(rosterName, options,
ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences), contentWidth, millimeterScale);
}
}
@@ -0,0 +1,90 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Groups;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Services;
/// <summary>Führt einen der vier bestehenden WebUntis-Abgleiche (unverändert, über ihre bestehenden
/// Dialoge) aus und vermerkt das Ergebnis im <see cref="UntisHubService"/> - genutzt sowohl vom
/// Untis-Hub-Dialog ("Jetzt prüfen" je Zeile) als auch von den WebUntis-Menüpunkten in
/// <c>MainWindow</c>, damit beide Einstiegspunkte denselben Fälligkeitsstand pflegen.</summary>
public static class UntisHubActions
{
public static async Task RunFehlzeitenAsync(Window owner, LearningGroup group, UntisHubJobKind kind,
DateOnly start, DateOnly end, UntisHubService hub)
{
var vm = new WebUntisLessonAbsenceComparisonViewModel(group,
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>())
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(kind, group.Id, vm.Status);
}
public static async Task RunKlassenbuchAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisDocumentationComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IDocumentationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisDocumentationComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Klassenbuchabgleich, null, vm.Status);
}
public static async Task RunHausaufgabenAsync(Window owner, UntisHubService hub)
{
var vm = new WebUntisHomeworkComparisonViewModel(
App.Services.GetRequiredService<WebUntisIntegrationService>(),
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<ISubjectRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<SchoolYearService>());
var loaded = TrackLoad(vm, v => v.Busy);
await new WebUntisHomeworkComparisonDialog { DataContext = vm }.ShowDialog(owner);
if (loaded()) hub.RecordRun(UntisHubJobKind.Hausaufgabenabgleich, null, vm.Status);
}
public static async Task RunOffenePeriodsAsync(Window owner, UntisHubService hub)
{
var dialog = new OpenUntisPeriodsDialog();
await dialog.ShowDialog(owner);
hub.RecordRun(UntisHubJobKind.OffenePeriods, null, dialog.LastStatus);
}
/// <summary>Erkennt nicht-invasiv (ohne die Vergleichs-ViewModels zu ändern), ob im Dialog
/// tatsächlich ein Ladeversuch stattfand: <c>Busy</c> wechselt in <c>Load()</c> immer erst auf
/// true und im <c>finally</c>-Block zurück auf false, egal ob erfolgreich oder mit Fehler
/// abgebrochen - genau dieser Übergang wird hier beobachtet.</summary>
private static Func<bool> TrackLoad<T>(T vm, Func<T, bool> isBusy) where T : ObservableObject
{
var loaded = false;
var wasBusy = false;
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName != "Busy") return;
var busy = isBusy(vm);
if (wasBusy && !busy) loaded = true;
wasBusy = busy;
};
return () => loaded;
}
}
@@ -0,0 +1,115 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public enum UntisHubDueState { Ok, Due, Overdue }
/// <summary>Eine Zeile im Untis-Hub: eine Job-Instanz (Fehlzeiten-Kadenz je Lerngruppe, oder einer
/// der drei dashboard-weiten Jobs) mit ihrer aktuellen Fälligkeit.</summary>
public sealed record UntisHubJobRow(
UntisHubJobKind Kind, Guid? GroupId, string GroupName,
DateTime? LastRunAt, string? LastResultSummary, UntisHubDueState DueState, string DueLabel);
/// <summary>
/// Zeigt, welche der bestehenden, rein manuell ausgelösten WebUntis-Abgleiche (Fehlzeiten pro
/// Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) fällig sind - ohne selbst
/// WebUntis anzufragen (Nutzer-Feedback: "nicht bei WebUntis auffallen", siehe
/// <see cref="UntisReportCacheService"/>). Die eigentlichen Abgleiche laufen weiterhin über die
/// bestehenden Vergleichsdialoge (siehe <see cref="UntisHubActions"/>); dieser Service verwaltet
/// nur die Fälligkeits-Zeitstempel dazu.
/// </summary>
public sealed class UntisHubService(
IGroupRepository groups, IUntisHubJobStateRepository jobStates, SchoolYearService schoolYears)
{
private static readonly TimeSpan FehlzeitenKurzDue = TimeSpan.FromDays(14);
private static readonly TimeSpan FehlzeitenKurzOverdue = TimeSpan.FromDays(21);
private static readonly TimeSpan FehlzeitenLangDue = TimeSpan.FromDays(60);
private static readonly TimeSpan FehlzeitenLangOverdue = TimeSpan.FromDays(90);
private static readonly TimeSpan GlobalDue = TimeSpan.FromDays(14);
private static readonly TimeSpan GlobalOverdue = TimeSpan.FromDays(21);
private static readonly (UntisHubJobKind Kind, string Label)[] GlobalJobs =
[
(UntisHubJobKind.OffenePeriods, "Offene Stunden"),
(UntisHubJobKind.Klassenbuchabgleich, "Klassenbuchabgleich"),
(UntisHubJobKind.Hausaufgabenabgleich, "Hausaufgabenabgleich"),
];
public List<UntisHubJobRow> GetRows()
{
var eligibleGroups = groups.GetBySchoolYear(schoolYears.CurrentSchoolYear())
.Where(g => g.WebUntisLessonId is not null && !g.ExcludedFromUntisHub)
.OrderBy(g => g.Name)
.ToList();
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
}
/// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
{
var now = DateTime.UtcNow;
Save(kind, groupId, now, summary);
if (kind == UntisHubJobKind.FehlzeitenLang)
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
}
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
jobStates.Save(new UntisHubJobState
{
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
});
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
/// <see cref="UntisReportCacheService.Plan"/>): aus den fälligkeitsrelevanten Lerngruppen und den
/// zuletzt gespeicherten Job-Zuständen wird die vollständige Hub-Zeilenliste gebaut - zwei
/// Fehlzeiten-Zeilen je Gruppe plus die drei dashboard-weiten Zeilen.
public static List<UntisHubJobRow> BuildRows(
IReadOnlyList<LearningGroup> eligibleGroups, IReadOnlyList<UntisHubJobState> states, DateTime utcNow)
{
// Nach Sync-Aktivierung von UntisHubJobState (siehe TODO.md) können zwei Geräte, die
// denselben, noch nie gelaufenen Job unabhängig voneinander zum ersten Mal ausführen,
// bevor sie sich gegenseitig gesehen haben, kurzzeitig zwei Datensätze für dasselbe
// (Kind, GroupId) anlegen - hier den zuletzt gelaufenen wählen statt einen beliebigen.
UntisHubJobState? State(UntisHubJobKind kind, Guid? groupId) =>
states.Where(s => s.Kind == kind && s.GroupId == groupId)
.OrderByDescending(s => s.LastRunAt)
.FirstOrDefault();
var rows = new List<UntisHubJobRow>();
foreach (var group in eligibleGroups)
{
rows.Add(Row(UntisHubJobKind.FehlzeitenKurz, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenKurz, group.Id), FehlzeitenKurzDue, FehlzeitenKurzOverdue, utcNow));
rows.Add(Row(UntisHubJobKind.FehlzeitenLang, group.Id, group.Name,
State(UntisHubJobKind.FehlzeitenLang, group.Id), FehlzeitenLangDue, FehlzeitenLangOverdue, utcNow));
}
foreach (var (kind, label) in GlobalJobs)
rows.Add(Row(kind, null, label, State(kind, null), GlobalDue, GlobalOverdue, utcNow));
return rows;
}
private static UntisHubJobRow Row(UntisHubJobKind kind, Guid? groupId, string groupName,
UntisHubJobState? state, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
var (dueState, dueLabel) = DueStatus(state?.LastRunAt, due, overdue, utcNow);
return new UntisHubJobRow(kind, groupId, groupName, state?.LastRunAt, state?.LastResultSummary,
dueState, dueLabel);
}
private static (UntisHubDueState, string) DueStatus(
DateTime? lastRunAt, TimeSpan due, TimeSpan overdue, DateTime utcNow)
{
if (lastRunAt is null) return (UntisHubDueState.Overdue, "noch nie geprüft");
var age = utcNow - lastRunAt.Value;
var days = (int)age.TotalDays;
if (age >= overdue) return (UntisHubDueState.Overdue, $"fällig seit {days} Tagen");
if (age >= due) return (UntisHubDueState.Due, $"fällig seit {days} Tagen");
return (UntisHubDueState.Ok, days == 0 ? "gerade eben geprüft" : $"vor {days} Tag(en) geprüft");
}
}
@@ -0,0 +1,44 @@
using LehrerApp.Core.Importing;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
/// </summary>
public static class UntisLessonAbsenceHelper
{
private const int FullLessonMinutes = 45;
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
{
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
}
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
public static int? StudentExternKey(Student student)
{
student.ExternalIds ??= [];
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
&& int.TryParse(value, out var key) ? key : null;
}
}
+23 -6
View File
@@ -65,9 +65,9 @@ public class UntisSyncService : IDisposable
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
public async Task PollAsync()
public async Task PollAsync(CancellationToken cancellationToken = default)
{
if (!await _gate.WaitAsync(0)) return;
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
try
{
var url = _settings.GetIcalUrl();
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -88,7 +88,7 @@ public class UntisSyncService : IDisposable
UntisPollResult result;
try
{
result = ProcessIcsText(icsText);
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
var savedSubstitutionCount = 0;
foreach (var candidate in diffResult.SubstitutionsToSave)
{
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
if (existing is not null) candidate.Id = existing.Id;
// Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
// Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
// Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
if (existing is not null)
{
candidate.Id = existing.Id;
if (SubstitutionContentEquals(existing, candidate)) continue;
}
_substitutions.Save(candidate);
savedSubstitutionCount++;
}
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
return new UntisPollResult(events.Count, savedSubstitutionCount);
}
private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
left.Date == right.Date && left.Kind == right.Kind &&
left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
left.Notes == right.Notes && left.ExternalId == right.ExternalId;
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
@@ -5,6 +5,7 @@ namespace LehrerApp.Desktop.Services;
public sealed class WebUntisIntegrationException(string message) : Exception(message);
public sealed record UntisSchoolYearDto(int UntisId, string Name, int StartDate, int EndDate);
public sealed record UntisHolidayDto(int UntisId, string Name, string? LongName, int StartDate, int EndDate);
public sealed record UntisClassDto(int UntisId, string Name, string? LongName);
public sealed record UntisTeacherDto(int UntisId, string Name, string? ForeName, string? LongName, string? Title,
bool Active, IReadOnlyList<int> DepartmentUntisIds)
@@ -81,6 +82,10 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
async client => (IReadOnlyList<UntisSchoolYearDto>)(await client.GetSchoolYearsAsync(token))
.Select(x => new UntisSchoolYearDto(x.UntisId, x.Name, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisHolidayDto>> GetHolidaysAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisHolidayDto>)(await client.GetHolidaysAsync(token))
.Select(x => new UntisHolidayDto(x.UntisId, x.Name, x.LongName, x.StartDate, x.EndDate)).ToList(), token);
public Task<IReadOnlyList<UntisClassDto>> GetClassesAsync(int schoolYearId, CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisClassDto>)(await client.GetClassesAsync(schoolYearId, token))
.Select(x => new UntisClassDto(x.UntisId, x.Name, x.LongName)).ToList(), token);
@@ -200,6 +205,13 @@ public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettings
};
}
public Task<UntisOpenPeriodsMeta> GetOpenPeriodsMetaAsync(int schoolYearId, CancellationToken token = default) =>
ExecuteAsync(client => client.GetOpenPeriodsMetaAsync(schoolYearId, token), token);
public Task<IReadOnlyList<UntisOpenPeriod>> GetOpenPeriodsAsync(int schoolYearId, int? teacherId,
int? classId, DateOnly start, DateOnly end, CancellationToken token = default) =>
ExecuteAsync(client => client.GetOpenPeriodsAsync(schoolYearId, teacherId, classId, start, end, token), token);
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
@@ -13,10 +13,17 @@ internal class WebUntisSettingsConfig
public int? TeacherUntisId { get; set; }
public int? HomeroomClassUntisId { get; set; }
public string? HomeroomClassName { get; set; }
public List<CachedUntisHoliday>? CachedHolidays { get; set; }
public DateTime? HolidaysFetchedAt { get; set; }
}
public sealed record WebUntisCredentials(string School, string Host, string Username, string Password);
/// <summary>Ferienzeitraum aus WebUntis' <c>getHolidays</c>-Bericht, hier auf die für die
/// Fehlquoten-Berechnung ("Klassenlehrer"-Feature) relevanten Felder reduziert. Kein Geheimnis
/// (anders als iCal-URL/API-Zugangsdaten in dieser Datei), deshalb unverschlüsselt gecacht.</summary>
public sealed record CachedUntisHoliday(string Name, DateOnly Start, DateOnly End);
/// <summary>
/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie
/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die
@@ -115,6 +122,16 @@ public class WebUntisSettingsService
Save();
}
public DateTime? HolidaysFetchedAt => _config.HolidaysFetchedAt;
public IReadOnlyList<CachedUntisHoliday>? GetCachedHolidays() => _config.CachedHolidays;
public void SetCachedHolidays(IReadOnlyList<CachedUntisHoliday> holidays, DateTime at)
{
_config.CachedHolidays = holidays.ToList();
_config.HolidaysFetchedAt = at;
Save();
}
private byte[] GenerateAndSaveKey()
{
var key = SyncCrypto.GenerateKey();
@@ -0,0 +1,106 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>Die sieben Dashboard-Kacheln, die tatsächlich "wo muss ich reagieren" beantworten
/// (siehe <see cref="DashboardViewModel.AttentionCount"/>), zusammengefasst zu einer Karte mit
/// Filter-Chips statt sieben eigenen Sichtbarkeits-Schaltern. Reihenfolge hier ist die Reihenfolge
/// der Gruppen in der zusammengefassten Ansicht (siehe DashboardViewModel.RebuildAttention) — sie
/// übernimmt bewusst die frühere Sortierung aus DashboardSettingsService.DefaultCardOrder, damit
/// sich nichts, was Nutzer bereits kennen, unvorhersehbar umsortiert.
/// "Klausurwochen" (examload) bleibt bewusst außen vor: die Einträge dort sind Wochen-Aggregate,
/// keine Einzelvorgänge, die sich abhaken oder anklicken lassen — sie passen nicht ins Item-Modell.</summary>
public enum AttentionKind { MissingTeachingTime, Excuse, Correction, Unplanned, Alert, Attendance, SupportPlan }
/// <summary>Eine Inline-Aktion auf einem Handlungsbedarf-Eintrag (z.B. "Entschuldigt"/"Unentschuldigt",
/// "Erfassen"). Verwendet ICommand statt Action, damit die bereits vorhandenen generierten
/// RelayCommands (OpenExcuseItem.MarkExcusedCommand, DashboardViewModel.AddMissingTeachingTimeCommand)
/// direkt weiterverwendet werden können, statt sie in einen zweiten Delegate-Typ zu verpacken.</summary>
public sealed record AttentionAction(string Label, ICommand Command, object? Parameter = null);
/// <summary>Gemeinsame Projektion für die sieben Handlungsbedarf-Arten. Trägt nur Anzeigedaten —
/// die eigentliche Beschaffung bleibt unverändert in den jeweiligen DashboardViewModel.LoadXxx-
/// Methoden; sie erzeugen am Ende ein AttentionItem statt (wie vorher) ihre eigene Item-Klasse in
/// ihre eigene ObservableCollection einzutragen.</summary>
public sealed class AttentionItem
{
public AttentionKind Kind { get; }
public string Title { get; }
public string Subtitle { get; }
/// <summary>Kurztext rechts neben dem Titel — Fehlzeitenquote, Alert-Kind-Label. Meist leer.</summary>
public string TrailingText { get; }
public string DateDisplay { get; }
public bool IsOverdue { get; }
public AlertSeverity? Severity { get; }
/// <summary>Nur bei Korrekturen gesetzt (0100); steuert die ProgressBar. Eigenes HasProgress statt
/// Nullability, damit die ProgressBar-Bindung ein einfaches int (kompatibel mit Value:double) bleibt.</summary>
public int ProgressPercent { get; }
public bool HasProgress { get; }
/// <summary>Text unter der ProgressBar, z.B. "3 von 5 Arbeiten bewertet". Nur bei Korrekturen gesetzt.</summary>
public string ProgressLabel { get; }
/// <summary>Färbt TrailingText wie die frühere Fehlzeiten-Warnung (fest "Foreground=Red") ein —
/// nur bei Attendance gesetzt, weil ein AttentionItem dieser Art per Konstruktion nur entsteht,
/// wenn die Fehlzeitenquote den Schwellenwert bereits überschreitet.</summary>
public bool IsWarningTrailing { get; }
public IReadOnlyList<AttentionAction> Actions { get; }
public Action? Navigate { get; }
public AttentionItem(AttentionKind kind, string title, string subtitle = "", string trailingText = "",
string dateDisplay = "", bool isOverdue = false, AlertSeverity? severity = null,
int progressPercent = 0, string progressLabel = "", bool hasProgress = false,
bool isWarningTrailing = false, IReadOnlyList<AttentionAction>? actions = null, Action? navigate = null)
{
Kind = kind;
Title = title;
Subtitle = subtitle;
TrailingText = trailingText;
DateDisplay = dateDisplay;
IsOverdue = isOverdue;
Severity = severity;
ProgressPercent = progressPercent;
ProgressLabel = progressLabel;
HasProgress = hasProgress;
IsWarningTrailing = isWarningTrailing;
Actions = actions ?? [];
Navigate = navigate;
}
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
public bool HasTrailingText => !string.IsNullOrWhiteSpace(TrailingText);
public bool HasDate => !string.IsNullOrWhiteSpace(DateDisplay);
public bool HasActions => Actions.Count > 0;
public bool HasSeverity => Severity is not null;
public bool IsHighSeverity => Severity == AlertSeverity.High;
public bool IsMediumSeverity => Severity == AlertSeverity.Medium;
}
/// <summary>Eine Kind-Gruppe innerhalb der zusammengefassten Handlungsbedarf-Liste. Items behalten
/// die Sortierung, die ihre jeweilige LoadXxx-Methode schon immer produziert hat (z.B. Fehlzeiten
/// nach Quote absteigend, Korrekturen nach Datum) — ein zweites, generisches "Importance"-Sortieren
/// quer über alle Arten würde diese bewusst gewählten Reihenfolgen wieder zerstören.</summary>
public sealed class AttentionGroup(AttentionKind kind, string header, IReadOnlyList<AttentionItem> items)
{
public AttentionKind Kind { get; } = kind;
public string Header { get; } = header;
public IReadOnlyList<AttentionItem> Items { get; } = items;
public int Count => Items.Count;
}
/// <summary>Ein Filter-Chip für eine Handlungsbedarf-Art in der zusammengefassten Karte — ersetzt
/// die frühere Sichtbarkeit je Einzelkachel (siehe DashboardViewModel.LoadAttentionFilters).</summary>
public partial class AttentionFilterOption : ObservableObject
{
[ObservableProperty] private bool _isActive = true;
public AttentionKind Kind { get; }
public string Label { get; }
public Action? OnChanged { get; set; }
public AttentionFilterOption(AttentionKind kind, string label)
{
Kind = kind;
Label = label;
}
partial void OnIsActiveChanged(bool value) => OnChanged?.Invoke();
}
@@ -0,0 +1,249 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
/// "Vorgänge"-Tab der Klassenlehreransicht: Fallmappen für konkrete, laufende Probleme mit einer/
/// einem oder mehreren Schüler*innen der Klasse, die Dokumentation und (eingefrorene) WebUntis-
/// Klassenbucheinträge bündeln. Löst wie <see cref="ClassTeacherDetailsViewModel"/> die Untis-
/// Roster-Namen der Klasse auf lokale <see cref="Student"/>-Datensätze auf (bewusst ein eigener,
/// nicht geteilter Abgleich — dasselbe kleine Muster steckt schon zweimal im Code, siehe
/// <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>, eine gemeinsame Abstraktion dafür ist
/// hier nicht Teil der Aufgabe).
/// </summary>
public partial class ClassTeacherCasesViewModel : ObservableObject
{
private readonly IVorgangRepository _vorgaenge;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly UntisReportCacheService _cache;
private string _className = "";
private List<StudentOption> _rosterStudentOptions = [];
public ObservableCollection<VorgangItem> Cases { get; } = [];
[ObservableProperty] private bool _onlyOpen = true;
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _status = "";
public bool HasCases => Cases.Count > 0;
public Func<List<StudentOption>, Vorgang?, Task<Vorgang?>>? OnEditVorgang { get; set; }
public Func<VorgangItem, Task<bool>>? OnConfirmDeleteVorgang { get; set; }
public Func<Guid, List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditDocumentation { get; set; }
public ClassTeacherCasesViewModel(IVorgangRepository vorgaenge, IDocumentationRepository documentation,
IStudentRepository students, UntisReportCacheService cache)
{
_vorgaenge = vorgaenge;
_documentation = documentation;
_students = students;
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
Cases.Clear();
Status = "Wird geladen…";
NotifyState();
_ = LoadInternal();
}
partial void OnOnlyOpenChanged(bool value) => _ = LoadInternal();
[RelayCommand]
private Task Refresh() => LoadInternal();
private async Task LoadInternal()
{
if (string.IsNullOrWhiteSpace(_className)) return;
// IsRevealed übersteht den Neuaufbau der Liste nicht automatisch (neue VorgangItem-Instanzen
// pro Load) - deshalb hier gemerkt und danach wiederhergestellt, sonst klappt eine gerade
// aufgeklappte Karte bei jeder Verknüpfungs-/Status-Aktion (die intern neu lädt) wieder zu.
var revealedIds = Cases.Where(c => c.IsRevealed).Select(c => c.Model.Id).ToHashSet();
Busy = true;
Cases.Clear();
NotifyState();
try
{
var roster = await _cache.GetStudentRosterAsync(_className);
var localStudents = _students.GetAll();
var matches = roster
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
.DistinctBy(x => x.StudentId)
.ToList();
_rosterStudentOptions = matches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var rosterIds = matches.Select(m => m.StudentId).ToHashSet();
var nameById = matches.ToDictionary(m => m.StudentId, m => m.DisplayName);
var relevant = _vorgaenge.GetAll().Where(v => v.StudentIds.Any(rosterIds.Contains));
if (OnlyOpen) relevant = relevant.Where(v => v.Status == VorgangStatus.Open);
var allDocs = _documentation.GetAll();
foreach (var vorgang in relevant.OrderByDescending(v => v.UpdatedAt))
{
var names = vorgang.StudentIds.Select(id => nameById.GetValueOrDefault(id, ""))
.Where(n => n != "");
var item = new VorgangItem(vorgang, string.Join(", ", names), LinkDocumentation,
UnlinkDocumentation, RemoveClassRegisterEntry, EditDocumentation)
{ IsRevealed = revealedIds.Contains(vorgang.Id) };
var linkedIds = vorgang.DocumentationIds.ToHashSet();
foreach (var doc in allDocs.Where(d => linkedIds.Contains(d.Id)))
item.LinkedDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var doc in FilterAvailableDocumentation(allDocs, vorgang))
item.AvailableDocumentation.Add(new DocumentationItem(doc, nameById.GetValueOrDefault(doc.StudentId, "")));
foreach (var entry in vorgang.ClassRegisterEntries.OrderByDescending(e => e.Date))
item.ClassRegisterRows.Add(new VorgangClassRegisterEntryRow(entry));
Cases.Add(item);
}
Status = OnlyOpen ? $"{Cases.Count} offene Vorgänge" : $"{Cases.Count} Vorgänge";
}
finally { Busy = false; NotifyState(); }
}
private void NotifyState() => OnPropertyChanged(nameof(HasCases));
[RelayCommand]
private async Task AddVorgang()
{
if (OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, null);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task EditVorgang(VorgangItem? item)
{
if (item is null || OnEditVorgang is null) return;
var result = await OnEditVorgang(_rosterStudentOptions, item.Model);
if (result is null) return;
_vorgaenge.Save(result);
await LoadInternal();
}
[RelayCommand]
private async Task ToggleStatus(VorgangItem? item)
{
if (item is null) return;
item.Model.Status = item.Model.Status == VorgangStatus.Open ? VorgangStatus.Closed : VorgangStatus.Open;
item.Model.ClosedAt = item.Model.Status == VorgangStatus.Closed ? DateTime.UtcNow : null;
_vorgaenge.Save(item.Model);
await LoadInternal();
}
[RelayCommand]
private async Task DeleteVorgang(VorgangItem? item)
{
if (item is null) return;
if (OnConfirmDeleteVorgang is not null && !await OnConfirmDeleteVorgang(item)) return;
_vorgaenge.Delete(item.Model.Id);
await LoadInternal();
}
[RelayCommand]
private async Task CreateAndLinkDocumentation(VorgangItem? item)
{
if (item is null || OnEditDocumentation is null) return;
var studentOptions = _rosterStudentOptions.Where(s => item.Model.StudentIds.Contains(s.Id)).ToList();
var defaultStudentId = item.Model.StudentIds.FirstOrDefault();
var result = await OnEditDocumentation(defaultStudentId, studentOptions, null);
if (result is null) return;
_documentation.Save(result);
item.Model.DocumentationIds.Add(result.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
/// Öffnet eine verknüpfte oder noch verknüpfbare Dokumentation im vollen Bearbeiten-Dialog, ohne
/// den Vorgänge-Tab zu verlassen (Nutzer-Feedback: bisher musste man dafür zum Nachlesen in den
/// Schülerdatensatz wechseln). Reine Bearbeitung der Dokumentation selbst — welche Vorgänge sie
/// verlinken, ändert sich dadurch nicht.
private async Task EditDocumentation(DocumentationItem doc)
{
if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(doc.Model.StudentId, _rosterStudentOptions, doc.Model);
if (result is null) return;
_documentation.Save(result);
await LoadInternal();
}
/// Dokumentation der Vorgangs-Schüler*innen, die noch nicht mit diesem Vorgang verknüpft ist —
/// reine, ohne Repository-Zugriff testbare Filterlogik (gleiches Muster wie
/// <see cref="ClassTeacherRosterRow.Build"/>).
public static List<Documentation> FilterAvailableDocumentation(IReadOnlyList<Documentation> allDocs, Vorgang vorgang)
{
var linkedIds = vorgang.DocumentationIds.ToHashSet();
return allDocs.Where(d => vorgang.StudentIds.Contains(d.StudentId) && !linkedIds.Contains(d.Id)).ToList();
}
private async Task LinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Add(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task UnlinkDocumentation(VorgangItem item, DocumentationItem doc)
{
item.Model.DocumentationIds.Remove(doc.Model.Id);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
private async Task RemoveClassRegisterEntry(VorgangItem item, VorgangClassRegisterEntryRow row)
{
item.Model.ClassRegisterEntries.Remove(row.Model);
_vorgaenge.Save(item.Model);
await LoadInternal();
}
// ── Anheften eines Klassenbuch-Eintrags aus dem Klassenbuch-Tab (ClassTeacherDetailsViewModel,
// per Konstruktor-Injection dieser Instanz — kein Func-Hook, da hier keine UI im Spiel ist,
// nur Zugriff auf schon geladene Daten dieser Geschwister-ViewModel-Instanz) ────────────────
public IReadOnlyList<StudentOption> RosterStudentOptions => _rosterStudentOptions;
/// Offene Vorgänge, an die sich ein Klassenbuch-Eintrag für diese/n Schüler*in anheften lässt
/// (Namensabgleich wie beim restlichen Klassenlehrer-Bereich, siehe UntisNameMatching).
public List<VorgangItem> OpenCasesForStudent(string untisDisplayName)
{
var match = _rosterStudentOptions.FirstOrDefault(s =>
UntisNameMatching.NamesMatch(s.Name, untisDisplayName));
if (match is null) return [];
return Cases.Where(c => c.IsOpen && c.Model.StudentIds.Contains(match.Id)).ToList();
}
public async Task PinClassRegisterEntryAsync(Guid vorgangId, VorgangClassRegisterEntry entry)
{
var vorgang = _vorgaenge.GetById(vorgangId);
if (vorgang is null) return;
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
}
public async Task<Vorgang> CreateAndPinAsync(string title, Guid studentId, VorgangClassRegisterEntry entry)
{
var vorgang = new Vorgang { Title = title, StudentIds = [studentId] };
vorgang.ClassRegisterEntries.Add(entry);
_vorgaenge.Save(vorgang);
await LoadInternal();
return vorgang;
}
}
@@ -1,7 +1,10 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
@@ -116,6 +119,24 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
public enum ClassTeacherCalendarEventKind { Late, Absent, Unexcused, Excused, ClassRegister, Homework }
/// <summary>Kompakter, farblich codierter Marker in der Klassenlehrer-Monatsansicht.</summary>
public sealed record ClassTeacherCalendarEvent(string StudentName, string Code, string Label,
string ColorHex, ClassTeacherCalendarEventKind Kind)
{
public string Tooltip => $"{StudentName}: {Label}";
}
/// <summary>Ein Werktag im 5-Spalten-Monatsraster; Tage außerhalb des Monats sind Platzhalter.</summary>
public sealed record ClassTeacherCalendarDay(DateOnly? Date, IReadOnlyList<ClassTeacherCalendarEvent> Events)
{
public bool IsPlaceholder => Date is null;
public string DayLabel => Date?.ToString("dd.") ?? "";
public string WeekdayLabel => Date?.ToString("ddd", System.Globalization.CultureInfo.GetCultureInfo("de-DE")) ?? "";
public bool HasEvents => Events.Count > 0;
}
/// <summary>
/// Details-Ansicht des Klassenlehrer-Bereichs: Klassenbucheinträge, die andere Lehrkräfte zu
/// Schülern der Klasse angelegt haben (WebUntis "-alle-"-Bericht, gefiltert auf fremde statt der
@@ -130,29 +151,71 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly UntisReportCacheService _cache;
private readonly IDocumentationRepository _documentation;
private readonly IStudentRepository _students;
private readonly ClassTeacherCasesViewModel _cases;
private string _className = "";
private DateOnly _loadedStart;
private DateOnly _loadedEnd;
/// Schüler*innen der Klasse, per Namensabgleich (<see cref="UntisNameMatching"/>) den lokalen
/// <see cref="Student"/>-Datensätzen zugeordnet — Grundlage für die "Eigene Dokumentation"-Ansicht,
/// die es (anders als der WebUntis-Klassenbuchbericht) nur lokal gibt. Nach jedem <see cref="LoadInternal"/>
/// neu aufgebaut.
private List<(Guid StudentId, string DisplayName)> _rosterMatches = [];
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
public ObservableCollection<DocumentationItem> OwnDocumentationEntries { get; } = [];
public ObservableCollection<ClassTeacherCalendarDay> CalendarDays { get; } = [];
[ObservableProperty] private DateTimeOffset _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset _endDate = DateTimeOffset.Now;
/// Für das Kontextmenü "→ An Vorgang anheften" (DataGrid.SelectedItem, zweigleisig gebunden).
[ObservableProperty] private ClassTeacherClassRegisterRow? _selectedEntry;
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-6);
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
[ObservableProperty] private string _status = "Zeitraum wählen und laden.";
[ObservableProperty] private bool _busy;
/// Von der Übersicht gesetzt (Klick auf eine Roster-Zeile) - leer zeigt alle Schüler*innen.
[ObservableProperty] private string _studentFilter = "";
[ObservableProperty] private int _quickRangeIndex = 1;
[ObservableProperty] private bool _showMonthlyCalendar;
[ObservableProperty] private bool _includeHomeworkInCalendar;
[ObservableProperty] private DateOnly _calendarMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1);
/// Umschalter Klassenbuch (WebUntis, andere Lehrkräfte) ↔ eigene Dokumentation.
[ObservableProperty] private bool _showOwnDocumentation;
[ObservableProperty] private int _ownDocumentationCount;
[ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount;
public Func<List<StudentOption>, Documentation?, Task<Documentation?>>? OnEditOwnDocumentation { get; set; }
public Func<DocumentationItem, Task<bool>>? OnConfirmDeleteOwnDocumentation { get; set; }
/// Zeigt die "→ Vorgang"-Auswahl für eine Klassenbuchzeile: bestehenden offenen Vorgang wählen
/// oder einen neuen Titel eingeben. Liefert null bei Abbruch.
public Func<ClassTeacherClassRegisterRow, Task<PinToVorgangChoice?>>? OnPickVorgangForPin { get; set; }
public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
public int UntisCriticalCount => Entries.Count(e => e.IsDangerStatus);
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
public ClassTeacherDetailsViewModel(UntisReportCacheService cache, IDocumentationRepository documentation,
IStudentRepository students, ClassTeacherCasesViewModel cases)
{
_cache = cache;
_documentation = documentation;
_students = students;
_cases = cases;
}
public void Initialize(string className)
@@ -161,13 +224,21 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
StudentFilter = "";
Entries.Clear();
AbsenceEntries.Clear();
CalendarDays.Clear();
CategoryAggregates.Clear();
OwnDocumentationEntries.Clear();
_rosterMatches = [];
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnShowOwnDocumentationChanged(bool value) => OnPropertyChanged(nameof(ShowCategoryAggregates));
partial void OnShowMonthlyCalendarChanged(bool value) => OnPropertyChanged(nameof(ShowAbsenceListEmpty));
partial void OnIncludeHomeworkInCalendarChanged(bool value) => BuildCalendar();
partial void OnCalendarMonthChanged(DateOnly value) => OnPropertyChanged(nameof(CalendarMonthLabel));
partial void OnQuickRangeIndexChanged(int value)
{
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
@@ -178,6 +249,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand]
private Task Load() => LoadInternal(forceRefresh: false);
[RelayCommand] private void ShowUntisRegister() => ShowOwnDocumentation = false;
[RelayCommand] private void ShowOwnDocs() => ShowOwnDocumentation = true;
/// Umgeht bewusst die Stunden-Sperre von <see cref="UntisReportCacheService"/> — für den Fall,
/// dass man sicher weiß, dass sich seit dem letzten automatischen Abruf etwas geändert hat.
[RelayCommand]
@@ -186,6 +260,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
[RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
[RelayCommand] private void ShowAbsenceList() => ShowMonthlyCalendar = false;
[RelayCommand]
private async Task ShowCalendar()
{
ShowMonthlyCalendar = true;
await LoadCalendarMonth(forceRefresh: false);
}
[RelayCommand]
private async Task PreviousCalendarMonth()
{
CalendarMonth = CalendarMonth.AddMonths(-1);
await LoadCalendarMonth(forceRefresh: false);
}
[RelayCommand]
private async Task NextCalendarMonth()
{
CalendarMonth = CalendarMonth.AddMonths(1);
await LoadCalendarMonth(forceRefresh: false);
}
private Task LoadCalendarMonth(bool forceRefresh)
{
StartDate = new DateTimeOffset(CalendarMonth.ToDateTime(TimeOnly.MinValue));
var end = CalendarMonth.AddMonths(1).AddDays(-1);
EndDate = new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue));
return LoadInternal(forceRefresh);
}
[RelayCommand]
private async Task ClearStudentFilter()
{
@@ -195,17 +300,20 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
private async Task LoadInternal(bool forceRefresh)
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.LocalDateTime);
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
if (string.IsNullOrWhiteSpace(_className)) { Status = "Keine Klasse ausgewählt."; return; }
Busy = true; Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); NotifyListState();
Busy = true;
Entries.Clear(); AbsenceEntries.Clear(); CategoryAggregates.Clear(); OwnDocumentationEntries.Clear();
NotifyListState();
try
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask);
var rosterTask = _cache.GetStudentRosterAsync(_className);
await Task.WhenAll(classRegisterTask, absencesTask, rosterTask);
var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
@@ -223,14 +331,234 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
BuildCalendar();
var localStudents = _students.GetAll();
_rosterMatches = rosterTask.Result
.Select(r => (Roster: r, Student: ClassTeacherOverviewViewModel.MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.Select(x => (StudentId: x.Student!.Id, DisplayName: x.Roster.DisplayName))
.DistinctBy(x => x.StudentId)
.ToList();
_loadedStart = start;
_loadedEnd = end;
LoadOwnDocumentationEntries();
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
$"{AbsenceEntries.Count} Fehlzeiten-Tage, {OwnDocumentationEntries.Count} eigene Dokumentation im Zeitraum.";
NotifyListState();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); }
}
private void BuildCalendar()
{
CalendarDays.Clear();
foreach (var day in BuildCalendarDays(CalendarMonth, AbsenceEntries, Entries, IncludeHomeworkInCalendar))
CalendarDays.Add(day);
}
/// <summary>Baut ein Montag-bis-Freitag-Raster aus den ohnehin geladenen Berichten. Negative
/// Klassenbucheinträge erscheinen als !; Hausaufgaben sind eine bewusst optionale Sonderlage.</summary>
public static IReadOnlyList<ClassTeacherCalendarDay> BuildCalendarDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
{
var first = new DateOnly(month.Year, month.Month, 1);
var last = first.AddMonths(1).AddDays(-1);
var gridStart = first.AddDays(-(((int)first.DayOfWeek + 6) % 7));
var gridEnd = last.AddDays((7 - (int)last.DayOfWeek) % 7);
var result = new List<ClassTeacherCalendarDay>();
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
{
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) continue;
if (date.Month != first.Month)
{
result.Add(new ClassTeacherCalendarDay(null, []));
continue;
}
result.Add(new ClassTeacherCalendarDay(date,
BuildDayEvents(date, absences, registerEntries, includeHomework)));
}
return result;
}
public static IReadOnlyList<ClassTeacherCalendarEvent> BuildDayEvents(DateOnly date,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<ClassTeacherClassRegisterRow> registerEntries, bool includeHomework)
{
var events = new List<ClassTeacherCalendarEvent>();
foreach (var absence in absences.Where(a => a.Date == date).OrderBy(a => a.StudentDisplayName))
{
var (code, label, color, kind) = absence.IsUnexcused
? ("U", "Unentschuldigt gefehlt", "#C62828", ClassTeacherCalendarEventKind.Unexcused)
: absence.IsLate
? ("V", "Verspätet", "#EF6C00", ClassTeacherCalendarEventKind.Late)
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
}
foreach (var entry in registerEntries.Where(e => e.Date == date).OrderBy(e => e.StudentDisplayName))
{
var isHomework = ContainsHomework(entry);
if (isHomework && !includeHomework) continue;
if (!entry.IsDangerStatus && !isHomework) continue;
events.Add(new ClassTeacherCalendarEvent(entry.StudentDisplayName,
isHomework ? "H" : "!", isHomework ? "Hausaufgaben" : "Negativer Klassenbucheintrag",
isHomework ? "#6A1B9A" : "#8E24AA",
isHomework ? ClassTeacherCalendarEventKind.Homework : ClassTeacherCalendarEventKind.ClassRegister));
}
return events;
}
private static bool ContainsHomework(ClassTeacherClassRegisterRow entry) =>
entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true;
/// Baut <see cref="OwnDocumentationEntries"/> aus <see cref="_rosterMatches"/> und dem zuletzt
/// geladenen Zeitraum neu auf — separat von <see cref="LoadInternal"/>, damit Anlegen/Bearbeiten/
/// Löschen eines eigenen Eintrags nicht auch die WebUntis-Berichte neu abruft. Die eigentliche
/// Filter-/Sortierlogik steckt in der reinen, ohne Repository-Zugriff testbaren
/// <see cref="FilterOwnDocumentation"/> — analog zu <see cref="ClassTeacherRosterRow.Build"/>.
private void LoadOwnDocumentationEntries()
{
OwnDocumentationEntries.Clear();
var entries = FilterOwnDocumentation(_documentation.GetAll(), _rosterMatches,
_loadedStart, _loadedEnd, StudentFilter);
foreach (var d in entries)
{
var name = DocumentationStudentDisplay(d, _rosterMatches);
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
}
OwnDocumentationCount = OwnDocumentationEntries.Count;
OwnDocumentationFollowUpCount = entries.Count(d => d.IsDraft);
OwnDocumentationCriticalCount = entries.Count(d =>
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
}
/// Eigene Dokumentation zu Schüler*innen der Klasse im gewählten Zeitraum: nur Einträge
/// (nicht gelöschter) Schüler*innen, die sich per Namensabgleich der WebUntis-Klasse zuordnen
/// ließen (<paramref name="rosterMatches"/>, siehe <see cref="ClassTeacherOverviewViewModel.MatchStudent"/>),
/// mit demselben Schülerfilter wie die WebUntis-Klassenbuchzeilen. Entwürfe ("Nacharbeiten")
/// zuerst, danach neueste zuerst.
public static List<Documentation> FilterOwnDocumentation(IReadOnlyList<Documentation> all,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches,
DateOnly start, DateOnly end, string studentFilter)
{
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
return all
.Where(d => !d.IsDeleted &&
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
d.Date >= start && d.Date <= end)
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
.ToList();
}
private static bool MatchesOwnDocStudentFilter(Documentation d,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
{
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
if (IsUnassignedClassDocumentation(d))
return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
return name is not null && NameContainsSearchTerm(name, studentFilter);
}
private static bool IsUnassignedClassDocumentation(Documentation d) =>
d.StudentId == Guid.Empty && d.GroupId is null;
private static bool NameContainsSearchTerm(string name, string search)
{
if (UntisNameMatching.NamesMatch(name, search)) return true;
var searchKey = UntisNameMatching.NameKey(search);
return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
}
private static string DocumentationStudentDisplay(Documentation d,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
{
if (d.StudentId != Guid.Empty)
return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
?? "Unbekannter Schülerbezug";
return d.Participants.Count > 0
? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
: "Ohne Schülerbezug";
}
[RelayCommand]
private async Task AddOwnDocumentation()
{
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, null);
if (result is null) return;
_documentation.Save(result);
LoadOwnDocumentationEntries();
}
[RelayCommand]
private async Task EditOwnDocumentation(DocumentationItem? item)
{
if (item is null || OnEditOwnDocumentation is null) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, item.Model);
if (result is null) return;
_documentation.Save(result);
LoadOwnDocumentationEntries();
}
[RelayCommand]
private async Task DeleteOwnDocumentation(DocumentationItem? item)
{
if (item is null) return;
if (OnConfirmDeleteOwnDocumentation is not null && !await OnConfirmDeleteOwnDocumentation(item)) return;
_documentation.Delete(item.Model.Id);
LoadOwnDocumentationEntries();
}
/// Für den "→ Vorgang"-Auswahldialog im Code-behind: offene Vorgänge, an die sich diese
/// Klassenbuchzeile anheften lässt (Durchreiche zur Geschwister-ViewModel-Instanz, siehe _cases).
public List<VorgangItem> OpenCasesFor(string studentName) => _cases.OpenCasesForStudent(studentName);
/// Heftet eine WebUntis-Klassenbuchzeile als eingefrorene Kopie an einen (ggf. neuen) Vorgang
/// im "Vorgänge"-Tab (<see cref="ClassTeacherCasesViewModel"/>) an — die Zeile selbst hat keine
/// stabile ID, deshalb Werte-Kopie statt Referenz (siehe VorgangClassRegisterEntry).
[RelayCommand]
private async Task PinToVorgang(ClassTeacherClassRegisterRow? row)
{
if (row is null || OnPickVorgangForPin is null) return;
var choice = await OnPickVorgangForPin(row);
if (choice is null) return;
var entry = BuildClassRegisterSnapshot(row);
if (choice.ExistingVorgangId is { } vorgangId)
await _cases.PinClassRegisterEntryAsync(vorgangId, entry);
else if (!string.IsNullOrWhiteSpace(choice.NewVorgangTitle))
{
var student = _cases.RosterStudentOptions
.FirstOrDefault(s => UntisNameMatching.NamesMatch(s.Name, row.StudentName));
if (student is null) return;
await _cases.CreateAndPinAsync(choice.NewVorgangTitle, student.Id, entry);
}
}
/// Baut die eingefrorene Klassenbuch-Kopie aus einer Zeile — reine, ohne Repository-Zugriff
/// testbare Umwandlung.
public static VorgangClassRegisterEntry BuildClassRegisterSnapshot(ClassTeacherClassRegisterRow row) => new()
{
Date = row.Date, StudentName = row.StudentName, Subject = row.Subject,
TeacherUsername = row.TeacherUsername, CategoryName = row.CategoryName,
CategoryGroup = row.CategoryGroup, Text = row.Text,
};
// WebUntis liefert Namen je nach Bericht in anderer Reihenfolge als der Schülerreport, aus dem
// StudentFilter beim Klick in der Übersicht gesetzt wird (siehe UntisNameMatching) - ein
// exakter String-Vergleich hier ließ die gefilterten Listen fälschlich leer erscheinen.
@@ -247,6 +575,11 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(ShowAbsenceListEmpty));
OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
OnPropertyChanged(nameof(CanAddOwnDocumentation));
OnPropertyChanged(nameof(UntisCriticalCount));
}
}
@@ -14,6 +14,14 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
/// <summary>Auswahlbereich des kompakten Monatskalenders. Ein leerer Schülername steht für die
/// bisherige Klassen-Gesamtansicht.</summary>
public sealed record ClassTeacherCalendarScope(string? StudentName, string DisplayName);
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
@@ -71,31 +79,189 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
};
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt nur
/// Werktage, ohne Ferienkalender — eine bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag.
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
/// Zählt bewusst NUR echte Fehltage (unentschuldigt oder entschuldigt abwesend), keine reinen
/// Verspätungstage mehr — Nutzer-Feedback: die Quote behandelte "10× 5 Min. verspätet" bisher
/// exakt wie "10× ganztägig unentschuldigt gefehlt", obwohl beides für die eigene Reaktion
/// (Elterngespräch vs. Achselzucken) grundverschieden ist. Reine Verspätung landet stattdessen
/// separat in <see cref="YearLateDayCount"/> (gleiche Kategorisierung wie schon länger im
/// Wochentrend, siehe <see cref="ClassTeacherOverviewViewModel.BuildTrend"/>: Unentschuldigt vor
/// Verspätet vor Entschuldigt, überschneidungsfrei).
public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; }
/// Tage, die WebUntis als Verspätung führt (ohne Unentschuldigt-Status) — zählen bewusst nicht
/// in <see cref="YearAbsenceDayCount"/>/<see cref="YearAbsenceRatePercent"/>, tragen aber mit
/// reduziertem Gewicht zu <see cref="PatternScore"/> bei.
public int YearLateDayCount { get; init; }
/// Entschuldigt abwesende Tage (weder unentschuldigt noch nur verspätet) — Kehrwert von
/// <see cref="YearUnexcusedDayCount"/> innerhalb von <see cref="YearAbsenceDayCount"/>.
public int YearExcusedDayCount { get; init; }
/// Klassenbucheinträge anderer Lehrkräfte über das ganze bisherige Schuljahr mit
/// <c>CategoryGroup</c> "Negativ" (Nutzer-Feedback: sollen ebenfalls ins Auffälligkeitsbild
/// einfließen — "wenn die negativen Einträge durch die Decke gehen"). Anders als
/// <see cref="HasRecentClassRegisterEntry"/> (nur letzte 7 Tage, für die "Klassenbuch"-Badges)
/// bewusst über das ganze Jahr, damit sich ein Muster über die Zeit zeigen kann.
public int YearNegativeClassRegisterCount { get; init; }
/// Mindestens ein Klassenbucheintrag mit dem Schlüsselwort "suspendier(t)" im Zeitraum — soll
/// den Score unabhängig von allem anderen sofort deutlich nach oben treiben (Nutzer-Feedback:
/// "Suspendiert sollte direkt hochschießen").
public bool HasSuspensionEntry { get; init; }
/// Gewichtete Summe aus <see cref="ClassRegisterEntryWeight"/> über alle Klassenbucheinträge
/// des Schuljahres — fließt in <see cref="PatternScore"/> ein.
public double ClassRegisterScoreComponent { get; init; }
public int SchoolDaysElapsed { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 && YearAbsenceDayCount > 0;
/// Nutzer-Feedback: nachdem der Nenner zeitweise Ferientage mitzählte (siehe TODO.md), soll die
/// Herkunft der Zahl nachvollziehbar bleiben, ohne dafür die WebUntis-Ferienliste separat
/// nachschlagen zu müssen — deshalb hier sichtbar im Tooltip statt nur intern verrechnet.
public int HolidayWeekdaysExcluded { get; init; }
/// Angenommener erster Unterrichtstag (<see cref="ClassTeacherOverviewViewModel.EstimateTermStart"/>),
/// aus demselben Nachvollziehbarkeits-Grund wie <see cref="HolidayWeekdaysExcluded"/> im Tooltip
/// sichtbar — WebUntis' Ferienkalender deckt die Sommerferien selbst nicht ab (siehe TODO.md),
/// die Korrektur passiert also am Startpunkt, nicht an abgezogenen Tagen mittendrin.
public DateOnly TermStart { get; init; }
public bool HasYearSummary => SchoolDaysElapsed > 0 &&
(YearAbsenceDayCount > 0 || YearLateDayCount > 0 || YearNegativeClassRegisterCount > 0);
public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string YearSummaryLabel => !HasYearSummary ? "" :
YearAbsenceDayCount > 0 ? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn"
: YearLateDayCount > 0 ? $"{YearLateDayCount}× verspätet, keine Fehltage seit Schuljahresbeginn"
: $"{YearNegativeClassRegisterCount} negative Klassenbucheinträge seit Schuljahresbeginn";
public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "");
(YearAbsenceDayCount > 0
? $"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "")
: $"Keine Fehltage seit {TermStart:dd.MM.}") +
(YearLateDayCount > 0 ? $" · {YearLateDayCount} Tage verspätet (zählt nicht zur Quote)" : "") +
(YearNegativeClassRegisterCount > 0 ? $" · {YearNegativeClassRegisterCount} negative Klassenbucheinträge" : "") +
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
private static readonly string[] SuspensionKeywords = ["suspendier"];
/// Auffälligkeits-Score über das ganze bisherige Schuljahr statt nur "heute" (Nutzer-Feedback:
/// wer heute da ist, aber davor 5 Tage unentschuldigt fehlte, verschwand bisher komplett aus der
/// Liste, während der Musterschüler beim ersten Fehltag des Jahres auf Rang eins landete, sobald
/// er der/die einzige "heute Auffällige" war). Reine Summe ohne zeitliche Abklingung — für die
/// vom Nutzer genannten Fälle (Muster über die letzten Tage/Wochen) reicht das, ohne die
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
public double PatternScore { get; init; }
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
public double RegressionScore { get; init; }
public string? RegressionVorgangTitle { get; init; }
private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
private static bool IsNegativeClassRegisterEntry(UntisForeignClassRegisterEventDto e) =>
e.CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true;
private static bool MatchesAnyKeyword(UntisForeignClassRegisterEventDto e, IReadOnlyList<string> keywords) =>
keywords.Any(k =>
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
{
var weight = 0.0;
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
return weight;
}
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
private static readonly string[] LatenessDomainKeywords = ["versp"];
private static readonly string[] ClassbookDomainKeywords =
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
{
var domains = new HashSet<VorgangScoreDomain>();
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Attendance);
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Lateness);
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
domains.Add(VorgangScoreDomain.Classbook);
if (domains.Count == 0)
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
return domains;
}
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
IReadOnlyList<Vorgang> closedVorgaenge)
{
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
foreach (var v in closedVorgaenge)
{
if (v.ClosedAt is not { } closedAt) continue;
var closedDate = DateOnly.FromDateTime(closedAt);
foreach (var domain in MatchDomains(v.Tags))
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
cutoffs[domain] = closedDate;
}
return cutoffs;
}
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
{
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
var classRegister = yearRegisterEntries
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
.Sum(e => ClassRegisterEntryWeight(e, weights));
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
late * weights.LateDayWeight + classRegister;
}
public static IReadOnlyList<ClassTeacherRosterRow> Build(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
IReadOnlyList<UntisForeignClassRegisterEventDto> recentClassRegisterEntries,
DateOnly? today = null,
IReadOnlyList<ClassAbsenceDaySummaryRow>? yearAbsences = null,
int schoolDaysElapsed = 0)
int schoolDaysElapsed = 0,
int holidayWeekdaysExcluded = 0,
DateOnly termStart = default,
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
PatternScoreWeights? weights = null,
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
{
var effectiveWeights = weights ?? new PatternScoreWeights();
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
@@ -103,6 +269,12 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
.ToDictionary(g => g.Key, g => g.First());
var recentByName = recentClassRegisterEntries.GroupBy(e => UntisNameMatching.NameKey(e.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
// Für den Score bewusst über das ganze Schuljahr statt nur die letzten 7 Tage (anders als
// recentClassRegisterEntries oben, das für HasRecentClassRegisterEntry/-Badges weiterhin nur
// die aktuelle Woche abbildet) — fällt auf recentClassRegisterEntries zurück, wenn der
// Aufrufer keine eigene Jahresliste mitgibt (z.B. bestehende Tests).
var yearRegisterByName = (yearClassRegisterEntries ?? recentClassRegisterEntries)
.GroupBy(e => UntisNameMatching.NameKey(e.StudentName)).ToDictionary(g => g.Key, g => g.ToList());
var yearRows = yearAbsences ?? [];
var yearByKey = yearRows.Where(a => a.ExternKey is not null)
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.ToList());
@@ -117,15 +289,47 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? [];
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
var regressionScore = 0.0;
if (mostRecentClosed is not null)
{
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
}
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
registerEntries.Count > 0)
{
TodayAbsence = absence,
HasClassRegisterToday = registerEntries.Any(e => TryDate(e.Date, out var date) && date == referenceDate),
YearAbsenceDayCount = yearEntries.Count,
// Unentschuldigt vor Verspätet vor Entschuldigt, überschneidungsfrei — dieselbe
// Kategorisierung wie im Wochentrend (BuildTrend). Nur Verspätung (nicht auch
// unentschuldigt) fällt bewusst aus YearAbsenceDayCount heraus, siehe dort.
YearAbsenceDayCount = yearEntries.Count(r => r.IsUnexcused || !r.IsLate),
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
YearLateDayCount = yearEntries.Count(r => !r.IsUnexcused && r.IsLate),
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
PatternScore = patternScore,
RegressionScore = regressionScore,
RegressionVorgangTitle = mostRecentClosed?.Title,
SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart,
};
})
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
@@ -157,7 +361,7 @@ public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int U
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
}
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind)
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
{
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
@@ -182,37 +386,60 @@ public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
absenceDays.Where(a => a.IsUnexcused)
// Verspätungen ausgeschlossen: dafür gibt es i.d.R. keine Entschuldigungspflicht, eine
// "Entschuldigung fehlt"-Erinnerung wäre hier gegenstandslos. Wiederholte Verspätungen
// laufen stattdessen über die Mustererkennung (siehe BuildPatternNotices).
absenceDays.Where(a => a.IsUnexcused && !a.IsLate)
.Select(a => new ClassTeacherOpenExcuseRow(a.StudentDisplayName, a.Date,
today.DayNumber - a.Date.DayNumber))
.OrderByDescending(r => r.DaysOpen).ThenBy(r => r.StudentName)
.ToList();
}
/// <summary>Eine einzelne, auf ein Minimum verdichtete Monatszelle im Übersichts-Widget.</summary>
public sealed record ClassTeacherCompactCalendarDay(DateOnly Date, string SignalCode,
string SignalColorHex, string Tooltip, bool IsToday)
{
public string DayNumber => Date.Day.ToString();
public bool HasSignal => !string.IsNullOrEmpty(SignalCode);
}
public partial class ClassTeacherOverviewViewModel : ObservableObject
{
private readonly WebUntisSettingsService _settings;
private readonly WebUntisIntegrationService _untis;
private readonly UntisReportCacheService _cache;
private readonly SchoolYearService _schoolYear;
private readonly IWorkTaskRepository _workTasks;
private readonly IStudentRepository _students;
private readonly IDocumentationRepository _documentation;
private readonly IVorgangRepository _vorgaenge;
private readonly PatternScoreSettingsService _patternScoreSettings;
private readonly IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { get; }
public ClassTeacherCasesViewModel CasesTab { get; }
public ObservableCollection<ClassTeacherRosterRow> Roster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> PrimaryRoster { get; } = [];
public ObservableCollection<ClassTeacherRosterRow> SecondaryRoster { get; } = [];
public ObservableCollection<ClassTeacherTrendDay> TrendDays { get; } = [];
public ObservableCollection<ClassTeacherPatternNotice> PatternNotices { get; } = [];
public ObservableCollection<ClassTeacherOpenExcuseRow> OpenExcuses { get; } = [];
public ObservableCollection<ClassTeacherCompactCalendarDay> CompactMonthDays { get; } = [];
public ObservableCollection<ClassTeacherCalendarScope> CalendarScopes { get; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private int _selectedRosterFilter;
/// Standardmäßig der Muster-Reiter (3) statt "Auffällig" (0) — Nutzer-Feedback: die reine
/// Heute-Ansicht lässt Schüler*innen mit Vorgeschichte (z.B. 5 Tage unentschuldigt letzte
/// Woche, heute aber da) komplett verschwinden, während ein erstmaliger Einzelfehltag ganz oben
/// steht, sobald er der/die einzige "heute Auffällige" ist.
[ObservableProperty] private int _selectedRosterFilter = 3;
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount;
@@ -225,6 +452,20 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
[ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[ObservableProperty] private int _openExcuseOverflowCount;
/// Eigene Dokumentation zu Schüler*innen der Klasse mit "Nacharbeiten"-Status bzw. dem
/// "Kritisch"-Tag — Kurzform derselben Zählung wie im Klassenbuch-Tab (siehe
/// <see cref="ClassTeacherDetailsViewModel.OwnDocumentationFollowUpCount"/>), hier direkt an
/// den "Klassenbuch öffnen"-Button gehängt statt in einer eigenen Kennzahlkarte.
[ObservableProperty] private int _ownDocumentationFollowUpCount;
[ObservableProperty] private int _ownDocumentationCriticalCount;
[ObservableProperty] private ClassTeacherCalendarScope? _selectedCalendarScope;
// Zuletzt per Load() geholte WebUntis-Rohdaten, für RefreshFromLocalDataOnly() - damit ein
// eingehendes Sync-Ereignis die Ansicht neu aufbauen kann, ohne selbst WebUntis anzufragen.
private IReadOnlyList<UntisStudentRosterCacheEntry>? _lastStudents;
private IReadOnlyList<UntisClassAbsenceEntryDto>? _lastAbsences;
private IReadOnlyList<UntisForeignClassRegisterEventDto>? _lastClassRegisterEvents;
private IReadOnlyList<CachedUntisHoliday>? _lastHolidays;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
@@ -233,9 +474,11 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool HasOwnDocumentationAlerts => OwnDocumentationFollowUpCount > 0 || OwnDocumentationCriticalCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2;
public bool PatternScoreFilterSelected => SelectedRosterFilter == 3;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount);
@@ -264,43 +507,99 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public string CompactMonthLabel
{
get
{
var month = DateOnly.FromDateTime(DateTime.Today).ToString("MMMM yyyy",
System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
return SelectedCalendarScope?.StudentName is { Length: > 0 }
? $"{month} · {SelectedCalendarScope.DisplayName}"
: $"{month} · gesamte Klasse";
}
}
public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings,
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab)
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
{
_settings = settings;
_untis = untis;
_cache = cache;
_schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_documentation = documentation;
_vorgaenge = vorgaenge;
_patternScoreSettings = patternScoreSettings;
_participation = participation;
_participationSessions = participationSessions;
_logger = logger;
DetailsTab = detailsTab;
CasesTab = casesTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnOwnDocumentationFollowUpCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnOwnDocumentationCriticalCountChanged(int value) => OnPropertyChanged(nameof(HasOwnDocumentationAlerts));
partial void OnSelectedCalendarScopeChanged(ClassTeacherCalendarScope? value)
{
OnPropertyChanged(nameof(CompactMonthLabel));
if (CalendarScopes.Count > 0) RebuildCompactMonthFromCachedData();
}
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value)
{
OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected));
OnPropertyChanged(nameof(PatternScoreFilterSelected));
ApplyRosterFilter();
}
private void ResetRosterState()
{
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); CompactMonthDays.Clear(); CalendarScopes.Clear(); SelectedCalendarScope = null;
OpenExcuseOverflowCount = 0;
}
// Wird beim Navigieren auf diese Seite aufgerufen statt LoadCommand: baut nur den lokalen
// Grundzustand auf (keine WebUntis-Anfrage), damit Öffnen der Ansicht nicht auf einen
// WebUntis-Login+Report-Abruf wartet. Die eigentlichen Fehlzeiten/Klassenbuch-Daten holt sich
// die Nutzerin bewusst über den "Aktualisieren"-Button (LoadCommand).
public void PrepareForDisplay()
{
if (Busy) return;
HomeroomClassName = _settings.HomeroomClassName;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
}
else
{
DetailsTab.Initialize(HomeroomClassName!);
CasesTab.Initialize(HomeroomClassName!);
if (StudentCount == 0)
Status = "Noch nicht geladen auf „Aktualisieren“ klicken, um aktuelle WebUntis-Daten zu holen.";
}
NotifyRosterState();
}
[RelayCommand]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
ResetRosterState();
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
@@ -310,55 +609,127 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
var className = HomeroomClassName!;
DetailsTab.Initialize(className);
CasesTab.Initialize(className);
Busy = true;
NotifyRosterState();
try
{
var today = DateOnly.FromDateTime(DateTime.Today);
var sevenDayStart = today.AddDays(-6);
var trendDays = LastSchoolDays(today, 7);
// Fehlzeiten seit Schuljahresbeginn statt nur der letzten 7 Tage: liefert die
// Kennzahl unten (YearSummaryLabel) und den Nenner für die Fehlquote in einem Abruf.
// UntisReportCacheService cached diesen "kalten" Bereich dauerhaft (siehe dort) —
// teuer ist nur der erste Abruf pro Schuljahr, nicht jedes Öffnen der Ansicht.
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
var studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, today);
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask);
// Seit Schuljahresbeginn statt nur die letzte Woche (Nutzer-Feedback: Klassenbucheinträge
// anderer Lehrkräfte — z.B. fehlende Hausaufgaben, schlechte Mitarbeit, im Extremfall eine
// Suspendierung — sollen wie die Fehlzeiten in den Auffälligkeits-Score einfließen, nicht
// nur die letzten 7 Tage). Dieselbe "kalte Historie bleibt gecacht"-Logik wie bei den
// Fehlzeiten oben, kein zusätzliches Abruf-Risiko. Die "letzte 7 Tage"-Badges
// (RecentClassRegisterCount, HasRecentClassRegisterEntry) werden weiterhin unten aus
// genau diesem einen Abruf herausgefiltert statt separat erneut abgerufen.
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, yearStart, today);
var holidaysTask = GetHolidaysAsync();
await Task.WhenAll(studentsTask, absencesTask, classRegisterTask, holidaysTask);
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absencesTask.Result);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var weekAbsenceDays = absenceDaysYear.Where(a => a.Date >= sevenDayStart).ToList();
var schoolDaysElapsed = CountWeekdays(yearStart, today);
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed)) Roster.Add(row);
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
LateCount = Roster.Count(r => r.IsLate);
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = classRegisterTask.Result.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildPatternNotices(weekAbsenceDays);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today);
_lastStudents = studentsTask.Result;
_lastAbsences = absencesTask.Result;
_lastClassRegisterEvents = classRegisterTask.Result;
_lastHolidays = holidaysTask.Result;
BuildRosterFromCachedData(studentsTask.Result, absencesTask.Result, classRegisterTask.Result,
holidaysTask.Result, today, yearStart, sevenDayStart, trendDays);
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; NotifyRosterState(); }
finally { Busy = false; NotifyRosterState(); }
}
// Baut die Ansicht ausschließlich aus den zuletzt per Load() geholten WebUntis-Daten plus
// aktuellen lokalen Daten (Vorgänge/Dokumentation) neu auf - löst NIE einen WebUntis-Request
// aus, auch nicht über UntisReportCacheService' stündliches Hot-Window-Refresh. Wird bei
// eingehenden Sync-Ereignissen aufgerufen (siehe MainWindowViewModel.RefreshCurrentPage);
// echte WebUntis-Daten holt bewusst ausschließlich der "Aktualisieren"-Button (LoadCommand),
// wenn die Nutzerin sich dafür entscheidet.
public void RefreshFromLocalDataOnly()
{
if (Busy || _lastStudents is null || _lastAbsences is null ||
_lastClassRegisterEvents is null || _lastHolidays is null) return;
var (today, yearStart, sevenDayStart, trendDays) = RosterDateWindow();
ResetRosterState();
BuildRosterFromCachedData(_lastStudents, _lastAbsences, _lastClassRegisterEvents, _lastHolidays,
today, yearStart, sevenDayStart, trendDays);
NotifyRosterState();
}
private (DateOnly Today, DateOnly YearStart, DateOnly SevenDayStart, IReadOnlyList<DateOnly> TrendDays) RosterDateWindow()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var yearStart = _schoolYear.SchoolYearStart(_schoolYear.CurrentSchoolYear(today));
return (today, yearStart, today.AddDays(-6), LastSchoolDays(today, 7));
}
private void BuildRosterFromCachedData(
IReadOnlyList<UntisStudentRosterCacheEntry> students,
IReadOnlyList<UntisClassAbsenceEntryDto> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> classRegisterEvents,
IReadOnlyList<CachedUntisHoliday> holidays,
DateOnly today, DateOnly yearStart, DateOnly sevenDayStart, IReadOnlyList<DateOnly> trendDays)
{
var absenceDaysYear = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences);
var todayAbsences = absenceDaysYear.Where(a => a.Date == today).ToList();
var termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidays);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
var recentRegisterEntries = classRegisterEvents
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
var localStudents = _students.GetAll();
var closedVorgaengeByNameKey = students
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
.Where(x => x.Student is not null)
.ToDictionary(
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
.Where(v => v.Status == VorgangStatus.Closed).ToList());
foreach (var row in ClassTeacherRosterRow.Build(students, todayAbsences,
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart, classRegisterEvents,
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
CalendarScopes.Add(new ClassTeacherCalendarScope(null, "Gesamte Klasse"));
foreach (var student in students.OrderBy(s => s.DisplayName))
CalendarScopes.Add(new ClassTeacherCalendarScope(student.DisplayName,
ClassTeacherOverviewViewModel.DisplayStudentName(student.DisplayName)));
SelectedCalendarScope = CalendarScopes[0];
StudentCount = Roster.Count;
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
TodayUnexcusedCount = Roster.Count(r => r.IsUnexcused);
LateCount = Roster.Count(r => r.IsLate);
PresentCount = Roster.Count(r => !r.HasAbsenceToday);
ExcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && !r.IsUnexcused);
UnexcusedAbsenceCount = Roster.Count(r => r.HasAbsenceToday && !r.IsLate && r.IsUnexcused);
RecentClassRegisterCount = recentRegisterEntries.Count;
BuildTrend(absenceDaysYear, trendDays);
BuildCompactMonth(absenceDaysYear, classRegisterEvents, today);
BuildPatternNotices(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildVorgangRegressionNotices();
BuildOwnDocumentationCounts();
BuildOpenExcuses(absenceDaysYear, today);
Status = $"{StudentCount} Schüler*innen · {TodayAlertCount} heute auffällig";
ApplyRosterFilter();
NotifySummary();
}
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand] private void ShowPatternScore() => SelectedRosterFilter = 3;
[RelayCommand]
private void OpenClassRegister()
@@ -376,6 +747,84 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void OpenMonthlyCalendar()
{
var today = DateOnly.FromDateTime(DateTime.Today);
DetailsTab.StudentFilter = SelectedCalendarScope?.StudentName ?? "";
DetailsTab.CalendarMonth = new DateOnly(today.Year, today.Month, 1);
ActiveTabIndex = 2;
DetailsTab.ShowCalendarCommand.Execute(null);
}
private void BuildCompactMonth(IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today)
{
CompactMonthDays.Clear();
foreach (var day in BuildCompactMonthDays(new DateOnly(today.Year, today.Month, 1),
absences, registerEntries, today, SelectedCalendarScope?.StudentName))
CompactMonthDays.Add(day);
}
private void RebuildCompactMonthFromCachedData()
{
if (_lastAbsences is null || _lastClassRegisterEvents is null) return;
var today = DateOnly.FromDateTime(DateTime.Today);
BuildCompactMonth(ClassAbsenceDaySummaryRow.GroupByStudentAndDay(_lastAbsences),
_lastClassRegisterEvents, today);
}
public static IReadOnlyList<ClassTeacherCompactCalendarDay> BuildCompactMonthDays(DateOnly month,
IReadOnlyList<ClassAbsenceDaySummaryRow> absences,
IReadOnlyList<UntisForeignClassRegisterEventDto> registerEntries, DateOnly today,
string? studentName = null)
{
var first = new DateOnly(month.Year, month.Month, 1);
if (!string.IsNullOrWhiteSpace(studentName))
{
absences = absences.Where(a => UntisNameMatching.NamesMatch(a.StudentName, studentName)).ToList();
registerEntries = registerEntries.Where(e => UntisNameMatching.NamesMatch(e.StudentName, studentName)).ToList();
}
var registerRows = registerEntries
.Select(e => (Entry: e, Valid: TryDate(e.Date, out var date), Date: date))
.Where(x => x.Valid && x.Date.Year == first.Year && x.Date.Month == first.Month)
.Select(x => new ClassTeacherClassRegisterRow(x.Date, x.Entry.Subject, x.Entry.StudentName,
x.Entry.TeacherUsername, x.Entry.CategoryName, x.Entry.CategoryGroup, x.Entry.Text))
.ToList();
var result = new List<ClassTeacherCompactCalendarDay>();
var days = DateTime.DaysInMonth(first.Year, first.Month);
for (var number = 1; number <= days; number++)
{
var date = new DateOnly(first.Year, first.Month, number);
var events = ClassTeacherDetailsViewModel.BuildDayEvents(date, absences, registerRows,
includeHomework: false);
var strongest = events.OrderBy(e => SignalPriority(e.Kind)).FirstOrDefault();
var tooltip = events.Count == 0
? $"{date:dd.MM.yyyy}: unauffällig"
: $"{date:dd.MM.yyyy}\n" + string.Join("\n", events.Select(e => e.Tooltip));
result.Add(new ClassTeacherCompactCalendarDay(date, strongest?.Code ?? "",
strongest?.ColorHex ?? "#00000000", tooltip, date == today));
}
return result;
}
internal static string DisplayStudentName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
private static int SignalPriority(ClassTeacherCalendarEventKind kind) => kind switch
{
ClassTeacherCalendarEventKind.Unexcused => 0,
ClassTeacherCalendarEventKind.ClassRegister => 1,
ClassTeacherCalendarEventKind.Absent => 2,
ClassTeacherCalendarEventKind.Late => 3,
ClassTeacherCalendarEventKind.Excused => 4,
_ => 5,
};
[RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{
@@ -406,6 +855,18 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
}
else if (SelectedRosterFilter == 3)
{
// Nutzer-Feedback: "heute auffällig" allein lässt Schüler*innen mit echter Vorgeschichte
// verschwinden, sobald sie an einem Tag zufällig anwesend sind, und lässt einen
// einmaligen Erstfehltag ganz oben stehen, sobald er der/die einzige "heute Auffällige"
// ist. Deshalb eigene Sortierung nach dem kumulierten Score übers Schuljahr statt nach
// dem heutigen Status — bewusst unabhängig von HasAbsenceToday.
PrimarySectionTitle = "Nach Muster seit Schuljahresbeginn";
foreach (var row in query.Where(r => r.PatternScore > 0)
.OrderByDescending(r => r.PatternScore).ThenBy(r => r.StudentName))
PrimaryRoster.Add(row);
}
else
{
PrimarySectionTitle = "Heute auffällig";
@@ -438,6 +899,9 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
}
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
/// Letzte <paramref name="count"/> Werktage bis einschließlich <paramref name="end"/>, ohne
/// Ferienkalender (bewusste Vereinfachung, siehe TODO.md 12.4-Nachtrag) — die App kennt keine
/// Schulferien, nur Wochenenden.
@@ -451,34 +915,123 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
return days;
}
/// Nenner für <see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/> — Werktage zwischen
/// Schuljahresbeginn und heute, ebenfalls ohne Ferienkalender.
private static int CountWeekdays(DateOnly start, DateOnly end)
/// Nutzer-Feedback: Schüler*innen, die seit Unterrichtsbeginn nachweislich an jedem Tag
/// fehlten, zeigten trotzdem nur ~57 % Fehlquote statt der erwarteten ~100 %. Ursache: der
/// Nenner zählte bislang jeden Werktag ab dem fest verdrahteten 1. August
/// (<see cref="SchoolYearService.SchoolYearStart"/>) als "Schultag" mit — die Sommerferien
/// enden je nach Bundesland/Jahr aber erst Wochen später, und genau zu Schuljahresbeginn macht
/// diese Ferienzeit einen großen Teil des bis dahin "verstrichenen" Zeitraums aus. WebUntis
/// kennt einen Teil des echten Ferienkalenders (<c>getHolidays</c>-Bericht, hier zwischen den
/// Werktagen ausgeschlossen) und behebt damit die kleineren Verzerrungen durch Herbst-/
/// Weihnachts-/Osterferien im weiteren Jahresverlauf. Die Sommerferien selbst liefert
/// <c>getHolidays</c> nach Prüfung der echten Antwort für dieses Konto aber NIE (über 11 Jahre
/// zurück kein einziger Sommerferien-Eintrag, siehe <see cref="EstimateTermStart"/>) — sie
/// liegen WebUntis-intern vermutlich außerhalb jedes Schuljahres-Datensatzes (der bei 1.8./31.7.
/// endet), nicht "in" einem davon. Für die Sommerferien bleibt deshalb weiterhin
/// <see cref="EstimateTermStart"/> nötig, das den Startpunkt selbst korrigiert statt Tage
/// innerhalb des Zeitraums abzuziehen.
public static int CountSchoolWeekdays(DateOnly start, DateOnly end, IReadOnlyList<CachedUntisHoliday> holidays)
{
if (end < start) return 0;
var count = 0;
for (var d = start; d <= end; d = d.AddDays(1))
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)) count++;
if (d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
!holidays.Any(h => d >= h.Start && d <= h.End))
count++;
return count;
}
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
/// Ergänzt <see cref="CountSchoolWeekdays"/> um genau die Lücke, die WebUntis' echter
/// Ferienkalender nicht schließt: die Sommerferien. Nimmt den frühesten Tag mit irgendeinem
/// Fehlzeiten-Eintrag der ganzen Klasse als Näherung für den tatsächlichen ersten
/// Unterrichtstag (ein solcher Eintrag kann nur an einem Tag mit tatsächlich stattfindendem
/// Unterricht entstehen) — Rückfall auf <paramref name="fallback"/>, wenn noch keine
/// Fehlzeiten vorliegen (dann bleibt <see cref="ClassTeacherRosterRow.HasYearSummary"/>
/// ohnehin ausgeblendet).
public static DateOnly EstimateTermStart(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly fallback) =>
absenceDaysYear.Count > 0 ? absenceDaysYear.Min(a => a.Date) : fallback;
/// Ferien ändern sich innerhalb eines Schuljahrs praktisch nie (anders als die
/// Fehlzeiten-/Klassenbuchberichte, deshalb hier keine der stündlichen "heißes Fenster"-Logik
/// aus <see cref="UntisReportCacheService"/>, sondern ein einfacher tagesgenauer Cache über
/// <see cref="WebUntisSettingsService"/> — kein zusätzliches LiteDB-Repository nötig). Schlägt
/// der Live-Abruf fehl (z. B. kurzzeitig kein Netz), wird der zuletzt bekannte Stand
/// weiterverwendet statt die ganze Übersicht mit einem Fehler zu blockieren; ist noch nie
/// erfolgreich abgerufen worden, bleibt die Liste leer und <see cref="CountSchoolWeekdays"/>
/// verhält sich wie vorher (reine Werktagszählung ohne Ferienabzug).
private static readonly TimeSpan HolidaysRefreshInterval = TimeSpan.FromDays(1);
private async Task<IReadOnlyList<CachedUntisHoliday>> GetHolidaysAsync()
{
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
var cached = _settings.GetCachedHolidays();
if (cached is not null && _settings.HolidaysFetchedAt is { } fetchedAt &&
DateTime.UtcNow - fetchedAt < HolidaysRefreshInterval)
return cached;
try
{
var fresh = (await _untis.GetHolidaysAsync())
.Select(h => new CachedUntisHoliday(h.Name, ParseDate(h.StartDate), ParseDate(h.EndDate)))
.ToList();
_settings.SetCachedHolidays(fresh, DateTime.UtcNow);
_logger?.Info("Klassenlehrer: WebUntis-Ferien geladen — " +
string.Join("; ", fresh.Select(h => $"{h.Name} {h.Start:yyyy-MM-dd}..{h.End:yyyy-MM-dd}")));
return fresh;
}
catch (WebUntisIntegrationException ex)
{
_logger?.Error("Klassenlehrer: WebUntis-Ferienabruf fehlgeschlagen", ex);
return cached ?? [];
}
}
private static DateOnly ParseDate(int value) => DateOnly.ParseExact(value.ToString(), "yyyyMMdd");
private const int LateYearEscalationThreshold = 5;
private void BuildPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear, DateOnly sevenDayStart)
{
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
.ToDictionary(g => g.Key, g => g.First().StudentName);
foreach (var notice in DetectLatePatterns(absenceDaysYear, displayNames, sevenDayStart))
PatternNotices.Add(notice);
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Verspätungen sind i.d.R. nicht entschuldigungsfähig (siehe <see cref="ClassTeacherOpenExcuseRow.Build"/>)
/// — eine Häufung ist trotzdem ein Signal, nur eines für ein Elterngespräch/einen Brief statt
/// für eine fehlende Entschuldigung. Zwei Stufen, Nutzer-Feedback: ein kurzfristiges Cluster
/// (<paramref name="sevenDayStart"/>..heute) bleibt ein sanfter Hinweis ohne Aktion, eine hohe
/// Jahressumme eskaliert zu Danger mit Wiedervorlage-Option
/// (<see cref="CreateReminderForPatternNoticeCommand"/>). Reine, ohne ViewModel-Zustand
/// testbare Kernlogik (gleiches Muster wie <see cref="DetectWeekdayPatterns"/>).
public static IReadOnlyList<ClassTeacherPatternNotice> DetectLatePatterns(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDaysYear,
IReadOnlyDictionary<string, string> displayNamesByKey,
DateOnly sevenDayStart,
int lateYearEscalationThreshold = LateYearEscalationThreshold)
{
var notices = new List<ClassTeacherPatternNotice>();
foreach (var group in absenceDaysYear.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{
var rows = group.ToList();
var displayName = Roster.FirstOrDefault(row =>
UntisNameMatching.NameKey(row.StudentName) == group.Key)?.StudentName ?? rows[0].StudentDisplayName;
var lateDays = rows.Count(row => row.IsLate);
var unexcusedDays = rows.Count(row => row.IsUnexcused && !row.IsLate);
if (unexcusedDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDays} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
else if (lateDays >= 2)
PatternNotices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDays}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
var weekRows = rows.Where(row => row.Date >= sevenDayStart).ToList();
var lateDaysWeek = weekRows.Count(row => row.IsLate);
var unexcusedDaysWeek = weekRows.Count(row => row.IsUnexcused && !row.IsLate);
var lateDaysYear = rows.Count(row => row.IsLate);
if (unexcusedDaysWeek >= 2)
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{unexcusedDaysWeek} unentschuldigte Fehltage in 7 Tagen", ClassTeacherStatusKind.Danger));
else if (lateDaysYear >= lateYearEscalationThreshold)
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDaysYear} Verspätungen seit Schuljahresbeginn Elterngespräch oder Brief erwägen",
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
else if (lateDaysWeek >= 2)
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{lateDaysWeek}-mal verspätet in 7 Tagen", ClassTeacherStatusKind.Warning));
}
OnPropertyChanged(nameof(HasPatternNotices));
return notices;
}
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
@@ -572,6 +1125,44 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
/// Default 3,0).
private const double RegressionThreshold = 3.0;
private void BuildVorgangRegressionNotices()
{
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
r.RegressionVorgangTitle is not null))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
/// verschwinden, nur weil er nicht mehr taufrisch ist. Selbe Namensabgleich-Logik wie
/// <see cref="BuildAttendanceParticipationNotices"/>.
private void BuildOwnDocumentationCounts()
{
var students = _students.GetAll();
var matchedIds = Roster
.Select(r => MatchStudent(r.StudentName, students))
.Where(s => s is not null)
.Select(s => s!.Id)
.ToHashSet();
var docs = _documentation.GetAll().Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId)).ToList();
OwnDocumentationFollowUpCount = docs.Count(d => d.IsDraft);
OwnDocumentationCriticalCount = docs.Count(d =>
d.Tags.Any(t => string.Equals(t, "Kritisch", StringComparison.OrdinalIgnoreCase)));
}
/// Reine, ohne Repository-Zugriff testbare Zuordnungslogik. Nutzt <c>FirstName</c>/<c>LastName</c>
/// statt <see cref="Student.FullName"/>, weil dessen "Nachname, Vorname"-Format mit Komma den
/// leerzeichenbasierten Wortabgleich in <see cref="UntisNameMatching"/> verfälschen würde
@@ -635,6 +1226,13 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
urgent: row.IsOverdue);
}
[RelayCommand]
private void CreateReminderForPatternNotice(ClassTeacherPatternNotice? notice)
{
if (notice is null || !notice.CanCreateReminder) return;
SaveReminder(notice.StudentName, notice.Message, urgent: true);
}
private void SaveReminder(string studentName, string notes, bool urgent)
{
var task = new WorkTask
@@ -0,0 +1,42 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Dialog zum Anpassen der <see cref="PatternScoreWeights"/> für den "Gesamtbild"-Reiter
/// der Klassenlehreransicht — ursprünglich feste Konstanten, auf Nutzerwunsch jetzt einstellbar.</summary>
public partial class PatternScoreWeightsDialogViewModel : ObservableObject
{
private readonly PatternScoreSettingsService _settings;
[ObservableProperty] private double _unexcusedDayWeight;
[ObservableProperty] private double _excusedDayWeight;
[ObservableProperty] private double _lateDayWeight;
[ObservableProperty] private double _negativeClassRegisterEntryWeight;
[ObservableProperty] private double _concerningKeywordBonus;
[ObservableProperty] private double _suspensionKeywordWeight;
public PatternScoreWeightsDialogViewModel(PatternScoreSettingsService settings)
{
_settings = settings;
var weights = settings.Load();
UnexcusedDayWeight = weights.UnexcusedDayWeight;
ExcusedDayWeight = weights.ExcusedDayWeight;
LateDayWeight = weights.LateDayWeight;
NegativeClassRegisterEntryWeight = weights.NegativeClassRegisterEntryWeight;
ConcerningKeywordBonus = weights.ConcerningKeywordBonus;
SuspensionKeywordWeight = weights.SuspensionKeywordWeight;
}
[RelayCommand]
private void Save() => _settings.Save(new PatternScoreWeights
{
UnexcusedDayWeight = UnexcusedDayWeight,
ExcusedDayWeight = ExcusedDayWeight,
LateDayWeight = LateDayWeight,
NegativeClassRegisterEntryWeight = NegativeClassRegisterEntryWeight,
ConcerningKeywordBonus = ConcerningKeywordBonus,
SuspensionKeywordWeight = SuspensionKeywordWeight,
});
}
@@ -0,0 +1,215 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
// ── Anzeige-Helfer ────────────────────────────────────────────────────────
public static class VorgangTagDisplay
{
// Vom Nutzer selbst genannte Schlagwörter — eigene Labels bleiben trotzdem frei möglich
// (AutoCompleteBox, gleiches Muster wie DocumentationTagDisplay.Suggestions).
public static string[] Suggestions { get; } =
["Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte", "Mitarbeit", "Elternkontakt", "Eskalation"];
}
public static class VorgangStatusDisplay
{
public static string Label(VorgangStatus status) => status == VorgangStatus.Closed ? "Geschlossen" : "Offen";
}
/// Ergebnis des "→ Vorgang"-Auswahldialogs beim Anheften einer Klassenbuchzeile: entweder ein
/// bestehender, offener Vorgang oder der Titel für einen neu anzulegenden.
public sealed record PinToVorgangChoice(Guid? ExistingVorgangId, string? NewVorgangTitle);
/// Eingefrorene WebUntis-Klassenbuchzeile mit deutscher Anzeige-Aufbereitung — das Core-Modell
/// <see cref="VorgangClassRegisterEntry"/> bleibt bewusst framework-frei ohne Formatierungslogik.
public sealed record VorgangClassRegisterEntryRow(VorgangClassRegisterEntry Model)
{
public string DateLabel => Model.Date.ToString("dd.MM.yyyy");
public string StudentName => Model.StudentName;
public string SummaryLabel => string.Join(" · ", new[] { Model.Subject, Model.CategoryName, Model.Text }
.Where(v => !string.IsNullOrWhiteSpace(v)));
}
// ── Mehrfachauswahl Schüler*innen (Anlegen/Bearbeiten-Dialog) ────────────────
public partial class VorgangStudentOption(Guid studentId, string name) : ObservableObject
{
public Guid StudentId { get; } = studentId;
public string Name { get; } = name;
[ObservableProperty] private bool _isSelected;
}
// ── Listen-Eintrag mit aufklappbarer Detailansicht ───────────────────────────
/// <summary>
/// Ein Vorgang in der Liste des "Vorgänge"-Tabs. Verknüpfte Dokumentation/Klassenbucheinträge
/// werden von <see cref="ClassTeacherCasesViewModel"/> beim Laden befüllt; Link/Unlink-Aktionen
/// laufen über die hier übergebenen Callbacks zurück ins ViewModel (statt eigenem Repository-
/// Zugriff hier), damit dieser Wrapper ein reiner Anzeige-Baustein bleibt — analog
/// <see cref="DocumentationItem"/>.
/// </summary>
public partial class VorgangItem : ObservableObject
{
private readonly Func<VorgangItem, DocumentationItem, Task> _onLink;
private readonly Func<VorgangItem, DocumentationItem, Task> _onUnlink;
private readonly Func<VorgangItem, VorgangClassRegisterEntryRow, Task> _onRemoveClassRegisterEntry;
private readonly Func<DocumentationItem, Task> _onEditDocumentation;
public Vorgang Model { get; }
public string StudentNames { get; }
public List<TagChip> TagChips { get; }
public string StatusLabel => VorgangStatusDisplay.Label(Model.Status);
public bool IsOpen => Model.Status == VorgangStatus.Open;
public string CreatedLabel => $"Angelegt {Model.CreatedAt:dd.MM.yyyy}";
public ObservableCollection<DocumentationItem> LinkedDocumentation { get; } = [];
public ObservableCollection<DocumentationItem> AvailableDocumentation { get; } = [];
public ObservableCollection<VorgangClassRegisterEntryRow> ClassRegisterRows { get; } = [];
public bool HasLinkedDocumentation => LinkedDocumentation.Count > 0;
public bool HasAvailableDocumentation => AvailableDocumentation.Count > 0;
public bool HasClassRegisterRows => ClassRegisterRows.Count > 0;
[ObservableProperty] private bool _isRevealed;
public VorgangItem(Vorgang model, string studentNames,
Func<VorgangItem, DocumentationItem, Task> onLink,
Func<VorgangItem, DocumentationItem, Task> onUnlink,
Func<VorgangItem, VorgangClassRegisterEntryRow, Task> onRemoveClassRegisterEntry,
Func<DocumentationItem, Task> onEditDocumentation)
{
Model = model;
StudentNames = studentNames;
TagChips = model.Tags.Select(t => new TagChip(t)).ToList();
_onLink = onLink;
_onUnlink = onUnlink;
_onRemoveClassRegisterEntry = onRemoveClassRegisterEntry;
_onEditDocumentation = onEditDocumentation;
}
[RelayCommand] private void Reveal() => IsRevealed = !IsRevealed;
/// Öffnet den vollen Dokumentations-Dialog zum Nachlesen/Bearbeiten, ohne den Vorgänge-Tab zu
/// verlassen (Nutzer-Feedback: bisher musste man dafür in den Schülerdatensatz wechseln).
[RelayCommand]
private async Task EditDocumentation(DocumentationItem? doc)
{
if (doc is not null) await _onEditDocumentation(doc);
}
[RelayCommand]
private async Task Link(DocumentationItem? doc)
{
if (doc is not null) await _onLink(this, doc);
}
[RelayCommand]
private async Task Unlink(DocumentationItem? doc)
{
if (doc is not null) await _onUnlink(this, doc);
}
[RelayCommand]
private async Task RemoveClassRegisterEntry(VorgangClassRegisterEntryRow? row)
{
if (row is not null) await _onRemoveClassRegisterEntry(this, row);
}
}
// ── Dialog: Vorgang anlegen/bearbeiten ────────────────────────────────────
public partial class VorgangDialogViewModel : ObservableObject
{
private readonly Vorgang? _editing;
public ObservableCollection<VorgangStudentOption> StudentOptions { get; }
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _description = "";
[ObservableProperty] private string _newTag = "";
public ObservableCollection<string> Tags { get; } = [];
public string[] TagSuggestions => VorgangTagDisplay.Suggestions;
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _studentsError = "";
public string DialogTitle => _editing is null ? "Vorgang anlegen" : "Vorgang bearbeiten";
public Vorgang? Result { get; private set; }
public VorgangDialogViewModel(List<StudentOption> rosterStudents, Vorgang? editing)
{
_editing = editing;
StudentOptions = new ObservableCollection<VorgangStudentOption>(rosterStudents.Select(s =>
new VorgangStudentOption(s.Id, s.Name) { IsSelected = editing?.StudentIds.Contains(s.Id) == true }));
if (editing is null) return;
Title = editing.Title;
Description = editing.Description;
foreach (var tag in editing.Tags) Tags.Add(tag);
}
[RelayCommand]
private void AddTag()
{
if (string.IsNullOrWhiteSpace(NewTag)) return;
var tag = NewTag.Trim();
if (!Tags.Contains(tag)) Tags.Add(tag);
NewTag = "";
}
[RelayCommand] private void RemoveTag(string? tag) { if (tag is not null) Tags.Remove(tag); }
[RelayCommand]
private void Save()
{
TitleError = ""; StudentsError = "";
var valid = true;
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
var selectedIds = StudentOptions.Where(s => s.IsSelected).Select(s => s.StudentId).ToList();
if (selectedIds.Count == 0) { StudentsError = "Mindestens eine/n Schüler*in auswählen."; valid = false; }
if (!valid) return;
Result = _editing ?? new Vorgang();
Result.Title = Title.Trim();
Result.Description = (Description ?? "").Trim();
Result.StudentIds = selectedIds;
Result.Tags = Tags.ToList();
}
}
// ── Dialog: Klassenbuchzeile an (bestehenden oder neuen) Vorgang anheften ────
public partial class PinToVorgangDialogViewModel : ObservableObject
{
public string RowSummary { get; }
public List<VorgangItem> MatchingOpenCases { get; }
public bool HasMatchingOpenCases => MatchingOpenCases.Count > 0;
[ObservableProperty] private VorgangItem? _selectedCase;
[ObservableProperty] private string _newTitle = "";
[ObservableProperty] private string _error = "";
public PinToVorgangChoice? Result { get; private set; }
public PinToVorgangDialogViewModel(string rowSummary, List<VorgangItem> matchingOpenCases)
{
RowSummary = rowSummary;
MatchingOpenCases = matchingOpenCases;
}
[RelayCommand]
private void Confirm()
{
Error = "";
if (SelectedCase is not null) { Result = new PinToVorgangChoice(SelectedCase.Model.Id, null); return; }
if (!string.IsNullOrWhiteSpace(NewTitle)) { Result = new PinToVorgangChoice(null, NewTitle.Trim()); return; }
Error = "Bestehenden Vorgang wählen oder Titel für einen neuen Vorgang eingeben.";
}
}
+319 -123
View File
@@ -35,8 +35,12 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis;
private readonly Func<DateTime> _now;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -50,6 +54,22 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8;
/// Nutzer-Feedback: "ich vergesse es, und fasse die App auch außerhalb der Schule manchmal
/// nicht mehr an" — die Erinnerung an fehlende Unterrichtszeit-Erfassung schaut deshalb nicht
/// nur auf heute zurück, sondern auf ein paar Tage, damit ein vergessener Tag nicht verloren
/// geht, sobald der nächste Schultag anbricht.
private const int MissingTeachingTimeLookbackDays = 14;
/// Für den heutigen Tag soll die Erinnerung nicht schon mitten im Unterricht auftauchen —
/// erst diese Zeitspanne nach dem laut Stundenplan letzten Unterrichtsende.
private const int MissingTeachingTimeTodayDelayMinutes = 30;
/// Gleiche Puffer-Idee wie TimeTrackingViewModel.ComputeTodaysTeachingWindow (bewusst hier
/// noch einmal definiert statt geteilt — der Vorschlag ist nur zwei Zeilen Rechnung).
private const int TeachingWindowBufferBeforeMinutes = 15;
private const int TeachingWindowBufferAfterMinutes = 10;
/// Vorausschau für die Klausurwochen-Karte (Nutzer-Feedback): weit genug, um eine sich
/// anbahnende Häufung noch rechtzeitig vor dem Anlegen weiterer Klausuren zu zeigen, aber
/// keine Vorschau auf das ganze Schuljahr.
private const int ExamLoadLookaheadDays = 60;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
@@ -70,18 +90,43 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
public ObservableCollection<UnplannedLessonItem> UnplannedLessons { get; } = [];
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
// Zusammengefasste "Handlungsbedarf"-Karte (vormals sieben eigene Kacheln/Collections:
// Entschuldigungen, Fehlzeiten, Förderplan, Korrekturen, ungeplante Stunden, Auffälligkeiten,
// Unterrichtszeit-Nacherfassung — siehe AttentionItem.cs). Attention traegt nur, was nach
// Filterung (AttentionFilters) tatsaechlich angezeigt wird; die Rohdaten je Art liegen in den
// privaten _xyzItems-Feldern und werden von RebuildAttention() neu zusammengesetzt, sobald sich
// Daten oder Filter aendern — ohne die Repos erneut abzufragen.
public ObservableCollection<AttentionGroup> Attention { get; } = [];
public ObservableCollection<AttentionFilterOption> AttentionFilters { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
private readonly List<OpenExcuseItem> _excuses = [];
private readonly List<AttentionItem> _attendanceItems = [];
private readonly List<AttentionItem> _supportItems = [];
private readonly List<AttentionItem> _correctionItems = [];
private readonly List<AttentionItem> _unplannedItems = [];
private readonly List<AttentionItem> _alertItems = [];
private readonly List<AttentionItem> _missingTimeItems = [];
/// Reihenfolge und Überschriften der Gruppen in der zusammengefassten Handlungsbedarf-Karte —
/// übernimmt die frühere Kachel-Reihenfolge aus DashboardSettingsService.DefaultCardOrder,
/// damit sich für Nutzer nichts unvorhersehbar umsortiert.
private static readonly (AttentionKind Kind, string Header)[] AttentionGroupOrder =
[
(AttentionKind.MissingTeachingTime, "Unterrichtszeit nacherfassen"),
(AttentionKind.Excuse, "Offene Entschuldigungen"),
(AttentionKind.Correction, "Offene Korrekturen"),
(AttentionKind.Unplanned, "Ungeplante Stunden"),
(AttentionKind.Alert, "Auffälligkeiten"),
(AttentionKind.Attendance, "Fehlzeiten-Warnung"),
(AttentionKind.SupportPlan, "Förderplan-Wiedervorlage"),
];
// Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; }
public Action<Guid>? OnNavigateToStudent { get; set; }
@@ -96,28 +141,32 @@ public partial class DashboardViewModel : ObservableObject
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe
// (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; }
// Öffnet den Nacherfassen-Dialog für einen Tag mit fehlender Unterrichtszeit-Erfassung
// (Nutzer-Feedback), vorbelegt mit Datum, Kategorie "Unterricht" und dem laut Stundenplan
// berechneten Zeitfenster — echtes Speichern bleibt eine bewusste Bestätigung im Dialog.
public Func<MissingTeachingTimeItem, Task>? OnAddMissingTeachingTime { get; set; }
public DashboardCardOption TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks");
public DashboardCardOption CalendarCard => Card("calendar");
public DashboardCardOption ExcusesCard => Card("excuses");
public DashboardCardOption UpcomingCard => Card("upcoming");
public DashboardCardOption CorrectionsCard => Card("corrections");
public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption GroupsCard => Card("groups");
public DashboardCardOption AttentionCard => Card("attention");
public int TodayLessonCount => TodaysLessons.Count;
public int OpenTaskCount => OpenTasks.Count;
public int UpcomingCount => UpcomingDates.Count;
public int AttentionCount => OpenExcuses.Count + AttendanceWarnings.Count + SupportPlanReviews.Count
+ OpenCorrections.Count + UnplannedLessons.Count + Alerts.Count;
public int AttentionCount => _excuses.Count + _attendanceItems.Count + _supportItems.Count
+ _correctionItems.Count + _unplannedItems.Count + _alertItems.Count + _missingTimeItems.Count;
public string TodayLessonSummary => TodayLessonCount == 1 ? "1 Stunde" : $"{TodayLessonCount} Stunden";
public string OpenTaskSummary => OpenTaskCount == 1 ? "1 Aufgabe" : $"{OpenTaskCount} Aufgaben";
public string AttentionSummary => AttentionCount == 1 ? "1 offener Punkt" : $"{AttentionCount} offene Punkte";
public string UpcomingSummary => UpcomingCount == 1 ? "1 Termin" : $"{UpcomingCount} Termine";
[ObservableProperty] private string _webUntisHealthLabel = "";
[ObservableProperty] private bool _isWebUntisHealthWarning;
[ObservableProperty] private bool _isWebUntisHealthVisible;
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
IReportGradeRepository reportGrades, IGroupMembershipRepository memberships,
@@ -127,8 +176,11 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis,
IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null,
Func<DateTime>? now = null)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -137,16 +189,38 @@ public partial class DashboardViewModel : ObservableObject
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_timeEntries = timeEntries;
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
_untisHub = untisHub;
_webUntis = webUntis;
// Testbare Uhr statt direkter DateTime.Now-Aufrufe (siehe Load()/LoadMissingTeachingTime):
// TimeOnly.AddHours()/AddMinutes() wickelt bei Mitternacht um, ohne injizierbares "jetzt"
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
_now = now ?? (() => DateTime.Now);
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
}
LoadDashboardCards();
LoadAttentionFilters();
Load();
RefreshWebUntisHealth();
}
/// <summary>Liest nur den gespeicherten Fälligkeitsstand der Untis-Hub-Jobs (kein
/// WebUntis-Zugriff, siehe <see cref="UntisHubService.GetRows"/>) - aufgerufen bei jedem
/// Dashboard-Refresh und erneut, nachdem der Nutzer den Hub geöffnet/einen Abgleich gemacht hat.</summary>
public void RefreshWebUntisHealth()
{
IsWebUntisHealthVisible = _webUntis.IsAvailable;
if (!IsWebUntisHealthVisible) return;
var rows = _untisHub.GetRows();
var due = rows.Count(r => r.DueState != UntisHubDueState.Ok);
IsWebUntisHealthWarning = due > 0;
WebUntisHealthLabel = due > 0 ? $"WebUntis ⚠ {due} fällig" : "WebUntis ✓";
}
private DashboardCardOption Card(string key) => DashboardCards.First(c => c.Key == key);
@@ -155,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
private void Load()
{
var now = DateTime.Now;
var now = _now();
var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear();
@@ -209,11 +283,14 @@ public partial class DashboardViewModel : ObservableObject
LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today);
LoadExamWeekLoads(groups, today);
LoadMissingTeachingTime(today);
LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today);
RebuildAttention();
UpdateDashboardSummary();
}
@@ -222,14 +299,14 @@ public partial class DashboardViewModel : ObservableObject
TodayCard.IsEmpty = TodaysLessons.Count == 0;
TasksCard.IsEmpty = OpenTasks.Count == 0;
CalendarCard.IsEmpty = false;
ExcusesCard.IsEmpty = OpenExcuses.Count == 0;
UpcomingCard.IsEmpty = UpcomingDates.Count == 0;
CorrectionsCard.IsEmpty = OpenCorrections.Count == 0;
UnplannedCard.IsEmpty = UnplannedLessons.Count == 0;
AlertsCard.IsEmpty = Alerts.Count == 0;
AttendanceCard.IsEmpty = AttendanceWarnings.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
AttentionCard.IsEmpty = AttentionCount == 0;
// Erst nachdem alle IsEmpty-Werte stehen: welche Kachel tatsaechlich gerendert wird, haengt
// ueber EffectiveIsVisible daran, und davon wiederum die Zeilen-/Spaltenzuordnung.
ApplyCardLayout();
OnPropertyChanged(nameof(TodayLessonCount));
OnPropertyChanged(nameof(OpenTaskCount));
@@ -296,7 +373,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAttendanceWarnings(DateOnly today)
{
AttendanceWarnings.Clear();
_attendanceItems.Clear();
var schoolYear = _sy.CurrentSchoolYear();
var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear);
@@ -319,14 +396,88 @@ public partial class DashboardViewModel : ObservableObject
items.Add(new AttendanceWarningItem(student.Id, student.FullName, balance.AbsenceRatePercent));
}
foreach (var item in items.OrderByDescending(i => i.AbsenceRatePercent))
AttendanceWarnings.Add(item);
_attendanceItems.Add(ToAttentionItem(item));
}
private AttentionItem ToAttentionItem(AttendanceWarningItem w) => new(
AttentionKind.Attendance, w.StudentName, trailingText: $"{w.AbsenceRatePercent:0.#} %",
isWarningTrailing: true, navigate: () => OnNavigateToStudent?.Invoke(w.StudentId));
// ── Klausurwochen (Nutzer-Feedback) ───────────────────────────────────────
//
// Persönliche Klausurlast über alle Kurse hinweg — anders als die klassenbezogene
// Kollisionsprüfung, die bereits der schulische Klausurplaner übernimmt (siehe
// ExamWeekLoadService). Nur geplante, noch bevorstehende Klausuren zählen: die
// Vorausschau soll helfen, eine sich anbahnende Woche zu erkennen, bevor man eine weitere
// Klausur genau dort einträgt — bereits durchgeführte/korrigierte Klausuren werden schon
// von der Karte "Offene Korrekturen" abgedeckt.
private void LoadExamWeekLoads(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
ExamWeekLoads.Clear();
var lastDay = today.AddDays(ExamLoadLookaheadDays);
var upcoming = groups.Keys
.SelectMany(gid => _exams.GetByGroup(gid))
.Where(e => e.Status == ExamStatus.Planned && e.Date >= today && e.Date <= lastDay);
foreach (var week in ExamWeekLoadService.FindOverloadedWeeks(upcoming))
ExamWeekLoads.Add(new ExamWeekLoadItem(week.IsoWeek, week.WeekStart, week.WeekEnd, week.ExamCount));
}
// ── Unterrichtszeit-Erfassung nachholen (Nutzer-Feedback) ─────────────────
//
// "Ich vergesse es, und fasse die App außerhalb der Schule manchmal nicht mehr an" — schaut
// deshalb bewusst ein paar Tage zurück statt nur auf heute. Ein Tag zählt als erfasst, sobald
// irgendein TimeEntry der Kategorie "Unterricht" an diesem Datum existiert; die genaue
// Zeitüberschneidung mit dem Stundenplan wird nicht geprüft (dieselbe grobe Betrachtung wie
// beim bestehenden "Unterrichtszeit übernehmen"-Vorschlag für heute).
private void LoadMissingTeachingTime(DateOnly today)
{
_missingTimeItems.Clear();
var firstDay = today.AddDays(-MissingTeachingTimeLookbackDays);
var publicHolidayDates = Enumerable.Range(firstDay.Year, today.Year - firstDay.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(_now());
var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1))
{
if (IsFreeDay(date, schoolHolidays, publicHolidayDates)) continue;
var daySlots = _timetableSlots.GetAll().Where(s => s.Weekday == date.DayOfWeek).ToList();
if (daySlots.Count == 0) continue;
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
var periodTimes = daySlots.Where(s => !cancelledPeriods.Contains(s.PeriodNumber))
.Select(s => _periodSchedule.GetTimes(s.PeriodNumber))
.Where(t => t is not null).Select(t => t!.Value).ToList();
if (periodTimes.Count == 0) continue; // alle Stunden entfallen oder kein Zeitraster hinterlegt
var lastPeriodEnd = periodTimes.Max(t => t.End);
if (date == today && nowTime < lastPeriodEnd.AddMinutes(MissingTeachingTimeTodayDelayMinutes))
continue; // heute: erst 30 Minuten nach Unterrichtsschluss erinnern
if (_timeEntries.GetByDate(date).Any(e => e.Category == "Unterricht")) continue;
var windowStart = periodTimes.Min(t => t.Start).AddMinutes(-TeachingWindowBufferBeforeMinutes);
var windowEnd = lastPeriodEnd.AddMinutes(TeachingWindowBufferAfterMinutes);
items.Add(new MissingTeachingTimeItem(date, windowStart, windowEnd));
}
foreach (var item in items.OrderBy(i => i.Date))
_missingTimeItems.Add(new AttentionItem(AttentionKind.MissingTeachingTime, item.DateDisplay,
actions: [new AttentionAction("Erfassen", AddMissingTeachingTimeCommand, item)]));
}
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today)
{
SupportPlanReviews.Clear();
_supportItems.Clear();
var dueBy = today.AddDays(SupportPlanDueWithinDays);
var due = _documentation.GetAll()
@@ -339,8 +490,11 @@ public partial class DashboardViewModel : ObservableObject
{
var student = _students.GetById(d.StudentId);
if (student is null) continue;
SupportPlanReviews.Add(new SupportPlanDueItem(
d.StudentId, student.FullName, d.Title, d.SupportData!.ReviewDate!.Value, today));
var reviewDate = d.SupportData!.ReviewDate!.Value;
var studentId = d.StudentId;
_supportItems.Add(new AttentionItem(AttentionKind.SupportPlan, student.FullName, subtitle: d.Title,
dateDisplay: reviewDate.ToString("dd.MM.yyyy"), isOverdue: reviewDate < today,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
}
}
@@ -385,7 +539,7 @@ public partial class DashboardViewModel : ObservableObject
private void LoadOpenCorrections(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
OpenCorrections.Clear();
_correctionItems.Clear();
foreach (var group in groups.Values)
foreach (var exam in _exams.GetByGroup(group.Id)
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
@@ -393,8 +547,13 @@ public partial class DashboardViewModel : ObservableObject
{
var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
_memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
group.Name, exam.Date, evaluated, expected, today));
var percent = expected == 0 ? 0 : (int)Math.Round(evaluated * 100.0 / expected);
var groupId = group.Id;
_correctionItems.Add(new AttentionItem(AttentionKind.Correction, exam.Title, subtitle: group.Name,
dateDisplay: exam.Date.ToString("dd.MM.yyyy"),
isOverdue: exam.Date < today.AddDays(-7) && evaluated < expected,
progressPercent: percent, progressLabel: $"{evaluated} von {expected} Arbeiten bewertet", hasProgress: true,
navigate: () => OnNavigateToExam?.Invoke(groupId)));
}
}
@@ -410,14 +569,14 @@ public partial class DashboardViewModel : ObservableObject
private void LoadUnplannedLessons(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
UnplannedLessons.Clear();
_unplannedItems.Clear();
var lastDay = today.AddDays(UnplannedLessonsLookaheadDays);
var publicHolidayDates = Enumerable.Range(today.Year, lastDay.Year - today.Year + 1)
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var items = new List<UnplannedLessonItem>();
var items = new List<(DateOnly Date, int PeriodNumber, LearningGroup Group)>();
foreach (var group in groups.Values.Where(g => g.RequiresLessonPlanning))
{
var slots = _timetableSlots.GetByGroup(group.Id);
@@ -446,12 +605,19 @@ public partial class DashboardViewModel : ObservableObject
{
continue;
}
items.Add(new UnplannedLessonItem(group.Id, group.Name, date, slot.PeriodNumber, today));
items.Add((date, slot.PeriodNumber, group));
}
}
}
foreach (var item in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber).ThenBy(i => i.GroupName))
UnplannedLessons.Add(item);
foreach (var (date, periodNumber, group) in items.OrderBy(i => i.Date).ThenBy(i => i.PeriodNumber)
.ThenBy(i => i.Group.Name))
{
var dateDisplay = date == today ? "Heute" : date == today.AddDays(1) ? "Morgen" : date.ToString("dd.MM.");
var groupId = group.Id;
_unplannedItems.Add(new AttentionItem(AttentionKind.Unplanned,
$"{group.Name} · {periodNumber}. Stunde", dateDisplay: dateDisplay,
navigate: () => OnNavigateToUnplannedLesson?.Invoke(groupId)));
}
}
/// <summary>Erkennt, ob eine Stunde ohne eigene Lesson bereits Teil einer Doppelstunde ist, die
@@ -483,11 +649,11 @@ public partial class DashboardViewModel : ObservableObject
private void LoadAlerts(IReadOnlyDictionary<Guid, LearningGroup> groups, DateOnly today)
{
Alerts.Clear();
foreach (var warning in AttendanceWarnings)
Alerts.Add(new DashboardAlertItem(warning.StudentId, null, warning.StudentName,
"Fehlzeiten", $"Fehlzeitenquote {warning.AbsenceRatePercent:0.#} %", AlertSeverity.High));
_alertItems.Clear();
// Fehlzeiten-Auffälligkeiten erscheinen in der zusammengefassten Handlungsbedarf-Karte
// bereits als eigene Gruppe "Fehlzeiten-Warnung" (LoadAttendanceWarnings) — eine weitere
// Kopie hier wäre jetzt eine sichtbare Dopplung derselben Schüler/Zahl, die vor dem Merge
// durch zwei getrennte Kacheln (Auffälligkeiten vs. Fehlzeiten-Warnung) nicht auffiel.
foreach (var group in groups.Values)
{
var groupReportGrades = _reportGrades.GetByGroup(group.Id);
@@ -496,6 +662,7 @@ public partial class DashboardViewModel : ObservableObject
{
var student = _students.GetById(membership.StudentId);
if (student is null) continue;
var studentId = student.Id;
var values = _grades.GetByStudentAndGroup(student.Id, group.Id)
.OrderBy(g => g.Date)
.Select(g => int.TryParse(g.Value, out var value) ? (int?)value : null)
@@ -509,9 +676,10 @@ public partial class DashboardViewModel : ObservableObject
? recent - previous >= 1.0
: previous - recent >= 3.0;
if (declined)
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Notenabfall", $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
AlertSeverity.Medium));
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
subtitle: $"{group.Name}: zuletzt {recent:0.0}, zuvor {previous:0.0}",
trailingText: "Notenabfall", severity: AlertSeverity.Medium,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
}
var latestReport = groupReportGrades
@@ -520,9 +688,10 @@ public partial class DashboardViewModel : ObservableObject
var effective = latestReport?.OverrideValue ?? latestReport?.CalculatedValue;
if (int.TryParse(effective, out var reportValue)
&& (group.GradingSystem == GradingSystem.Grades1To6 ? reportValue >= 5 : reportValue <= 4))
Alerts.Add(new DashboardAlertItem(student.Id, group.Id, student.FullName,
"Versetzungsgefährdung", $"{group.Name}: aktueller Stand {reportValue}",
AlertSeverity.High));
_alertItems.Add(new AttentionItem(AttentionKind.Alert, student.FullName,
subtitle: $"{group.Name}: aktueller Stand {reportValue}",
trailingText: "Versetzungsgefährdung", severity: AlertSeverity.High,
navigate: () => OnNavigateToStudent?.Invoke(studentId)));
}
}
}
@@ -544,18 +713,74 @@ public partial class DashboardViewModel : ObservableObject
private static string CardTitle(string key) => key switch
{
"today" => "Heute", "tasks" => "Offene Aufgaben", "calendar" => "Kalender",
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key,
"upcoming" => "Anstehende Termine", "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"attention" => "Handlungsbedarf", _ => key,
};
// ── Handlungsbedarf: Filter-Chips ─────────────────────────────────────────
//
// Ersetzt die frühere Sichtbarkeit je Einzelkachel (sieben Schalter im "Bereiche anpassen"-
// Panel) durch Filter-Chips innerhalb der zusammengefassten Karte — dichter, und der
// naheliegende Ort, weil alle sieben jetzt eine Karte sind. Bewusst nur für die Dauer der
// Sitzung (keine Persistenz über DashboardSettingsService): das JSON-Format dort ist eine
// flache Liste von Kachel-Einstellungen, eine zweite Objektform nur für diese sieben Filter
// hätte das Dateiformat aufgespalten, ohne dass "welche Handlungsbedarf-Art blende ich
// dauerhaft aus" bisher als Bedürfnis geäußert wurde.
private void LoadAttentionFilters()
{
AttentionFilters.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
var option = new AttentionFilterOption(kind, header) { OnChanged = RebuildAttention };
AttentionFilters.Add(option);
}
}
private bool IsAttentionFilterActive(AttentionKind kind) =>
AttentionFilters.FirstOrDefault(f => f.Kind == kind)?.IsActive ?? true;
/// <summary>Setzt Attention aus den bereits geladenen _xyzItems-Feldern neu zusammen — reine
/// Umsortierung/Filterung im Speicher, kein Repo-Zugriff. Wird nach jedem Load() sowie nach
/// jeder punktuellen Änderung (Entschuldigung aufgelöst, Zeit nacherfasst, Filter-Chip
/// umgeschaltet) aufgerufen.</summary>
private void RebuildAttention()
{
var byKind = new Dictionary<AttentionKind, IReadOnlyList<AttentionItem>>
{
[AttentionKind.MissingTeachingTime] = _missingTimeItems,
[AttentionKind.Excuse] = _excuses.Select(ToAttentionItem).ToList(),
[AttentionKind.Correction] = _correctionItems,
[AttentionKind.Unplanned] = _unplannedItems,
[AttentionKind.Alert] = _alertItems,
[AttentionKind.Attendance] = _attendanceItems,
[AttentionKind.SupportPlan] = _supportItems,
};
Attention.Clear();
foreach (var (kind, header) in AttentionGroupOrder)
{
if (!IsAttentionFilterActive(kind)) continue;
var items = byKind[kind];
if (items.Count == 0) continue;
Attention.Add(new AttentionGroup(kind, header, items));
}
}
private static AttentionItem ToAttentionItem(OpenExcuseItem e) => new(
AttentionKind.Excuse, e.StudentName, subtitle: $"{e.GroupName} · {e.DateDisplay}",
actions: [new AttentionAction("Entschuldigt", e.MarkExcusedCommand),
new AttentionAction("Unentschuldigt", e.MarkUnexcusedCommand)]);
// Zaehlt bewusst EffectiveIsVisible, nicht IsVisible: eine eingeschaltete, aber gerade leere
// HideWhenEmpty-Kachel wird nicht gerendert und darf deshalb auch keinen Rasterplatz belegen,
// sonst bleibt an ihrer Stelle eine Luecke im zweispaltigen Grid.
private void ApplyCardLayout()
{
var visibleIndex = 0;
foreach (var card in DashboardCards)
{
var index = card.IsVisible ? visibleIndex++ : 0;
var index = card.EffectiveIsVisible ? visibleIndex++ : 0;
card.Row = index / 2;
card.Column = index % 2;
}
@@ -591,15 +816,24 @@ public partial class DashboardViewModel : ObservableObject
SaveAndApplyCardLayout();
}
[RelayCommand] private void OpenStudentAttendance(AttendanceWarningItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
/// <summary>Ersetzt die früheren fünf eigenen Navigations-Commands (OpenStudentAttendance,
/// OpenStudentSupportPlan, OpenCorrection, OpenUnplannedLesson, OpenAlert) — jedes AttentionItem
/// trägt sein Sprungziel bereits als Closure in Navigate.</summary>
[RelayCommand] private void OpenAttentionItem(AttentionItem? item) => item?.Navigate?.Invoke();
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand]
private async Task AddMissingTeachingTime(MissingTeachingTimeItem? item)
{
if (item is null || OnAddMissingTeachingTime is null) return;
await OnAddMissingTeachingTime(item);
LoadMissingTeachingTime(DateOnly.FromDateTime(DateTime.Today));
RebuildAttention();
UpdateDashboardSummary();
}
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{
OpenExcuses.Clear();
_excuses.Clear();
var cutoff = today.AddDays(-OpenExcuseMaxAgeDays);
var items = new List<OpenExcuseItem>();
@@ -618,8 +852,7 @@ public partial class DashboardViewModel : ObservableObject
}
}
}
foreach (var item in items.OrderBy(i => i.Date))
OpenExcuses.Add(item);
_excuses.AddRange(items.OrderBy(i => i.Date));
}
private void ResolveExcuse(OpenExcuseItem item, AttendanceStatus status)
@@ -628,7 +861,8 @@ public partial class DashboardViewModel : ObservableObject
if (entry is null) return;
entry.Attendance = status;
_participationEntries.Save(entry);
OpenExcuses.Remove(item);
_excuses.Remove(item);
RebuildAttention();
UpdateDashboardSummary();
}
@@ -829,12 +1063,6 @@ public partial class DashboardViewModel : ObservableObject
else OnNavigateToGroup?.Invoke(groupId);
}
}
[RelayCommand] private void OpenCorrection(CorrectionProgressItem? item)
{ if (item is not null) OnNavigateToExam?.Invoke(item.GroupId); }
[RelayCommand] private void OpenUnplannedLesson(UnplannedLessonItem? item)
{ if (item is not null) OnNavigateToUnplannedLesson?.Invoke(item.GroupId); }
[RelayCommand] private void OpenAlert(DashboardAlertItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); }
[RelayCommand] private void Refresh() => Load();
[RelayCommand] private Task AddTask() => AddTaskInternal(startAsReminder: false);
@@ -966,24 +1194,21 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
public double AbsenceRatePercent { get; } = absenceRatePercent;
}
// ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
{
public Guid StudentId { get; }
public string StudentName { get; }
public string Title { get; }
public string ReviewDateDisplay { get; }
public bool IsOverdue { get; }
public int ExamCount { get; } = examCount;
public string Label => $"KW {isoWeek} ({weekStart:dd.MM.}{weekEnd:dd.MM.})";
public string CountDisplay => ExamCount == 1 ? "1 Klausur" : $"{ExamCount} Klausuren";
}
public SupportPlanDueItem(Guid studentId, string studentName, string title, DateOnly reviewDate, DateOnly today)
{
StudentId = studentId;
StudentName = studentName;
Title = title;
ReviewDateDisplay = reviewDate.ToString("dd.MM.yyyy");
IsOverdue = reviewDate < today;
}
public class MissingTeachingTimeItem(DateOnly date, TimeOnly windowStart, TimeOnly windowEnd)
{
private static readonly CultureInfo De = new("de-DE");
public DateOnly Date { get; } = date;
public TimeOnly WindowStart { get; } = windowStart;
public TimeOnly WindowEnd { get; } = windowEnd;
public string DateDisplay { get; } = date.ToString("dddd, dd.MM.", De);
}
public partial class CalendarDayCell : ObservableObject
@@ -1061,46 +1286,8 @@ public sealed class UpcomingDateItem(UpcomingDateKind kind, DateOnly date, strin
};
}
public sealed class CorrectionProgressItem(Guid examId, Guid groupId, string title, string groupName,
DateOnly date, int completed, int total, DateOnly today)
{
public Guid ExamId { get; } = examId;
public Guid GroupId { get; } = groupId;
public string Title { get; } = title;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int Completed { get; } = completed;
public int Total { get; } = total;
public int Percent => Total == 0 ? 0 : (int)Math.Round(Completed * 100.0 / Total);
public string ProgressDisplay => $"{Completed} von {Total} Arbeiten bewertet";
public string DateDisplay => Date.ToString("dd.MM.yyyy");
public bool IsOverdue => Date < today.AddDays(-7) && Completed < Total;
}
public sealed class UnplannedLessonItem(Guid groupId, string groupName, DateOnly date, int periodNumber, DateOnly today)
{
public Guid GroupId { get; } = groupId;
public string GroupName { get; } = groupName;
public DateOnly Date { get; } = date;
public int PeriodNumber { get; } = periodNumber;
public string DateDisplay => Date == today ? "Heute" : Date == today.AddDays(1) ? "Morgen" : Date.ToString("dd.MM.");
public string Display => $"{GroupName} · {PeriodNumber}. Stunde";
}
public enum AlertSeverity { Medium, High }
public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string studentName,
string kindLabel, string detail, AlertSeverity severity)
{
public Guid StudentId { get; } = studentId;
public Guid? GroupId { get; } = groupId;
public string StudentName { get; } = studentName;
public string KindLabel { get; } = kindLabel;
public string Detail { get; } = detail;
public AlertSeverity Severity { get; } = severity;
public string SeverityColor => Severity == AlertSeverity.High ? "#D32F2F" : "#F59E0B";
}
public partial class DashboardCardOption : ObservableObject
{
[ObservableProperty] private bool _isVisible;
@@ -1113,12 +1300,19 @@ public partial class DashboardCardOption : ObservableObject
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; }
/// <summary>Rinnstein zur jeweils anderen Rasterspalte. Muss aus der berechneten
/// <see cref="Column"/> kommen und darf nicht im XAML fest an der Kachel haengen: welche Kachel
/// links und welche rechts landet, entscheidet sich erst zur Laufzeit aus Reihenfolge und
/// Sichtbarkeit, ein fester Margin sitzt dann bei jeder Umschaltung auf der falschen Seite.</summary>
public Avalonia.Thickness Margin => Column == 0
? new Avalonia.Thickness(0, 0, 8, 8)
: new Avalonia.Thickness(8, 0, 0, 8);
public DashboardCardOption(string key, string title, bool isVisible)
{
Key = key;
Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
or "alerts" or "attendance" or "support";
HideWhenEmpty = key is "upcoming" or "attention";
_isVisible = isVisible;
}
@@ -1129,4 +1323,6 @@ public partial class DashboardCardOption : ObservableObject
}
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
partial void OnColumnChanged(int value) => OnPropertyChanged(nameof(Margin));
}
@@ -32,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateTextError = "";
[ObservableProperty] private string _returnedAtTextError = "";
[ObservableProperty] private string _weekLoadHint = "";
[ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting;
@@ -108,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
}
foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals();
UpdateWeekLoadHint();
}
partial void OnUseWeightingChanged(bool value)
@@ -115,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
foreach (var t in Tasks) t.ShowWeight = value;
}
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
/// Nutzer-Feedback: persönliche Klausurlast pro Woche — die klassenbezogene
/// Kollisionsprüfung übernimmt bereits der schulische Klausurplaner (siehe
/// ExamWeekLoadService), hier geht es um die eigene Häufung über alle Kurse hinweg.
private void UpdateWeekLoadHint()
{
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
{
WeekLoadHint = "";
return;
}
var existing = ExamWeekLoadService.CountInSameWeek(_exams.GetAll(), date, _editingExam?.Id);
var totalIfSaved = existing + 1;
WeekLoadHint = totalIfSaved >= ExamWeekLoadService.WarningThreshold
? $"Damit hättest du {totalIfSaved} Klausuren in dieser Woche."
: "";
}
[RelayCommand]
private void AddTask()
{

Some files were not shown because too many files have changed in this diff Show More