Author SHA1 Message Date
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
admin dade41ee8d Stundenplan verbesserung
CI / build-and-test (push) Canceled after 0s
2026-08-30 00:53:32 +02:00
adminandClaude Sonnet 5 2b299fc940 Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung
- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:52:44 +02:00
adminandClaude Sonnet 5 dbd8777e64 Punkteeingabe: Enter und Pfeiltasten unterstützen beide Korrektur-Workflows
Enter läuft jetzt zeilenweise (Arbeit für Arbeit), Pfeil-Hoch/-Runter spaltenweise
mit Umbruch zur Nachbaraufgabe (Aufgabe für Aufgabe) - beides ohne Moduswechsel,
da sich beide Reihenfolgen ohnehin auf dieselbe Tabelle abbilden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:25:53 +02:00
admin 89a6376fb6 Improve desktop UX and accessibility 2026-08-29 20:56:55 +02:00
admin b7a105af73 Dashboard und Schnellnavigation fix 2026-08-29 19:35:05 +02:00
adminandClaude Sonnet 5 7d3d021d09 Stundenplan: Wochenend-Flakiness in Unterrichtsmodus-Test behoben
OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus schlug
an Sa/So fehl, weil today.DayOfWeek dort keiner Kachel im Mo-Fr-Wochenraster
entspricht. TimetableViewModel bekommt dafür eine austauschbare Clock-Property
(Standard: DateTime.Now), sodass der Test "heute" unabhängig vom tatsächlichen
Wochentag simulieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:35:16 +02:00
adminandClaude Sonnet 5 7fbf035cbb Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des
Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt
manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:27:18 +02:00
admin 9b04d7eb98 UX-Update 2.0, Neues Dashboard 2026-08-29 00:45:19 +02:00
adminandClaude Sonnet 5 229ef79e75 Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste
Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:21:49 +02:00
adminandClaude Sonnet 5 cd08bbbbc4 Stundenplan: Dropdown für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
Die zwei Buttons in der "Heute"-Tagesliste mit teils vom Lesson-Status
abhängiger Doppelbedeutung ("Verlaufsplan ansehen"/"Zur Lerngruppe") waren
unklar. Ersetzt durch ein Dropdown mit vier ausdrücklich benannten Zielen;
drei nutzen bestehende Commands, "Sitzplan" (Sitzpläne-Tab) ist neu und
bisher nur indirekt über den Unterrichtsmodus erreichbar gewesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:03:11 +02:00
adminandClaude Sonnet 5 442e3d5e3d Unterrichtsmodus: Schnellbewertung und Hausaufgabe-Kontrolle direkt im Fenster
Zwei neue Buttons ("Mitarbeit", "Anwesenheit/Hausaufgabe") öffnen die
bestehenden Schnellbewertungs-Dialoge direkt aus dem Unterrichtsmodus, ohne
zum Mitarbeit-Tab wechseln zu müssen — TeachingModeViewModel bekommt dafür
eine eigene ParticipationTabViewModel-Instanz, vorselektiert auf die zu
dieser Stunde gehörende Sitzung. Die linke Spalte zeigt jetzt zusätzlich die
Hausaufgabe der letzten Stunde mit Kontrolliert-Checkbox sowie die
Hausaufgabe dieser Stunde als editierbares Feld, statt nur lesbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:20:40 +02:00
adminandClaude Sonnet 5 3f484ce3c8 Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)
Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:57:09 +02:00
adminandClaude Sonnet 5 3f8dab4abd Klassenlehrer-Tab: Warnung/Gefahr-Farben per validate_palette.js ersetzt
Amber und Korallrot waren laut Nutzer-Feedback auf dem Bildschirm kaum
auseinanderzuhalten. Die dataviz-Skill-Validierung bestätigte das
quantitativ (ΔE 2.5 unter Deuteranopie, 11.4 selbst bei normaler
Farbwahrnehmung — beides unter der Zielschwelle). Neue Werte aus der
validierten Kategorial-/Statuspalette der Skill statt erneut freihändig
gewählter Farben; Tagesüberblick-Balken tauscht zusätzlich die
Segmentreihenfolge, weil Grün und das neue Dunkel-Gelb sonst direkt
aneinanderstießen. Auch ein Kopiereinfügen-Duplikat in TODO.md behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:33:54 +02:00
adminandClaude Sonnet 5 b3b86f0cf4 Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)
Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante;
Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei
kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding
gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten).
Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier
Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber
ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein
vorangestelltes Symbol (Barrierefreiheit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:25:32 +02:00
adminandClaude Sonnet 5 9d375caf14 Klassenlehrer-Tab: Theme-Ressourcen statt Hex-Farben, gestapelter Trend, Fehlquote seit Schuljahresbeginn
Semantische Brushes (Light/Dark) lösen die fest codierten Dark-Hex-Werte ab, die
im hellen Theme unbenutzbar waren. Trend-Chart zeigt jetzt einen echten
gestapelten Balken über die letzten 7 Schultage statt drei unabhängig
skalierten über Kalendertage; neue Fehlquote je Schüler*in seit
Schuljahresbeginn ergänzt den Heute-Snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:47:13 +02:00
admin cf8de08501 Redesign Klassenlehrer Tab. 2026-08-26 22:50:40 +02:00
admin 265ffe9a74 Untis: Stundenplansync ab App Start 2026-08-26 22:24:10 +02:00
adminandClaude Sonnet 5 0c8bb58e1b fix: Klassenlehrer-Übersicht rief Schülerreport bislang live statt gecacht ab
Die Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten
und Klassenbuch schon lokal gecacht waren (Nutzer-Feedback: "fühlt sich an
wie ein Live-Pull mit CSV-Parsing"). Grund: genau das passierte - der
Schülerreport (Namen fürs Roster) lief komplett am Cache vorbei bei jedem
Öffnen live gegen WebUntis.

Neues UntisStudentRosterCacheEntry/UntisStudentRosterCacheRepository
(gleiches "kein db.OnChange"-Prinzip wie die bestehenden Caches) plus
UntisReportCacheService.GetStudentRosterAsync - ohne heißes/kaltes Fenster,
da eine Klassenliste keine Historie hat, nur dieselbe Stundenschwelle
"gilt der letzte Abruf noch als frisch".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:18:53 +02:00
adminandClaude Sonnet 5 69cc16bbd2 fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung
Zwei Bugs behoben:
- Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis
  liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge
  (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte
  "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich
  (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht
  selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer
  falsch).
- Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter
  Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht
  zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay
  gebunden. Explizit ergänzt.

Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt
dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:27:14 +02:00
adminandClaude Sonnet 5 2b29dea824 WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge
Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes
Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu
abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro
Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge,
bei WebUntis aufzufallen).

Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den
bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert
(gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) -
jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr
gewollt an.

UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens
stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird
dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine,
ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten
nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein
zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe.

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:10:01 +02:00
adminandClaude Sonnet 5 056e864edd fix: WebUntis-Schülerreport verkraftet fehlenden externKey
Klassenlehrer-Übersicht scheiterte komplett mit "Ungültige Zahl in Spalte
externKey", sobald ein Schüler der Klasse keinen gepflegten externen
Schlüssel hat (in echten Schuldaten vorkommend). externKey ist jetzt
optional statt Pflichtfeld (UntisStudent/UntisStudentDto.ExternKey -> int?);
betraf denselben Bericht wie der bestehende WebUntis-Klassenimport, der das
aber schon immer beim Weiterimport toleriert hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:04 +02:00
adminandClaude Sonnet 5 33c15ffe3e WIP (unstable): Klassenlehrer-Feature auf Einstellungen/eigenen Bereich umgestellt
Nutzer-Feedback: Klassenlehrer ist eine Rolle für die ganze Klasse, unabhängig vom
eigenen Unterricht - die Anbindung an ein LearningGroup.IsClassTeacher-Flag mit Tab
in der Gruppendetailansicht war deshalb der falsche Ort. Ersetzt durch:

- Klassenauswahl in den WebUntis-Einstellungen (WebUntisSettingsService.HomeroomClassName),
  über den bestehenden WebUntis-Klassenpicker.
- Eigener Sidebar-Bereich "Klassenlehrer" statt Gruppen-Tab.
- Zwei Ebenen: kompakte Roster-Übersicht mit Ampel-Symbolen (Fehlzeiten heute,
  Klassenbuch-Badge) und Details (die bisherigen Rohdaten-Listen, jetzt gefiltert
  auf einzelne Schüler*innen anspringbar).

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:30:34 +02:00
admin 5e16ebf1bf Update Sync 2026-08-25 21:30:00 +02:00
adminandClaude Sonnet 5 7245196689 WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)
Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:19:39 +02:00
adminandClaude Sonnet 5 f9f5f75aa8 Dokumentation: Eintrag von WebUntis-Klassenbuch-Abgleich ausschließen
Neues Feld Documentation.ExcludeFromWebUntisSync (Checkbox im Dialog) blendet
den Eintrag aus dem Zwischenablage-Vorschlag im Klassenbuch-Abgleich aus, für
Notizen, die nur für die eigene pädagogische Arbeit gedacht sind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:08:50 +02:00
admin 7fa8a93c82 Klassenbucheinträge abgleich und poll 2026-08-25 09:31:41 +02:00
admin 87a7badb44 Untis-API: Fehlzeiten abgleich 2026-08-25 00:21:53 +02:00
admin 17475a781f Untis-API Optimierung. 2026-08-24 22:33:30 +02:00
admin 1db16f8b69 Untis-API: Download Bug behoben 2026-08-24 22:06:42 +02:00
admin 34a9fdf73b Untis-API von Server zu Client 2026-08-24 22:00:26 +02:00
admin 0f10f754d0 Untis API Integration 2026-08-24 21:52:00 +02:00
admin 5841d96c5b Vorarbeit: WebUntis-API 2026-08-24 12:14:36 +02:00
adminandClaude Sonnet 5 a30aab0fa5 Fix: doppelte Mitarbeits-Sitzung durch Unterrichtsmodus-Instanz
EnsureTodaySession() prüfte nur den lokalen Instanz-Zustand, nicht das
Repository. Da der Unterrichtsmodus eine eigene, per DI frisch aufgelöste
SeatingPlanTabViewModel-Instanz nutzt (getrennt vom Sitzplan-Tab der
Kursübersicht), legte eine spätere Bewertung im normalen Tab eine zweite,
unverknüpfte Sitzung für denselben Tag an, statt die bereits über den
Unterrichtsmodus verknüpfte zu erkennen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:43:19 +02:00
adminandClaude Sonnet 5 07788dcfcb TODO: TickTick-Sync für Erinnerungen ergänzt
Recherche zur TickTick Open API (Auth per persönlichem Token, Push/Pull-
Endpunkte, Architektur analog zum bestehenden WebUntis-Abgleich) als
neuer Punkt 6.1.7 festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:12:47 +02:00
admin 788ae6ad32 Wetterdienst: Hover Bugfix 2026-08-23 21:51:26 +02:00
admin 83a430f8ee Wetterdienst: Erweitert 2026-08-23 21:45:25 +02:00
admin e76884a58e Wetterdienst: Verhersage verlängert, Anzeige im Stundenplan. 2026-08-23 21:31:15 +02:00
admin 1b32166ce0 Wetterdienst 2026-08-23 20:51:19 +02:00
admin cc973418f3 Wetterdienst 2026-08-23 20:51:06 +02:00
admin dd5ecb0334 Jahresplanimport und Ablgeich von Untis 2026-08-23 19:23:05 +02:00
adminandClaude Sonnet 5 4a59bff9de Merge branch 'unstable': WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:49 +02:00
adminandClaude Sonnet 5 4b9bcfe85d fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen
- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:36 +02:00
adminandClaude Sonnet 5 4eb4d0a946 WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:30:15 +02:00
281 changed files with 28067 additions and 2849 deletions
+3
View File
@@ -4,3 +4,6 @@
# Mindestens 32 zufällige Zeichen z.B. generiert mit: # Mindestens 32 zufällige Zeichen z.B. generiert mit:
# openssl rand -base64 32 # openssl rand -base64 32
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
+24
View File
@@ -0,0 +1,24 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/dotnet/sdk:10.0
steps:
- uses: actions/checkout@v4
- name: Restore
run: dotnet restore LehrerApp.sln
- name: Build
run: dotnet build LehrerApp.sln --no-restore --configuration Release
- name: Test
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
+15 -1
View File
@@ -5,7 +5,7 @@
"name": "Desktop App starten", "name": "Desktop App starten",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "Desktop bauen", "preLaunchTask": "TemplateDesigner bauen",
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll", "program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
"args": [], "args": [],
"cwd": "${workspaceFolder}/LehrerApp.Desktop", "cwd": "${workspaceFolder}/LehrerApp.Desktop",
@@ -15,6 +15,20 @@
"DOTNET_ENVIRONMENT": "Development" "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", "name": "API Server starten",
"type": "coreclr", "type": "coreclr",
+13
View File
@@ -27,6 +27,19 @@
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"group": "build" "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", "label": "Solution bauen",
"command": "dotnet", "command": "dotnet",
+4
View File
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`). plaintext, see `PlainSyncEvent` vs `SyncEvent`).
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus - **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is `SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`). configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests` - Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit. Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
### MVVM conventions (Desktop) ### MVVM conventions (Desktop)
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator --> <!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
<NoWarn>CS8618</NoWarn> <NoWarn>CS8618</NoWarn>
</PropertyGroup> </PropertyGroup>
+3 -2
View File
@@ -17,9 +17,10 @@
<!-- Datenbank --> <!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" /> <PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit, <!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
<PackageVersion Include="QuestPDF" Version="2025.7.0" /> <PackageVersion Include="QuestPDF" Version="2025.7.0" />
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
<PackageVersion Include="PdfPig" Version="0.1.13" />
<!-- API --> <!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
@@ -0,0 +1,118 @@
using System.IO.Compression;
using System.Text;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class DwdWeatherServiceTests
{
[Fact]
public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation()
{
const string catalog = """
ID ICAO NAME LAT LON ELEV
----- ---- -------------------- ----- ------- -----
10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119
10382 EDDT BERLIN-TEGEL 52.56 13.31 37
""";
var stations = DwdWeatherService.ParseStationCatalog(catalog);
var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7);
Assert.Equal(2, stations.Count);
Assert.Equal("10641", nearest!.Id);
Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name);
}
[Fact]
public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull()
{
var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'");
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:IssueTime>2026-08-23T15:00:00Z</dwd:IssueTime>
<dwd:ForecastTimeSteps><dwd:TimeStep>{{now}}</dwd:TimeStep></dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>293.15</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="R101"><dwd:value>67.6</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="SunD1"><dwd:value>1800</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
var hour = Assert.Single(parsed.Hours);
Assert.Equal(20, hour.TemperatureC);
Assert.Equal(18, hour.WindSpeedKmh);
Assert.Null(hour.WindGustKmh);
Assert.Equal(1.3, hour.PrecipitationMm);
Assert.Equal(68, hour.PrecipitationProbabilityPercent);
Assert.Equal(30, hour.SunshineDurationMinutes);
Assert.Equal(61, hour.WeatherCode);
}
[Fact]
public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
{
var start = DateTime.UtcNow.AddHours(1);
var timeSteps = string.Join("", Enumerable.Range(0, 100)
.Select(i => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
Assert.Equal(100, parsed.Hours.Count);
}
[Fact]
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
{
var xml = """
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>warnung-1</identifier><status>Actual</status>
<info><language>de-DE</language><event>GEWITTER</event><severity>Severe</severity>
<onset>2026-08-23T18:00:00Z</onset><expires>2099-08-23T20:00:00Z</expires>
<headline>Amtliche Warnung</headline><description>Test</description>
<area><polygon>50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0</polygon></area>
</info>
</alert>
""";
var warning = Assert.Single(DwdWeatherService.ParseCapArchive(
Zip("warning.xml", xml), DateTime.UtcNow));
Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5));
Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5));
Assert.Equal("GEWITTER", warning.Warning.Event);
}
private static byte[] Zip(string fileName, string content)
{
using var memory = new MemoryStream();
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(fileName);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
writer.Write(content);
}
return memory.ToArray();
}
}
@@ -0,0 +1,46 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class NominatimGeocoderTests
{
[Fact]
public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName()
{
Uri? requestedUri = null;
var handler = new RecordingHandler(request =>
{
requestedUri = request.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""",
Encoding.UTF8, "application/json"),
};
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") };
var geocoder = new NominatimGeocoder(http);
var result = await geocoder.GeocodeAsync(new SchoolLocationRequest
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach",
}, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(50.105, result!.Latitude);
Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName);
Assert.Contains("countrycodes=de", requestedUri!.Query);
Assert.Contains("street=Schulweg%201", requestedUri.Query);
Assert.Contains("postalcode=63000", requestedUri.Query);
}
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(response(request));
}
}
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SchoolWeatherStoreTests
{
[Fact]
public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4));
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City);
Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City);
}
[Fact]
public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow });
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Null(temp.Store.GetWeather("eins"));
}
private static SchoolLocationProfile Location(string city, double lat, double lon) => new()
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city,
Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow,
};
private sealed class TempStore : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}");
public SchoolWeatherStore Store { get; }
public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); }
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true);
}
}
}
+272
View File
@@ -0,0 +1,272 @@
using System.Globalization;
using System.IO.Compression;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
/// <summary>Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
/// SchoolWeatherStore persistiert.</summary>
public sealed class DwdWeatherService(HttpClient http)
{
private const string StationCatalogUrl =
"https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
private const string ForecastUrl =
"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
private const string WarningsUrl =
"https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
private readonly SemaphoreSlim _gate = new(1, 1);
private List<MosmixStation>? _stations;
private DateTime _stationsLoadedAt;
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
private CachedWarnings? _warningsCache;
public async Task<WeatherSnapshot> GetAsync(SchoolLocationProfile location,
CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var stations = await GetStationsAsync(cancellationToken);
var station = FindNearestStation(stations, location.Latitude, location.Longitude)
?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
var forecast = await GetForecastAsync(station, cancellationToken);
var warnings = await GetWarningsAsync(cancellationToken);
return new WeatherSnapshot
{
StationId = station.Id,
StationName = station.Name,
StationDistanceKm = Math.Round(DistanceKm(
location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
ForecastIssuedAt = forecast.IssuedAt,
RetrievedAt = DateTime.UtcNow,
Forecast = forecast.Hours,
Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
.Select(w => w.Warning)
.OrderByDescending(w => SeverityRank(w.Severity))
.ThenBy(w => w.Onset)
.ToList(),
};
}
finally { _gate.Release(); }
}
private async Task<List<MosmixStation>> GetStationsAsync(CancellationToken cancellationToken)
{
if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
return _stations;
var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
_stations = ParseStationCatalog(text);
_stationsLoadedAt = DateTime.UtcNow;
return _stations;
}
private async Task<ParsedForecast> GetForecastAsync(MosmixStation station,
CancellationToken cancellationToken)
{
if (_forecastCache.TryGetValue(station.Id, out var cached) &&
DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
return cached.Forecast;
var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
ForecastUrl, station.Id), cancellationToken);
var parsed = ParseMosmix(bytes);
_forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
return parsed;
}
private async Task<List<ParsedWarning>> GetWarningsAsync(CancellationToken cancellationToken)
{
if (_warningsCache is not null &&
DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
return _warningsCache.Warnings;
var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
_warningsCache = new(DateTime.UtcNow, warnings);
return warnings;
}
public static List<MosmixStation> ParseStationCatalog(string text)
{
var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
var result = new List<MosmixStation>();
foreach (var line in text.Split('\n'))
{
var match = regex.Match(line.TrimEnd('\r'));
if (!match.Success ||
!double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
!double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
continue;
result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
}
return result;
}
public static MosmixStation? FindNearestStation(IEnumerable<MosmixStation> stations,
double latitude, double longitude) => stations.MinBy(s =>
DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
public static ParsedForecast ParseMosmix(byte[] kmz)
{
using var memory = new MemoryStream(kmz);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
var times = document.Descendants(dwd + "TimeStep")
.Select(x => ParseDate(x.Value))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
var values = document.Descendants(dwd + "Forecast")
.Where(x => x.Attribute(dwd + "elementName") is not null)
.ToDictionary(
x => x.Attribute(dwd + "elementName")!.Value,
x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
StringComparer.Ordinal);
double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
? series[i] : null;
var hours = new List<WeatherForecastHour>();
var earliest = DateTime.UtcNow.AddHours(-1);
// MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
// sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
for (var i = 0; i < times.Count && hours.Count < 240; i++)
{
if (times[i] < earliest) continue;
hours.Add(new WeatherForecastHour
{
ValidAt = times[i],
TemperatureC = Round(Value("TTT", i) - 273.15, 1),
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
PrecipitationMm = Round(Value("RR1c", i), 1),
// Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die
// Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor.
PrecipitationProbabilityPercent = Round(Value("R101", i), 0),
SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0),
CloudCoverPercent = Round(Value("Neff", i), 0),
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
});
}
return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
}
public static List<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
{
var result = new List<ParsedWarning>();
using var memory = new MemoryStream(zip);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
{
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
var root = document.Root;
if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
var info = root.Elements(cap + "info").FirstOrDefault(x =>
x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
?? root.Elements(cap + "info").FirstOrDefault();
if (info is null) continue;
var expires = ParseDate(info.Element(cap + "expires")?.Value);
if (expires is { } until && until < nowUtc) continue;
var polygons = info.Elements(cap + "area")
.SelectMany(x => x.Elements(cap + "polygon"))
.Select(x => ParsePolygon(x.Value))
.Where(x => x.Count >= 3)
.ToList();
// Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
// zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
if (polygons.Count == 0) continue;
result.Add(new ParsedWarning(new WeatherWarning
{
Identifier = root.Element(cap + "identifier")?.Value ?? "",
Event = info.Element(cap + "event")?.Value ?? "",
Headline = info.Element(cap + "headline")?.Value ?? "",
Description = info.Element(cap + "description")?.Value ?? "",
Instruction = info.Element(cap + "instruction")?.Value ?? "",
Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
Onset = ParseDate(info.Element(cap + "onset")?.Value),
Expires = expires,
}, polygons));
}
return result;
}
private static List<double?> ParseValues(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? (double?)value : null)
.ToList();
private static List<GeoPoint> ParsePolygon(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Split(','))
.Where(x => x.Length >= 2 &&
double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
.Select(x => new GeoPoint(
double.Parse(x[0], CultureInfo.InvariantCulture),
double.Parse(x[1], CultureInfo.InvariantCulture)))
.ToList();
public static bool PointInPolygon(IReadOnlyList<GeoPoint> polygon, double latitude, double longitude)
{
var inside = false;
for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
{
var a = polygon[i];
var b = polygon[j];
if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
(b.Latitude - a.Latitude) + a.Longitude)
inside = !inside;
}
return inside;
}
private static bool Contains(IEnumerable<IReadOnlyList<GeoPoint>> polygons,
double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
{
const double earthRadiusKm = 6371.0;
static double Rad(double degrees) => degrees * Math.PI / 180.0;
var dLat = Rad(lat2 - lat1);
var dLon = Rad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
private static DateTime? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
out var parsed) ? parsed.UtcDateTime : null;
private static double? Round(double? value, int digits) => value is null
? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
private static int SeverityRank(string severity) => severity switch
{
"Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
};
public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
public sealed record ParsedForecast(DateTime? IssuedAt, List<WeatherForecastHour> Hours);
public sealed record GeoPoint(double Latitude, double Longitude);
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
}
+118
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Globalization;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -149,6 +150,123 @@ public static class Endpoints
}); });
} }
// ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
public static void MapSchoolWeatherEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/school").RequireAuthorization();
g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
return location is null
? Results.NotFound("Noch kein Schulstandort hinterlegt.")
: Results.Ok(location);
});
g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var errors = ValidateLocation(request);
if (errors.Count > 0) return Results.ValidationProblem(errors);
var existing = store.GetLocation(uid);
if (existing is not null && SameLocation(existing, request))
return Results.Ok(existing);
GeocodingResult? result;
try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
catch (HttpRequestException)
{
return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (result is null)
return Results.UnprocessableEntity(new
{
detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
});
var profile = new SchoolLocationProfile
{
SchoolName = request.SchoolName.Trim(),
Street = request.Street.Trim(),
PostalCode = request.PostalCode.Trim(),
City = request.City.Trim(),
State = request.State,
Latitude = result.Latitude,
Longitude = result.Longitude,
ResolvedAddress = result.DisplayName,
UpdatedAt = DateTime.UtcNow,
};
store.SaveLocation(uid, profile);
return Results.Ok(profile);
});
g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
DwdWeatherService weather, CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
var cached = store.GetWeather(uid);
if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
return Results.Ok(cached);
try
{
var current = await weather.GetAsync(location, cancellationToken);
store.SaveWeather(uid, current);
return Results.Ok(current);
}
catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
{
if (cached is not null)
{
cached.IsStale = true;
return Results.Ok(cached);
}
return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
});
}
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.SchoolName))
errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
if (string.IsNullOrWhiteSpace(request.Street))
errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
if (string.IsNullOrWhiteSpace(request.City))
errors[nameof(request.City)] = ["Bitte den Ort angeben."];
if (!Enum.IsDefined(request.State))
errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
return errors;
}
private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
existing.State == request.State;
// ── JWT ─────────────────────────────────────────────────────────────────── // ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret) private static string Jwt(string userId, string secret)
+2 -1
View File
@@ -1,4 +1,5 @@
using LiteDB; using LiteDB;
using LehrerApp.Sync;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
namespace LehrerApp.Api; namespace LehrerApp.Api;
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
{ {
var col = GetCol(userId); var col = GetCol(userId);
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId) var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSeq).Take(500) .OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId, .Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp, DeviceType = e.DeviceType, Timestamp = e.Timestamp,
SequenceNr = e.ServerSeq, EntityType = e.EntityType, SequenceNr = e.ServerSeq, EntityType = e.EntityType,
+78
View File
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public interface IGeocoder
{
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
}
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
{
private static readonly SemaphoreSlim RequestGate = new(1, 1);
private static DateTime _lastRequestAt = DateTime.MinValue;
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
CancellationToken cancellationToken)
{
var structured = Query(new Dictionary<string, string>
{
["street"] = address.Street,
["postalcode"] = address.PostalCode,
["city"] = address.City,
});
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
if (result is not null) return result;
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
var freeForm = string.Join(", ", new[]
{
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return await SearchRespectingRateLimitAsync(
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
}
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
CancellationToken cancellationToken)
{
await RequestGate.WaitAsync(cancellationToken);
try
{
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
_lastRequestAt = DateTime.UtcNow;
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
var candidate = candidates?.FirstOrDefault();
if (candidate is null ||
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
return null;
return new(latitude, longitude, candidate.DisplayName ?? "");
}
finally { RequestGate.Release(); }
}
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
private sealed class NominatimCandidate
{
[JsonPropertyName("lat")]
public string? Lat { get; set; }
[JsonPropertyName("lon")]
public string? Lon { get; set; }
[JsonPropertyName("display_name")]
public string? DisplayName { get; set; }
}
}
+17
View File
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
var secret = builder.Configuration["JWT_SECRET"] var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new() .AddJwtBearer(o => o.TokenValidationParameters = new()
@@ -81,6 +83,20 @@ builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp => builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>())); new PlainEventStore(sp.GetRequiredService<EventStore>()));
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(client =>
{
client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddHttpClient("dwd", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton(sp => new DwdWeatherService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(); app.UseForwardedHeaders();
@@ -95,5 +111,6 @@ app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints(); app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints(); app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints(); app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints();
app.Run(); app.Run();
return 0; return 0;
+83
View File
@@ -0,0 +1,83 @@
using LehrerApp.Core.Models;
using LiteDB;
namespace LehrerApp.Api;
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
public sealed class SchoolWeatherStore : IDisposable
{
private readonly LiteDatabase _db;
public SchoolWeatherStore(string dataPath)
{
Directory.CreateDirectory(dataPath);
_db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
}
private ILiteCollection<LocationEntry> Locations
{
get
{
var col = _db.GetCollection<LocationEntry>("locations");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
private ILiteCollection<WeatherEntry> Weather
{
get
{
var col = _db.GetCollection<WeatherEntry>("weather");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
public SchoolLocationProfile? GetLocation(string userId) =>
Locations.FindOne(x => x.UserId == userId)?.Profile;
public void SaveLocation(string userId, SchoolLocationProfile profile)
{
var existing = Locations.FindOne(x => x.UserId == userId);
Locations.Upsert(new LocationEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Profile = profile,
});
// Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
Weather.DeleteMany(x => x.UserId == userId);
}
public WeatherSnapshot? GetWeather(string userId) =>
Weather.FindOne(x => x.UserId == userId)?.Snapshot;
public void SaveWeather(string userId, WeatherSnapshot snapshot)
{
var existing = Weather.FindOne(x => x.UserId == userId);
Weather.Upsert(new WeatherEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Snapshot = snapshot,
});
}
public void Dispose() => _db.Dispose();
private sealed class LocationEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public SchoolLocationProfile Profile { get; set; } = new();
}
private sealed class WeatherEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public WeatherSnapshot Snapshot { get; set; } = new();
}
}
@@ -124,9 +124,70 @@ public interface ISubstitutionEntryRepository
{ {
List<SubstitutionEntry> GetAll(); List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date); List<SubstitutionEntry> GetByDate(DateOnly date);
/// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID).
SubstitutionEntry? GetByExternalId(string externalId);
void Save(SubstitutionEntry entry); void Save(SubstitutionEntry entry);
void Delete(Guid id); void Delete(Guid id);
} }
/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf.
public interface IUntisSnapshotRepository
{
List<UntisSnapshotEntry> GetAll();
void Save(UntisSnapshotEntry entry);
void Delete(Guid id);
}
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
public interface IUntisAbsenceCacheRepository
{
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
}
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
public interface IUntisClassRegisterCacheRepository
{
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
}
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
public interface IUntisStudentRosterCacheRepository
{
List<UntisStudentRosterCacheEntry> GetByClass(string className);
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
}
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
public interface IUntisCacheFetchStateRepository
{
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
void Save(UntisCacheFetchState state);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
List<UntisSlotMapping> GetAll();
void Save(UntisSlotMapping mapping);
void Delete(Guid id);
}
/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis-
/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben
/// externen Feed selbst.
public interface IAnnualPlanEventRepository
{
List<AnnualPlanEvent> GetAll();
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
AnnualPlanEvent? GetByExternalId(string externalId);
void Save(AnnualPlanEvent entry);
void Delete(Guid id);
}
public interface IDocumentationRepository public interface IDocumentationRepository
{ {
List<Documentation> GetByStudent(Guid studentId); List<Documentation> GetByStudent(Guid studentId);
@@ -164,6 +225,9 @@ public interface IParticipationSessionRepository
{ {
List<ParticipationSession> GetByGroup(Guid groupId); List<ParticipationSession> GetByGroup(Guid groupId);
ParticipationSession? GetById(Guid id); ParticipationSession? GetById(Guid id);
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
List<ParticipationSession> GetAll();
void Save(ParticipationSession session); void Save(ParticipationSession session);
void Delete(Guid id); void Delete(Guid id);
} }
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
/// </summary>
public class AnnualPlanEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
public string ExternalId { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Location { get; set; } = "";
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
/// <summary>
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
/// lokale Schulzeit Europe/Berlin umgerechnet.
/// </summary>
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public bool IsAllDay { get; set; }
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
public string CalendarGroup { get; set; } = "";
public string Color { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public int Sequence { get; set; }
public DateTime? SourceLastModifiedUtc { get; set; }
}
+6
View File
@@ -13,6 +13,12 @@ public class Exam
public DateOnly? ReturnedAt { get; set; } public DateOnly? ReturnedAt { get; set; }
public Niveau? Niveau { get; set; } public Niveau? Niveau { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public DateOnly? ApprovalGrantedAt { get; set; }
public DateOnly? AnnouncedAt { get; set; }
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
/// (siehe ExamDialogViewModel.Save).
public Guid? SharedExamGroupId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
+13
View File
@@ -20,6 +20,19 @@ public class LearningGroup
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus. /// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
/// </summary> /// </summary>
public bool RequiresLessonPlanning { get; set; } = true; public bool RequiresLessonPlanning { get; set; } = true;
/// <summary>
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
/// </summary>
public int? WebUntisLessonId { 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 CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { 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. // Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
public bool HomeworkMissing { get; set; } public bool HomeworkMissing { get; set; }
public AttendanceStatus? Attendance { 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 DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
} }
public enum DayHighlightKind { Standout, Sleepy, Rough }
/// <summary> /// <summary>
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine /// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate /// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
_ => value, _ => 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,
};
}
}
+10 -1
View File
@@ -100,7 +100,10 @@ public class LessonPhaseStep
} }
public enum UnitStatus { Planned, Active, Completed } public enum UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted } // 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 }
/// <summary> /// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei /// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
@@ -210,6 +213,12 @@ public class SubstitutionEntry
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse"). /// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string? Notes { get; set; } public string? Notes { get; set; }
/// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist —
/// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe
/// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und
/// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen
/// (SubstitutionEntryDialogViewModel) bleibt es null.
public string? ExternalId { get; set; }
} }
/// <summary> /// <summary>
+79
View File
@@ -0,0 +1,79 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
/// direkt für Bereichs-Queries nutzbar.
/// </summary>
public class UntisAbsenceCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public string StudentName { get; set; } = "";
public int? ExternKey { get; set; }
public int Date { get; set; }
public int AbsentPeriods { get; set; }
public int AbsentMinutes { get; set; }
public string? TeacherUsernames { get; set; }
public string? Subject { get; set; }
public string? AbsenceReason { get; set; }
public string? Note { get; set; }
public int? EntryId { get; set; }
public string? HandledOn { get; set; }
public bool Counts { get; set; }
public string? ExcuseNote { get; set; }
public int? PeriodNumber { get; set; }
public string? Status { get; set; }
public bool CountsAsFullDay { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
public class UntisClassRegisterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int Date { get; set; }
public string? Subject { get; set; }
public string StudentName { get; set; } = "";
public string? TeacherUsername { get; set; }
public string? CategoryName { get; set; }
public string? CategoryGroup { get; set; }
public string? Text { get; set; }
}
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
public class UntisStudentRosterCacheEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public int? ExternKey { get; set; }
public string DisplayName { get; set; } = "";
}
public enum UntisCacheKind { Absences, ClassRegister, Roster }
/// <summary>
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
/// </summary>
public class UntisCacheFetchState
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClassName { get; set; } = "";
public UntisCacheKind Kind { get; set; }
public DateTime? HotWindowFetchedAt { get; set; }
public int? ColdCoverageStartDate { get; set; }
}
+69
View File
@@ -0,0 +1,69 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Zuletzt bekannter Zustand eines einzelnen WebUntis-iCal-Termins (ein VEVENT) — die lokale
/// "Sicherungskopie", gegen die jeder neue Abruf verglichen wird, um Änderungen zu erkennen
/// (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). <see cref="Uid"/>
/// ist die stabile iCal-UID des Termins (pro Wochen-Slot über das ganze Schuljahr gleich) und
/// dient als fachlicher Schlüssel für den Abgleich — bewusst kein <c>[BsonId]</c> darauf, da
/// LehrerApp.Core absichtlich frei von LiteDB/Avalonia-Abhängigkeiten bleibt (siehe CLAUDE.md);
/// die Suche nach <see cref="Uid"/> läuft stattdessen über eine gefilterte Repository-Abfrage.
/// </summary>
public class UntisSnapshotEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Vom Nutzer bestätigte Zuordnung eines regulären WebUntis-Wochenmusters (Wochentag + Uhrzeit +
/// Fach-Kürzel + Klassen-Token aus der Beschreibung) zu einer bestehenden <see cref="LearningGroup"/>
/// (<see cref="SubstitutionKind.Lesson"/>) oder — für Termine ohne Klassenbezug, z.B. Aufsichten —
/// zu einer festen Pause (<see cref="SubstitutionKind.Supervision"/>, <see cref="AfterPeriod"/>
/// statt <see cref="GroupId"/>/<see cref="PeriodNumber"/>; Nutzer-Feedback: "Zwei Termine sind
/// meine Aufsichten, die nicht zugeordnet werden können [...] vom Zeitraster und von der Dauer her
/// könnten die erfasst werden" — Aufsichten liegen in Pausen, nicht auf einem Unterrichtsstunden-
/// Zeitraster, brauchen also einen eigenen Auflösungsweg statt PeriodNumber). Nur bestätigte
/// (<see cref="Confirmed"/> true) Zuordnungen lösen automatisch geschriebene
/// <see cref="SubstitutionEntry"/>-Einträge aus (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung, bevor sie aktiv wird; siehe UntisMappingReviewDialog).
/// </summary>
public class UntisSlotMapping
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public TimeOnly StartTime { get; set; }
public string? Summary { get; set; }
public string ClassToken { get; set; } = "";
public SubstitutionKind Kind { get; set; } = SubstitutionKind.Lesson;
/// Nur bei <see cref="SubstitutionKind.Lesson"/> gesetzt.
public Guid? GroupId { get; set; }
/// Erste/primäre Stunde, zum Bestätigungszeitpunkt über PeriodScheduleService aufgelöst (siehe
/// UntisMatchingService) — hier gespeichert, damit UntisDiffService selbst framework-frei
/// bleibt und keine eigene Uhrzeit→Stunde-Auflösung braucht. Nur bei
/// <see cref="SubstitutionKind.Lesson"/> gesetzt.
public int? PeriodNumber { get; set; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt (siehe UntisSlotMatch.CoveredPeriods) —
/// bei einer Doppelstunde mehr als eine. Entscheidet, welche TimetableSlots als "durch WebUntis
/// bestätigt" gelten (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
/// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" — ohne diese Liste blieb
/// die zweite Stunde einer Doppelstunde immer "unzugeordnet", weil WebUntis dafür nur einen
/// einzigen, am Anfang beginnenden Termin meldet). Nur bei <see cref="SubstitutionKind.Lesson"/>
/// gesetzt.
public List<int> CoveredPeriods { get; set; } = [];
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, ebenfalls zum Bestätigungszeitpunkt aufgelöst
/// (die Pause direkt vor der WebUntis-Startzeit, 0 = vor der 1. Stunde).
public int? AfterPeriod { get; set; }
public bool Confirmed { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+63
View File
@@ -0,0 +1,63 @@
namespace LehrerApp.Core.Models;
/// <summary>Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
/// serverseitig beim expliziten Speichern, nicht während der Eingabe.</summary>
public class SchoolLocationRequest
{
public string SchoolName { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
public GermanState State { get; set; } = GermanState.NW;
}
/// <summary>Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.</summary>
public sealed class SchoolLocationProfile : SchoolLocationRequest
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string ResolvedAddress { get; set; } = "";
public DateTime UpdatedAt { get; set; }
}
public sealed class WeatherSnapshot
{
public string StationId { get; set; } = "";
public string StationName { get; set; } = "";
public double StationDistanceKm { get; set; }
public DateTime? ForecastIssuedAt { get; set; }
public DateTime RetrievedAt { get; set; }
/// <summary>True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
/// erfolgreich gespeicherten Stand zurückgibt.</summary>
public bool IsStale { get; set; }
public List<WeatherForecastHour> Forecast { get; set; } = [];
public List<WeatherWarning> Warnings { get; set; } = [];
}
public sealed class WeatherForecastHour
{
public DateTime ValidAt { get; set; }
public double? TemperatureC { get; set; }
public double? WindSpeedKmh { get; set; }
public double? WindGustKmh { get; set; }
public double? PrecipitationMm { get; set; }
/// <summary>Stündliche MOSMIX-Wahrscheinlichkeit für mehr als 0,1 mm Niederschlag.</summary>
public double? PrecipitationProbabilityPercent { get; set; }
/// <summary>MOSMIX-Sonnenscheindauer der letzten Stunde, in Minuten.</summary>
public double? SunshineDurationMinutes { get; set; }
public double? CloudCoverPercent { get; set; }
public int? WeatherCode { get; set; }
}
public sealed class WeatherWarning
{
public string Identifier { get; set; } = "";
public string Event { get; set; } = "";
public string Headline { get; set; } = "";
public string Description { get; set; } = "";
public string Instruction { get; set; } = "";
public string Severity { get; set; } = "";
public DateTime? Onset { get; set; }
public DateTime? Expires { get; set; }
}
+5 -1
View File
@@ -24,6 +24,10 @@ public class Documentation : IHasAttachments
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary> /// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
public bool IsDraft { get; set; } public bool IsDraft { get; set; }
public bool IsConfidential { get; set; } public bool IsConfidential { get; set; }
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
public bool ExcludeFromWebUntisSync { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4). // Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
@@ -89,7 +93,7 @@ public interface IHasAttachments
List<DocumentAttachment> Attachments { get; } List<DocumentAttachment> Attachments { get; }
} }
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben. // Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter } public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
public enum SupportStatus { Active, Completed, Paused } public enum SupportStatus { Active, Completed, Paused }
public class WorkTask public class WorkTask
@@ -0,0 +1,205 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
/// </summary>
public static class AnnualPlanIcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly record struct Property(string Head, string Value)
{
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
}
public static List<AnnualPlanEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
var result = new List<AnnualPlanEvent>();
Dictionary<string, Property>? current = null;
foreach (var line in lines)
{
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
continue;
}
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
result.Add(BuildEvent(current, result.Count + 1));
current = null;
continue;
}
if (current is null) continue;
var colon = line.IndexOf(':');
if (colon < 0) continue;
var head = line[..colon];
var separator = head.IndexOf(';');
var key = separator < 0 ? head : head[..separator];
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
}
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
return result;
}
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
{
var uid = Required(properties, "UID", number).Value.Trim();
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
var startProperty = Required(properties, "DTSTART", number);
var endProperty = properties.GetValueOrDefault("DTEND");
DateOnly startDate;
DateOnly endDate;
TimeOnly? startTime;
TimeOnly? endTime;
var isAllDay = startProperty.IsDate;
if (isAllDay)
{
startDate = ParseDate(startProperty.Value, number, "DTSTART");
var endExclusive = endProperty == default
? startDate.AddDays(1)
: endProperty.IsDate
? ParseDate(endProperty.Value, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
startTime = null;
endTime = null;
}
else
{
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
var end = endProperty == default
? start
: !endProperty.IsDate
? ParseLocalDateTime(endProperty, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
startDate = DateOnly.FromDateTime(start);
endDate = DateOnly.FromDateTime(end);
startTime = TimeOnly.FromDateTime(start);
endTime = TimeOnly.FromDateTime(end);
}
return new AnnualPlanEvent
{
ExternalId = uid,
Title = Text(properties, "SUMMARY"),
Description = Text(properties, "DESCRIPTION"),
Location = Text(properties, "LOCATION"),
StartDate = startDate,
EndDate = endDate,
StartTime = startTime,
EndTime = endTime,
IsAllDay = isAllDay,
CalendarGroup = Text(properties, "X-GROUPNAME"),
Color = Text(properties, "X-COLOR"),
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
};
}
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
private static string Raw(Dictionary<string, Property> properties, string key) =>
properties.TryGetValue(key, out var value) ? value.Value : "";
private static string Text(Dictionary<string, Property> properties, string key) =>
Unescape(Raw(properties, key));
private static DateOnly ParseDate(string value, int number, string field)
{
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)) return date;
throw Invalid(number, $"ungültiges {field}: {value}");
}
private static DateTime ParseLocalDateTime(Property property, int number, string field)
{
var utc = property.Value.EndsWith('Z');
var value = utc ? property.Value[..^1] : property.Value;
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
throw Invalid(number, $"ungültiges {field}: {property.Value}");
return utc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
private static DateTime? ParseOptionalUtc(Property property, int number)
{
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
return property.Value.EndsWith('Z')
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
}
private static List<string> Unfold(string text)
{
var result = new List<string>();
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
result[^1] += line[1..];
else
result.Add(line);
}
return result;
}
private static string Unescape(string value)
{
var result = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] != '\\' || i + 1 >= value.Length)
{
result.Append(value[i]);
continue;
}
var next = value[++i];
result.Append(next switch
{
'n' or 'N' => '\n',
'\\' => '\\',
',' => ',',
';' => ';',
_ => next,
});
}
return result.ToString();
}
private static FormatException Invalid(int number, string message) =>
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
}
+34
View File
@@ -14,6 +14,13 @@ public class BackupService
public string BackupDirectory => _backupDirectory; public string BackupDirectory => _backupDirectory;
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
public string? SecondaryBackupDirectory { get; set; }
public BackupService(string appDataPath) public BackupService(string appDataPath)
{ {
_backupDirectory = Path.Combine(appDataPath, "backups"); _backupDirectory = Path.Combine(appDataPath, "backups");
@@ -34,9 +41,36 @@ public class BackupService
File.Copy(databasePath, target, overwrite: true); File.Copy(databasePath, target, overwrite: true);
PruneOldBackups(keepCount); PruneOldBackups(keepCount);
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
TryMirrorToSecondary(target, fileName, keepCount);
return target; return target;
} }
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
{
try
{
Directory.CreateDirectory(SecondaryBackupDirectory!);
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
.Select(p => new FileInfo(p))
.OrderByDescending(f => f.LastWriteTime)
.Skip(keepCount))
{
try { stale.Delete(); }
catch { /* nächster Lauf versucht es erneut */ }
}
}
catch
{
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
}
}
public List<BackupInfo> ListBackups() => public List<BackupInfo> ListBackups() =>
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
@@ -15,7 +15,8 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder = public static readonly string[] DefaultCardOrder =
[ [
"today", "tasks", "calendar", "excuses", "upcoming", "today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
]; ];
private readonly string _configPath; private readonly string _configPath;
@@ -0,0 +1,67 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
public static class ExamCorrectionCounter
{
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
List<ExamResult> examResults)
{
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
return (expected, Math.Min(evaluated, expected));
}
}
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
public static class ExamPriorityService
{
public const int StuckAfterDays = 21;
private const int CorrectionGraceDays = 3;
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
{
if (exam.Status == ExamStatus.Returned)
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
if (exam.Status == ExamStatus.Graded)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (exam.Status == ExamStatus.Planned)
{
var daysUntil = exam.Date.DayNumber - today.DayNumber;
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
}
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
var daysSince = today.DayNumber - exam.Date.DayNumber;
if (evaluated <= 0)
{
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
}
if (expected > 0 && evaluated >= expected)
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
if (daysSince > StuckAfterDays)
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
}
}
@@ -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) public double WeightedAverage(List<(string Grade, double Weight)> grades)
{ {
var numeric = 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(); .Where(g => g.Value.HasValue).ToList();
if (numeric.Count == 0) return 0; if (numeric.Count == 0) return 0;
var total = numeric.Sum(g => g.Weight); var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total; 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. /// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard). /// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note /// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
@@ -60,6 +60,9 @@ public sealed class GroupRolloverService(
IsActive = true, IsActive = true,
IsOwnClass = source.IsOwnClass, IsOwnClass = source.IsOwnClass,
IsDifferentiated = source.IsDifferentiated, 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.
}; };
var sourceWasActive = source.IsActive; var sourceWasActive = source.IsActive;
+143
View File
@@ -0,0 +1,143 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Core.Services;
/// <summary>Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem
/// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich").</summary>
public class UntisIcsEvent
{
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public DayOfWeek Weekday => Date.DayOfWeek;
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
}
/// <summary>
/// Bewusst kein vollständiger RFC-5545-Parser, sondern genau der Ausschnitt, den der reale
/// WebUntis-Export tatsächlich nutzt (verifiziert an einem echten Export, siehe Planungsdokument):
/// flache VEVENT-Liste ohne RRULE-Wiederholung, ohne Line-Folding, ohne VALARM. `DTSTART;TZID=...`
/// wird als lokale Wanduhrzeit gelesen (die App kennt ohnehin nur "lokale Zeit" der Schule, siehe
/// PeriodScheduleService) — ein `...Z`-Suffix (UTC) wird defensiv unterstützt, auch wenn im realen
/// Export nicht beobachtet. Termine mit unbekanntem DTSTART-Format werden übersprungen statt die
/// gesamte Verarbeitung abzubrechen (gleiche Fail-soft-Haltung wie EventApplier in LehrerApp.Sync).
/// </summary>
public static class IcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static List<UntisIcsEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
var events = new List<UntisIcsEvent>();
Dictionary<string, (string Params, string Value)>? current = null;
foreach (var line in lines)
{
if (line == "BEGIN:VEVENT") { current = []; continue; }
if (line == "END:VEVENT")
{
if (current is not null && TryBuildEvent(current, out var evt)) events.Add(evt);
current = null;
continue;
}
if (current is null) continue;
var (key, paramsPart, value) = SplitPropertyLine(line);
if (key.Length == 0) continue;
current[key] = (paramsPart, value);
}
return events;
}
// RFC 5545 Line-Folding: eine Fortsetzungszeile beginnt mit Leerzeichen/Tab und gehört zur
// Vorzeile — im realen WebUntis-Export nicht beobachtet (Zeilen deutlich unter dem 75-Oktett-
// Limit), aber defensiv unterstützt, falls sich der Export einmal ändert.
private static List<string> Unfold(string icsText)
{
var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var result = new List<string>();
foreach (var raw in rawLines)
{
if ((raw.StartsWith(' ') || raw.StartsWith('\t')) && result.Count > 0)
result[^1] += raw[1..];
else
result.Add(raw);
}
return result;
}
// "KEY;PARAM=VAL;PARAM2=VAL2:VALUE" — der erste unescapte Doppelpunkt trennt Wert vom Rest.
private static (string Key, string Params, string Value) SplitPropertyLine(string line)
{
var colonIndex = line.IndexOf(':');
if (colonIndex < 0) return ("", "", "");
var head = line[..colonIndex];
var value = line[(colonIndex + 1)..];
var semiIndex = head.IndexOf(';');
return semiIndex < 0 ? (head, "", value) : (head[..semiIndex], head[(semiIndex + 1)..], value);
}
private static bool TryBuildEvent(Dictionary<string, (string Params, string Value)> props, out UntisIcsEvent evt)
{
evt = new UntisIcsEvent();
if (!props.TryGetValue("UID", out var uid) || string.IsNullOrWhiteSpace(uid.Value)) return false;
evt.Uid = uid.Value;
if (!props.TryGetValue("DTSTART", out var dtStart) || !TryParseLocalDateTime(dtStart, out var start))
return false;
evt.Date = DateOnly.FromDateTime(start);
evt.StartTime = TimeOnly.FromDateTime(start);
if (props.TryGetValue("DTEND", out var dtEnd) && TryParseLocalDateTime(dtEnd, out var end))
evt.EndTime = TimeOnly.FromDateTime(end);
evt.Summary = props.TryGetValue("SUMMARY", out var summary) && !string.IsNullOrWhiteSpace(summary.Value)
? Unescape(summary.Value) : null;
evt.Location = props.TryGetValue("LOCATION", out var location) ? Unescape(location.Value) : "";
evt.Description = props.TryGetValue("DESCRIPTION", out var description) ? Unescape(description.Value) : "";
evt.Status = props.TryGetValue("STATUS", out var status) && !string.IsNullOrWhiteSpace(status.Value)
? status.Value : "CONFIRMED";
return true;
}
// "TZID=Europe/Berlin:20260817T075000" (lokale Wanduhrzeit) oder "20260817T075000Z" (UTC).
private static bool TryParseLocalDateTime((string Params, string Value) prop, out DateTime local)
{
local = default;
var value = prop.Value;
var isUtc = value.EndsWith('Z');
var digits = isUtc ? value[..^1] : value;
if (!DateTime.TryParseExact(digits, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
return false;
local = isUtc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: parsed;
return true;
}
private static string Unescape(string value)
{
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
var next = value[i + 1];
sb.Append(next switch { ';' => ';', ',' => ',', 'n' or 'N' => '\n', '\\' => '\\', _ => next });
i++;
}
else sb.Append(value[i]);
}
return sb.ToString();
}
}
@@ -0,0 +1,99 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
public sealed class LessonSchedulingService(ILessonRepository lessons)
{
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
{
EnsureTargetIsFree(lesson, newDate, newPeriod);
var oldDate = lesson.Date;
var delta = newDate.DayNumber - oldDate.DayNumber;
if (shiftFollowing && delta != 0)
{
foreach (var other in lessons.GetByUnit(lesson.UnitId))
{
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
continue;
other.Date = other.Date.AddDays(delta);
lessons.Save(other);
}
}
lesson.Date = newDate;
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
lessons.Save(lesson);
}
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
int newPeriod, TimeOnly? newStartTime)
{
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
EnsureTargetIsFree(source, newDate, newPeriod);
var first = new List<LessonPhaseStep>();
var second = new List<LessonPhaseStep>();
var elapsed = 0;
foreach (var phase in source.Phases)
{
var remainingInFirst = splitAfterMinutes - elapsed;
if (remainingInFirst <= 0)
second.Add(Clone(phase, phase.DurationMinutes));
else if (phase.DurationMinutes <= remainingInFirst)
first.Add(Clone(phase, phase.DurationMinutes));
else
{
first.Add(Clone(phase, remainingInFirst));
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
}
elapsed += phase.DurationMinutes;
}
source.Phases = first;
var continuation = new Lesson
{
UnitId = source.UnitId,
GroupId = source.GroupId,
Date = newDate,
LessonNumber = newPeriod,
StartTime = newStartTime,
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} Fortsetzung",
Phases = second,
Homework = source.Homework,
HomeworkChecked = source.HomeworkChecked,
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
Reflection = source.Reflection,
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
};
source.Homework = null;
source.HomeworkChecked = false;
source.HomeworkCheckDismissed = false;
source.Reflection = null;
lessons.Save(source);
lessons.Save(continuation);
return continuation;
}
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
{
if (period is null) return;
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
.Any(l => l.Id != source.Id && l.LessonNumber == period);
if (occupied)
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
}
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
{
Name = source.Name,
DurationMinutes = duration,
Activity = source.Activity,
Material = source.Material,
Shorthand = source.Shorthand,
AlternativePathId = source.AlternativePathId,
};
}
@@ -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,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);
}
}
+228
View File
@@ -0,0 +1,228 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed class UntisDiffResult
{
/// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über
/// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert
/// statt dupliziert werden muss; siehe UntisSyncService).
public List<SubstitutionEntry> SubstitutionsToSave { get; init; } = [];
public List<UntisSnapshotEntry> SnapshotToSave { get; init; } = [];
/// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt,
/// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird.
public List<Guid> SnapshotIdsToDelete { get; init; } = [];
/// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht
/// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine
/// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend
/// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so"
/// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte
/// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim
/// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen.
/// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService).
public List<string> SubstitutionExternalIdsToDelete { get; init; } = [];
}
/// <summary>
/// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten
/// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden").
/// Framework-frei wie <see cref="UntisMatchingService"/> — nimmt nur einfache Objekte/Listen
/// entgegen, kein Datenbankzugriff.
/// </summary>
public class UntisDiffService
{
public const int DefaultLookaheadDays = 14;
public UntisDiffResult Diff(List<UntisIcsEvent> newEvents, List<UntisSnapshotEntry> previousSnapshot,
List<UntisSlotMapping> confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays,
List<SupervisionDuty>? existingSupervisionDuties = null, HashSet<DateOnly>? freeDates = null)
{
var previousByUid = previousSnapshot.ToDictionary(s => s.Uid);
// GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot
// (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer
// ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen.
var mappingByWeekdayStart = confirmedMappings
.Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null))
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
// Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe
// SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die
// KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll
// deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen.
var regularDutyKeys = (existingSupervisionDuties ?? [])
.Select(d => (d.Weekday, d.AfterPeriod))
.ToHashSet();
var substitutions = new List<SubstitutionEntry>();
var snapshotToSave = new List<UntisSnapshotEntry>();
var externalIdsToDelete = new List<string>();
var newEventsByUid = newEvents.ToDictionary(e => e.Uid);
foreach (var evt in newEvents)
{
snapshotToSave.Add(new UntisSnapshotEntry
{
Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(),
Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime,
Summary = evt.Summary, Location = evt.Location, Description = evt.Description,
Status = evt.Status, LastSeenAt = DateTime.UtcNow,
});
if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue;
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
{
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
continue;
}
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht
// doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses
// Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung
// davon.
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis."));
}
else
{
externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig
}
}
// Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher
// Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn
// die Aufsicht vorher tatsächlich einmal gesehen wurde).
var snapshotIdsToDelete = new List<Guid>();
foreach (var previous in previousSnapshot)
{
if (newEventsByUid.ContainsKey(previous.Uid)) continue;
if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue;
if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue;
if (mapping.Kind != SubstitutionKind.Supervision) continue;
var vanishedId = BuildVanishedExternalId(previous.Date, mapping);
substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen."));
snapshotIdsToDelete.Add(previous.Id);
}
// Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte
// künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives
// Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen
// VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten
// (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht
// nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht
// stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es
// gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf
// das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht
// Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und
// um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt.
if (newEvents.Count > 0)
{
var maxKnownDate = newEvents.Max(e => e.Date);
var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays);
var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet();
var freeDateSet = freeDates ?? [];
foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson))
{
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
{
if (date.DayOfWeek != mapping.Weekday) continue;
if (freeDateSet.Contains(date)) continue;
if (eventDates.Contains((date, mapping.StartTime)))
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
}
}
}
return new UntisDiffResult
{
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
};
}
// Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese
// manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim
// Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die
// Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c;
// Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb
// aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält
// (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit
// Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog).
// Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie
// UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens
// akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen.
//
// Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster
// ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der
// genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht
// mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende
// Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den
// gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die
// Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen.
private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping)
{
if (evt.Summary != mapping.Summary)
return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“.";
if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken)))
return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“.";
return null;
}
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping,
string externalId, string reason) => new()
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod,
Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId,
};
// Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum
// abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls.
private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"vanished-{mapping.Id}-{date:yyyyMMdd}";
// Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus
// Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update
// statt Duplikat), unabhängig vom Snapshot-Stand.
private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"missing-{mapping.Id}-{date:yyyyMMdd}";
}
@@ -0,0 +1,241 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
/// Stundenplan in der App zuordnet").</summary>
public sealed class UntisWeeklyPattern
{
public DayOfWeek Weekday { get; init; }
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public string? Summary { get; init; }
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
public List<string> ClassTokens { get; init; } = [];
public int OccurrenceCount { get; init; }
}
public sealed class UntisSlotMatch
{
public required UntisWeeklyPattern Pattern { get; init; }
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
/// entspricht CoveredPeriods[0].
public int? PeriodNumber { get; init; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:5009:20 für Stunde 1+2), während der
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
public List<int> CoveredPeriods { get; init; } = [];
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
public int? AfterPeriod { get; init; }
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
public Guid? SuggestedGroupId { get; init; }
public bool IsConfident { get; init; }
}
public sealed class UntisMatchResult
{
public List<UntisSlotMatch> Matches { get; init; } = [];
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
}
/// <summary>
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
/// </summary>
public class UntisMatchingService
{
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
// und den tatsächlichen WebUntis-Zeiten.
private const int StartTimeToleranceMinutes = 3;
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
{
var teacherToken = DetectTeacherToken(events);
var patterns = BuildWeeklyPatterns(events, teacherToken);
var matches = new List<UntisSlotMatch>();
foreach (var pattern in patterns)
{
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
if (pattern.ClassTokens.Count == 0)
{
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
continue;
}
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
matches.Add(new UntisSlotMatch
{
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
SuggestedGroupId = groupId, IsConfident = confident,
});
}
var matchedSlotKeys = matches
.Where(m => m.SuggestedGroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
.ToHashSet();
var unmatchedSlots = timetableSlots
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
.ToList();
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
}
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
events
.Select(e => LastToken(e.Description))
.Where(t => t is not null)
.GroupBy(t => t)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault();
private static string? LastToken(string description)
{
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[^1];
}
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
{
var withTokens = events.Select(e => new
{
Event = e,
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
});
var patterns = new List<UntisWeeklyPattern>();
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
{
var modal = group
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
.OrderByDescending(g => g.Count())
.First();
patterns.Add(new UntisWeeklyPattern
{
Weekday = group.Key.Weekday,
StartTime = group.Key.StartTime,
EndTime = group.Key.EndTime,
Summary = modal.Key.Summary,
ClassTokens = modal.Key.ClassTokens.Length == 0
? [] : modal.Key.ClassTokens.Split(';').ToList(),
OccurrenceCount = modal.Count(),
});
}
return patterns;
}
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
// Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER
// Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht
// sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich
// auf eine Annahme festzulegen.
private static readonly char[] ClassTokenSeparators = [';', ','];
private static List<string> ExtractClassTokens(string description, string? teacherToken)
{
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
? description[..^teacherToken.Length].TrimEnd()
: description;
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
{
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
if (exact is not null) return exact.PeriodNumber;
var closest = periodSchedule.Periods
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
.OrderBy(t => t.DiffMinutes)
.FirstOrDefault();
return closest.Period?.PeriodNumber;
}
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
{
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
return periodSchedule.Periods
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
.OrderBy(p => p.PeriodNumber)
.Select(p => p.PeriodNumber)
.ToList();
}
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
periodSchedule.Periods
.Where(p => p.End <= startTime)
.OrderByDescending(p => p.End)
.Select(p => (int?)p.PeriodNumber)
.FirstOrDefault() ?? 0;
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
{
if (classTokens.Count == 0) return (null, false);
var candidates = classTokens
.Select(token => ResolveSingleGroup(token, groups))
.Where(g => g is not null)
.Select(g => g!)
.Distinct()
.ToList();
if (candidates.Count == 1) return (candidates[0].Id, true);
if (candidates.Count == 0) return (null, false);
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
if (periodNumber is { } period)
{
var withExistingSlot = candidates
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
.ToList();
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
}
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
}
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
{
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact;
var normalizedToken = Normalize(token);
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
}
private static string Normalize(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
}
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll()); Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
} }
[Fact]
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
{
using var temp = new TempDatabase();
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, null));
}
[Fact]
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(temp.Path))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.True(service.CanOpenAndRead(temp.Path, null));
}
[Fact]
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
}
[Fact]
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
{
using var temp = new TempDatabase();
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
}
[Fact]
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
{
using var temp = new TempDatabase();
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
var service = new DatabaseEncryptionService();
Assert.False(service.CanOpenAndRead(temp.Path, null));
}
private sealed class TempDatabase : IDisposable private sealed class TempDatabase : IDisposable
{ {
private readonly string _directory = System.IO.Path.Combine( private readonly string _directory = System.IO.Path.Combine(
+193
View File
@@ -955,6 +955,199 @@ public sealed class RepositoryTests
Assert.Equal("Vertretung 8a", result[0].Description); Assert.Equal("Vertretung 8a", result[0].Description);
} }
[Fact]
public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag()
{
using var db = NewInMemoryContext();
var repo = new SubstitutionEntryRepository(db);
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" });
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 });
var result = repo.GetByExternalId("38818-2004584-2004587");
Assert.NotNull(result);
Assert.Equal(new DateOnly(2026, 3, 12), result!.Date);
Assert.Null(repo.GetByExternalId("unbekannt"));
}
// ── UntisSnapshotRepository / UntisSlotMappingRepository ─────────────────
[Fact]
public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
repo.Save(new UntisSnapshotEntry
{
Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17),
StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", Location = "Medien", Description = "10c HED",
});
var result = repo.GetAll();
Assert.Single(result);
Assert.Equal("38926-2013812-2013815", result[0].Uid);
Assert.Equal("SOL", result[0].Summary);
}
[Fact]
public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" };
repo.Save(entry);
entry.Location = "A1";
entry.Status = "CANCELLED";
repo.Save(entry);
var result = Assert.Single(repo.GetAll());
Assert.Equal("A1", result.Location);
Assert.Equal("CANCELLED", result.Status);
}
[Fact]
public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSlotMappingRepository(db);
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50),
Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true,
};
repo.Save(mapping);
Assert.Single(repo.GetAll());
repo.Delete(mapping.Id);
Assert.Empty(repo.GetAll());
}
// ── AnnualPlanEventRepository ────────────────────────────────────────────
[Fact]
public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine()
{
using var db = NewInMemoryContext();
var repo = new AnnualPlanEventRepository(db);
repo.Save(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt",
StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true,
});
repo.Save(new AnnualPlanEvent
{
ExternalId = "spaeter", Title = "Sommerfest",
StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true,
});
var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17));
Assert.Single(result);
Assert.Equal("fahrt", result[0].ExternalId);
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
}
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
[Fact]
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
]);
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
Assert.Single(result);
Assert.Equal(20260824, result[0].Date);
}
[Fact]
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
{
using var db = NewInMemoryContext();
var repo = new UntisAbsenceCacheRepository(db);
repo.InsertRange(
[
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
]);
repo.ReplaceRange("10c", 20260801, 20260831,
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
Assert.Single(untouched);
}
[Fact]
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisClassRegisterCacheRepository(db);
repo.InsertRange([new UntisClassRegisterCacheEntry
{
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
TeacherUsername = "mueller", CategoryName = "Positiv",
}]);
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
Assert.Equal("mueller", result.TeacherUsername);
}
[Fact]
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
{
using var db = NewInMemoryContext();
var repo = new UntisStudentRosterCacheRepository(db);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
var tenC = repo.GetByClass("10c");
var tenD = repo.GetByClass("10d");
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
Assert.Equal(42, tenC[0].ExternKey);
Assert.Single(tenD); // andere Klasse bleibt unberührt
}
[Fact]
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
{
using var db = NewInMemoryContext();
var repo = new UntisCacheFetchStateRepository(db);
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
repo.Save(new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
});
var result = repo.Get("10c", UntisCacheKind.Absences);
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
Assert.NotNull(result);
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
Assert.Equal(20260101, result.ColdCoverageStartDate);
Assert.Null(missing);
}
// ── WorkTaskRepository ──────────────────────────────────────────────────── // ── WorkTaskRepository ────────────────────────────────────────────────────
[Fact] [Fact]
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
} }
} }
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
public bool CanOpenAndRead(string dbPath, string? password)
{
if (!File.Exists(dbPath)) return false;
try
{
var connection = password is null
? new ConnectionString(dbPath)
: new ConnectionString(dbPath) { Password = password };
using var db = new LiteDatabase(connection);
_ = db.GetCollectionNames().ToList();
return true;
}
catch
{
return false;
}
}
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält. /// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
+17
View File
@@ -66,6 +66,13 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays"); public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties"); public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries"); public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
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<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"); public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
public void Checkpoint() => _db.Checkpoint(); public void Checkpoint() => _db.Checkpoint();
@@ -529,6 +536,16 @@ public class LiteDbContext : IDisposable
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date); SubstitutionEntries.EnsureIndex(x => x.Date);
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
UntisAbsenceCache.EnsureIndex(x => x.Date);
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
UntisClassRegisterCache.EnsureIndex(x => x.Date);
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
} }
public void Dispose() => _db.Dispose(); public void Dispose() => _db.Dispose();
@@ -492,6 +492,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
public List<ParticipationSession> GetByGroup(Guid groupId) => public List<ParticipationSession> GetByGroup(Guid groupId) =>
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList(); db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id); public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
public void Save(ParticipationSession s) public void Save(ParticipationSession s)
{ {
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId); ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
@@ -742,6 +743,8 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList(); db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) =>
db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) public void Save(SubstitutionEntry entry)
{ {
db.SubstitutionEntries.Upsert(entry); db.SubstitutionEntries.Upsert(entry);
@@ -754,6 +757,103 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
} }
} }
// Bewusst kein db.OnChange hier (anders als sonst überall): UntisSnapshotEntry ist eine rein
// lokale, hochfrequente Abgleich-Zwischenablage (jeder Poll aktualisiert alle Zeilen), deren
// Sync nur Rauschen erzeugen würde. UntisSlotMapping ist an die pro Gerät hinterlegte
// WebUntis-URL gebunden (WebUntisSettingsService, wie SyncSettingsService nicht synchronisiert)
// und deshalb ebenfalls sinnvollerweise lokal — jedes Gerät bestätigt seine Zuordnung einmal
// selbst über den Review-Dialog.
public class UntisSnapshotRepository(LiteDbContext db) : IUntisSnapshotRepository
{
public List<UntisSnapshotEntry> GetAll() => db.UntisSnapshotEntries.FindAll().ToList();
public void Save(UntisSnapshotEntry entry) => db.UntisSnapshotEntries.Upsert(entry);
public void Delete(Guid id) => db.UntisSnapshotEntries.Delete(id);
}
public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRepository
{
public List<UntisSlotMapping> GetAll() => db.UntisSlotMappings.FindAll().ToList();
public void Save(UntisSlotMapping mapping) => db.UntisSlotMappings.Upsert(mapping);
public void Delete(Guid id) => db.UntisSlotMappings.Delete(id);
}
// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines
// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen.
public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository
{
public List<AnnualPlanEvent> GetAll() =>
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from)
.OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry);
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
}
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
// ohnehin selbst ab (siehe UntisReportCacheService).
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
{
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisAbsenceCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisAbsenceCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
}
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
{
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
db.UntisClassRegisterCache
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
.ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
db.UntisClassRegisterCache.InsertBulk(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
}
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
// selbst ab.
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
{
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
db.UntisStudentRosterCache.InsertBulk(entries);
}
}
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
{
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
}
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{ {
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -0,0 +1,46 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSettingsServiceTests
{
private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern";
private static string TempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen()
{
var path = TempPath();
var service = new AnnualPlanSettingsService(path);
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json"));
var reloaded = new AnnualPlanSettingsService(path);
Assert.DoesNotContain("token=intern", raw);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
Assert.True(reloaded.Enabled);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport()
{
var service = new AnnualPlanSettingsService(TempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.False(service.Enabled);
Assert.Null(service.GetIcalUrl());
}
}
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSyncServiceTests
{
private static string Calendar(params string[] events) =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n";
private static string Event(string uid, string title, string date = "20260823") =>
$"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n";
private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) =>
new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events);
[Fact]
public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine()
{
var events = new FakeAnnualPlanEvents();
using var service = Build(events);
var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var originalId = events.GetByExternalId("1")!.Id;
var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824")));
Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted));
Assert.Equal(0, unchanged.ChangeCount);
Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted));
Assert.Equal(originalId, events.GetByExternalId("1")!.Id);
Assert.Null(events.GetByExternalId("2"));
}
[Fact]
public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "bestand", Title = "Bestehend",
StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var broken = Calendar(Event("neu", "Neu"),
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
Assert.Throws<FormatException>(() => service.ProcessIcsText(broken));
var remaining = Assert.Single(events.GetAll());
Assert.Equal("bestand", remaining.ExternalId);
}
[Fact]
public void Clear_EntferntDenLokalenFeedCache()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "1", StartDate = new DateOnly(2026, 8, 23),
EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var notified = false;
service.DataChanged += () => notified = true;
service.Clear();
Assert.Empty(events.GetAll());
Assert.True(notified);
}
}
@@ -0,0 +1,587 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
[Fact]
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
{
var students = new[]
{
Student(1001, "Ada Müller"),
Student(1002, "Ben Schmidt"),
Student(1003, "Cem Yilmaz"),
};
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
Assert.Equal(3, roster.Count);
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.True(ada.HasAbsenceToday);
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
Assert.False(ada.HasRecentClassRegisterEntry);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.False(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
Assert.False(cem.HasAbsenceToday);
Assert.False(cem.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
{
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
var ben = Assert.Single(roster);
Assert.True(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
{
var students = new[] { Student(1001, "Ada Müller") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
new DateOnly(2026, 8, 26)));
Assert.True(row.IsLate);
Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
}
[Fact]
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
{
var students = new[]
{
Student(1001, "Zora Unauffällig"),
Student(1002, "Ada Fehlzeit"),
Student(1003, "Ben Klassenbuch"),
};
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
};
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
rows.Select(r => r.StudentName));
}
[Fact]
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
{
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
termStart: new DateOnly(2026, 8, 1)));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
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()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.False(row.HasYearSummary);
Assert.Equal("", row.YearSummaryLabel);
Assert.Null(row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
yearAbsences, schoolDaysElapsed: 10));
Assert.Equal(1, row.YearAbsenceDayCount);
}
[Fact]
public void TrendDay_AnteileSummierenSichZurGesamtquote()
{
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
}
[Fact]
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
{
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
}
[Fact]
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
{
var students = new[] { Student(1001, "Zora Unauffällig") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
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]
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
Assert.Equal(6, rows[0].DaysOpen);
Assert.True(rows[0].IsOverdue);
Assert.Equal(1, rows[1].DaysOpen);
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]
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
{
var entries = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
"Lob", "Positiv", "Gute Mitarbeit"),
};
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
var homework = rows[0];
Assert.Equal(3, homework.Count);
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
}
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
[Fact]
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
{
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string>());
var notice = Assert.Single(notices);
Assert.Equal("Mona Muster", notice.StudentName);
Assert.Contains("Montag", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
}
[Fact]
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, new Dictionary<string, string>(), new HashSet<string>());
Assert.Empty(notices);
}
[Fact]
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string> { "Mona Muster" });
Assert.Empty(notices);
}
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
[Fact]
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
{
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
var students = new List<Student>
{
new() { FirstName = "Ada", LastName = "Müller" },
new() { FirstName = "Ben", LastName = "Schmidt" },
};
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
Assert.NotNull(match);
Assert.Equal("Ada", match!.FirstName);
}
[Fact]
public void MatchStudent_LiefertNullOhneTreffer()
{
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
}
@@ -1,7 +1,6 @@
using System.IO.Compression;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests;
public sealed class CreateLetterDialogViewModelTests : IDisposable public sealed class CreateLetterDialogViewModelTests : IDisposable
{ {
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
[Fact] [Fact]
public void OhneVorlage_KannKeinenBriefErzeugen() public void OhneVorlage_KannKeinenBriefErzeugen()
{ {
var student = StudentWithContact("Sehr geehrte Frau Muster,"); var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
var vm = Build(student, new LetterTemplateService(_directory));
Assert.False(vm.CanGenerate); Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
} }
@@ -26,83 +21,63 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
[Fact] [Fact]
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
{ {
var service = ServiceWithTemplate("Letter.Salutation"); var vm = Build(StudentWithContact(null), StoreWithTemplate(
var vm = Build(StudentWithContact(null), service); new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
Assert.False(vm.CanGenerate); 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] [Fact]
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie() public void VollstaendigeDaten_ErzeugenFlachesPdf()
{ {
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate"); var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service); new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
var output = Path.Combine(_directory, "Brief.docx"); 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(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
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);
} }
[Fact] [Fact]
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede() public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
{ {
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" }); var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
vm.LetterSalutation = "Sehr geehrte Frau Muster,"; 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.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation); Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
} }
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) => private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, service, new FakeMemberships([]), new FakeGroups([])); new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
private LetterTemplateService ServiceWithTemplate(params string[] tags) private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
{ {
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx"); var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) 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 entry = archive.CreateEntry("word/document.xml"); var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
using var writer = new StreamWriter(entry.Open()); lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
var controls = string.Join("", tags.Select(tag => y += 20;
$"<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>");
} }
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}")); TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
service.Import(source, "Elternbrief"); var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
return service;
} }
private static Student StudentWithContact(string? salutation) => new() private static Student StudentWithContact(string? salutation) => new()
{ {
FirstName = "Lena", FirstName = "Lena", LastName = "Beispiel",
LastName = "Beispiel", Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
Contacts = Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
[
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, true); }
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
} }
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using System.Globalization;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -11,6 +12,22 @@ namespace LehrerApp.Desktop.Tests;
/// (offene Aufgaben, ...). /// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests public sealed class DashboardViewModelTests
{ {
[Fact]
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
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.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible);
}
private static PeriodScheduleService NewPeriodSchedule() private static PeriodScheduleService NewPeriodSchedule()
{ {
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
@@ -42,12 +59,14 @@ public sealed class DashboardViewModelTests
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null) FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
return new DashboardViewModel( return new DashboardViewModel(
new FakeGroups([group]), new FakeSubjects([]), lessons, new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
@@ -55,7 +74,182 @@ public sealed class DashboardViewModelTests
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries()); substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
}
/// 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(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.True(vm.MissingTeachingTimeCard.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(vm.MissingTeachingTimeEntries, i => i.Date == 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(vm.MissingTeachingTimeEntries, i => i.Date == 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(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).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);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).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);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
} }
[Fact] [Fact]
@@ -225,6 +419,26 @@ public sealed class DashboardViewModelTests
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
} }
[Fact]
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var selectedDate = today.AddDays(1);
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
var vm = BuildVm(alphabeticallyFirst,
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
allGroups: [alphabeticallyFirst, selectedDayGroup]);
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
}
[Fact] [Fact]
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp() public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
{ {
@@ -244,6 +458,37 @@ public sealed class DashboardViewModelTests
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
} }
[Fact]
public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var annualPlan = new FakeAnnualPlanEvents();
annualPlan.Add(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt 6a",
Description = "Aushang beachten", Location = "Jugendherberge",
CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2),
StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false,
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" },
annualPlanEvents: annualPlan);
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.True(day.HasAnnualPlanEvent);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson);
var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan);
Assert.Equal("Klassenfahrt 6a", annual.Title);
Assert.Equal("Aushang beachten", annual.Description);
Assert.Contains("13:30", annual.Subtitle);
Assert.Null(annual.GroupId);
}
[Fact] [Fact]
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag() public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
{ {
@@ -491,4 +736,22 @@ public sealed class DashboardViewModelTests
Assert.Empty(tasks.GetAll()); Assert.Empty(tasks.GetAll());
} }
[Fact]
public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf()
{
var warning = new WeatherWarning
{
Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.",
Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc),
Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc),
};
var item = new DashboardWeatherWarningItem(warning);
Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline);
Assert.Equal("#D32F2F", item.SeverityColor);
Assert.Contains("Uhr", item.PeriodDisplay);
}
} }
@@ -247,6 +247,25 @@ public sealed class DocumentationDialogViewModelTests
Assert.Equal(groupId, vm.Result.GroupId); Assert.Equal(groupId, vm.Result.GroupId);
} }
[Fact]
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
{
var groupId = Guid.NewGuid();
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[wholeGroup], groupId)
{
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(groupId, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
}
[Fact] [Fact]
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt() public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
{ {
@@ -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);
}
}
@@ -0,0 +1,65 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
public sealed class ExamGradingDialogViewModelTests
{
[Fact]
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
var memberships = new FakeMemberships(
[
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
]);
var exam = new Exam
{
GroupId = group.Id,
Title = "Klausur 1",
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
GradingKey = GradingService.DefaultKey1To6(),
};
var results = new FakeResults();
var vm = new ExamGradingDialogViewModel(results, students, memberships,
new GradingService(), exam, group.Id);
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
rowWithPoints.Cells[0].TrySetValue(7);
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.False(rowWithPoints.Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
}
[Fact]
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
var students = new FakeStudents([student]);
var memberships = new FakeMemberships(
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
new GradingService(), exam, group.Id);
vm.Rows[0].Absent = true;
vm.MarkRemainingAbsentCommand.Execute(null);
Assert.True(vm.Rows[0].Absent);
}
}
@@ -0,0 +1,103 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Exams;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
public sealed class ExamsOverviewViewModelTests
{
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
FakeMemberships? memberships = null, FakeResults? results = null) =>
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
[Fact]
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
var vm = BuildVm([group], [overdue, planned]);
vm.LoadCommand.Execute(null);
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
}
[Fact]
public void LeereListe_BietetAbsprungZuLerngruppen()
{
var vm = BuildVm([], []);
var navigated = false;
vm.OnNavigateToGroups = () => navigated = true;
vm.LoadCommand.Execute(null);
vm.GoToGroupsCommand.Execute(null);
Assert.True(vm.HasNoExams);
Assert.True(navigated);
}
[Fact]
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
{
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
var duplicate = new Exam
{
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
};
var vm = BuildVm([groupA, groupB], [original, duplicate]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
Assert.True(vm.SelectedRow.HasSibling);
Assert.Single(vm.Siblings);
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
}
[Fact]
public void ToggleApproval_SetztUndLöschtDatum()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
var vm = BuildVm([group], [exam]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows[0];
vm.ToggleApprovalCommand.Execute(null);
Assert.NotNull(exam.ApprovalGrantedAt);
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
vm.ToggleApprovalCommand.Execute(null);
Assert.Null(exam.ApprovalGrantedAt);
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
}
[Fact]
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
{
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
var today = DateOnly.FromDateTime(DateTime.Today);
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
var vm = BuildVm([group], [conducted, returned]);
vm.LoadCommand.Execute(null);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
Assert.True(vm.ShowCorrectionProgress);
Assert.False(vm.ShowGradeSummary);
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
Assert.False(vm.ShowCorrectionProgress);
Assert.True(vm.ShowGradeSummary);
}
}
+99
View File
@@ -31,6 +31,22 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new WebUntisSettingsService(tempPath);
}
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AnnualPlanSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf. /// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService() public static SyncSettingsService BuildSyncSettingsService()
{ {
@@ -154,6 +170,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
{ {
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList(); public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public List<ParticipationSession> GetAll() => all.ToList();
public void Save(ParticipationSession session) public void Save(ParticipationSession session)
{ {
all.RemoveAll(s => s.Id == session.Id); all.RemoveAll(s => s.Id == session.Id);
@@ -422,10 +439,92 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
public void Add(SubstitutionEntry e) => _all.Add(e); public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList(); public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList(); public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
} }
public class FakeUntisSnapshots : IUntisSnapshotRepository
{
private readonly List<UntisSnapshotEntry> _all = [];
public void Add(UntisSnapshotEntry e) => _all.Add(e);
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
{
private readonly List<UntisSlotMapping> _all = [];
public void Add(UntisSlotMapping m) => _all.Add(m);
public List<UntisSlotMapping> GetAll() => _all.ToList();
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
}
public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
{
private readonly List<AnnualPlanEvent> _all = [];
public void Add(AnnualPlanEvent e) => _all.Add(e);
public List<AnnualPlanEvent> GetAll() => _all.ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
_all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
_all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
{
private readonly List<UntisAbsenceCacheEntry> _all = [];
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
{
private readonly List<UntisClassRegisterCacheEntry> _all = [];
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
_all.AddRange(entries);
}
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
}
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
{
private readonly List<UntisStudentRosterCacheEntry> _all = [];
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
_all.Where(e => e.ClassName == className).ToList();
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
{
_all.RemoveAll(e => e.ClassName == className);
_all.AddRange(entries);
}
}
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
{
private readonly List<UntisCacheFetchState> _all = [];
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
public void Save(UntisCacheFetchState state)
{
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
_all.Add(state);
}
}
public class FakeWorkTasks : IWorkTaskRepository public class FakeWorkTasks : IWorkTaskRepository
{ {
private readonly List<WorkTask> _all = []; private readonly List<WorkTask> _all = [];
@@ -0,0 +1,37 @@
using System.Globalization;
using LehrerApp.Desktop.Converters;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class FractionWidthConverterTests
{
[Fact]
public void Convert_MultipliziertTrackbreiteMitAnteil()
{
var result = FractionWidthConverter.Instance.Convert(
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(50d, result);
}
[Fact]
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
{
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
// darf nicht zu einer negativen oder undefinierten Breite führen.
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
}
[Fact]
public void Convert_ClamptNegativeErgebnisseAufNull()
{
var result = FractionWidthConverter.Instance.Convert(
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(0d, result);
}
}
@@ -0,0 +1,103 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GlobalSearchViewModelTests
{
[Fact]
public void LeereSuche_ZeigtSchnellaktionen()
{
var vm = BuildVm();
Assert.Collection(vm.Results,
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
Assert.Same(vm.Results[0], vm.SelectedResult);
}
[Fact]
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
{
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
var vm = BuildVm([student], [group], [exam], tasks);
vm.Query = "Chemie";
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
}
[Fact]
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
{
var student = new Student { FirstName = "Mia", LastName = "Muster" };
var vm = BuildVm([student]);
vm.Query = "Muster";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
GlobalSearchResult? navigated = null;
var closed = false;
vm.OnNavigate = x => navigated = x;
vm.OnClose = () => closed = true;
await vm.ExecuteCommand.ExecuteAsync(result);
Assert.Same(result, navigated);
Assert.True(closed);
}
[Fact]
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
{
var vm = BuildVm();
bool? reminder = null;
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
await vm.ExecuteCommand.ExecuteAsync(action);
Assert.True(reminder);
}
[Fact]
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
{
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
var vm = BuildVm(groups: [group], subjects: [subject]);
vm.Query = "wat";
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
Assert.Contains("WAT", result.Subtitle);
}
[Fact]
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
{
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
vm.Query = "Test";
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
}
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
List<Subject>? subjects = null) =>
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
}
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
var grades = new FakeGrades(); var grades = new FakeGrades();
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks, var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students, new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()), new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
students, memberships, groups, new FakeCompetencyDomains()), students, memberships, groups, new FakeCompetencyDomains()),
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
@@ -0,0 +1,26 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class GroupDocumentationQuickViewModelTests
{
[Fact]
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
{
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
var vm = new GroupDocumentationQuickViewModel([group])
{
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
Assert.Equal(group.Id, vm.Result.GroupId);
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
Assert.True(vm.Result.ExcludeFromWebUntisSync);
}
}
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
Assert.Equal("Alt", vm.Entries[1].Model.Title); Assert.Equal("Alt", vm.Entries[1].Model.Title);
} }
[Fact]
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
{
var group = new LearningGroup { Name = "10c" };
var docs = new FakeDocumentation();
docs.Add(new Documentation
{
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
});
var vm = BuildVm([], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
}
[Fact] [Fact]
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert() public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
{ {
@@ -5,76 +5,69 @@ using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback: /// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
/// wichtige Todos").
public sealed class GroupListViewModelTests public sealed class GroupListViewModelTests
{ {
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null, private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
FakeExams? exams = null, FakeWorkTasks? tasks = null) => new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
[Fact] [Fact]
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick() public void Gruppenkarte_OeffnetDirektDieUebersicht()
{ {
var group = new LearningGroup { Name = "9c" }; var group = CurrentGroup("9c");
var vm = BuildVm(group); var vm = BuildVm(group);
Guid? openedId = null;
int? openedTab = null;
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, ""); Assert.Single(vm.Groups).OpenCommand.Execute(null);
Assert.False(vm.QuickHasAnything); Assert.Equal(group.Id, openedId);
Assert.Equal(0, openedTab);
} }
[Fact] [Fact]
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur() public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
{ {
var group = new LearningGroup { Name = "9c" }; var first = CurrentGroup("9a");
var today = DateOnly.FromDateTime(DateTime.Today); var second = CurrentGroup("9b");
var lessons = new FakeLessons(); var vm = BuildVm(first, second);
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned }); Guid? editedId = null;
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
var vm = BuildVm(group, lessons: lessons, exams: exams); var secondItem = vm.Groups.Single(x => x.Id == second.Id);
vm.SelectedGroup = new GroupListItem(group, ""); await secondItem.EditCommand.ExecuteAsync(null);
Assert.True(vm.QuickHasNextLesson); Assert.Equal(second.Id, editedId);
Assert.Contains("Redox", vm.QuickNextLessonLabel);
Assert.True(vm.QuickHasNextExam);
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
Assert.True(vm.QuickHasAnything);
} }
[Fact] [Fact]
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit() public void Suche_FiltertWeiterhinNachGruppenname()
{ {
var group = new LearningGroup { Name = "9c" }; var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open });
var vm = BuildVm(group, tasks: tasks);
vm.SelectedGroup = new GroupListItem(group, ""); vm.SearchText = "Chemie";
Assert.True(vm.QuickHasTasks); Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
} }
[Fact] [Fact]
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick() public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
{ {
var group = new LearningGroup { Name = "9c" }; var group = CurrentGroup("9c");
var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group);
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]); var item = Assert.Single(vm.Groups);
var vm = BuildVm(group, exams: exams);
vm.SelectedGroup = new GroupListItem(group, "");
Assert.True(vm.QuickHasAnything);
vm.SelectedGroup = null; item.ToggleArchiveCommand.Execute(null);
Assert.False(vm.QuickHasAnything); Assert.False(group.IsActive);
Assert.Empty(vm.QuickTasks); Assert.Empty(vm.Groups);
} }
private static LearningGroup CurrentGroup(string name) => new()
{
Name = name,
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
IsActive = true,
};
} }
@@ -12,10 +12,13 @@ public sealed class GroupOverviewViewModelTests
{ {
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null, private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) => FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]), 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(), 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, private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario( FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
@@ -76,6 +79,86 @@ public sealed class GroupOverviewViewModelTests
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel); 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] [Fact]
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung() public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
{ {
@@ -0,0 +1,90 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class LessonSchedulingServiceTests
{
[Fact]
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
{
var groupId = Guid.NewGuid();
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
var repo = new FakeUnits();
repo.Add(planned); repo.Add(active);
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
vm.NewUnitTitle = "Neue Reihe";
vm.SaveCommand.Execute(null);
Assert.True(vm.ResultIsNew);
Assert.Equal("Neue Reihe", vm.Result?.Title);
Assert.Null(repo.GetById(vm.Result!.Id));
}
[Fact]
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
var repo = new FakeLessons();
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
Assert.Equal(5, moved.LessonNumber);
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
}
[Fact]
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
{
var source = new Lesson
{
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
Phases =
[
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
],
};
var repo = new FakeLessons();
repo.Add(source);
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
new(2026, 9, 3), 6, new TimeOnly(12, 15));
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
Assert.Equal("Fotosynthese Fortsetzung", continuation.Topic);
Assert.Null(source.Homework);
Assert.Equal("Aufgabe 2", continuation.Homework);
Assert.Equal(6, continuation.LessonNumber);
}
[Fact]
public void Move_LehntBelegtenZielterminAb()
{
var groupId = Guid.NewGuid();
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
var repo = new FakeLessons();
repo.Add(source); repo.Add(occupied);
var error = Assert.Throws<InvalidOperationException>(() =>
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
Assert.Contains("bereits eine Planung", error.Message);
}
}
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
Assert.Equal(0.4, vm.CurrentStudentContentOpacity); 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] [Fact]
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf() public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
{ {
@@ -0,0 +1,139 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SchoolDayWeatherSummaryTests
{
private static readonly DateOnly Day = new(2026, 8, 24);
[Fact]
public void From_GewitterHatVorrangVorSturmUndRegen()
{
var snapshot = Snapshot(
Hour(8, code: 61, rain: 2, gust: 70),
Hour(10, code: 95, rain: 1, gust: 80));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
Assert.Equal("⛈️", summary.Symbol);
}
[Fact]
public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt()
{
var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day);
Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind);
Assert.Equal("💨", summary.Symbol);
}
[Fact]
public void From_NaechtlicheBoeBeeinflusstSchultagNicht()
{
var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(20, summary!.MaxWindGustKmh);
Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind);
}
[Fact]
public void From_VerwendetFuerKennzahlenNurAchtBisSechzehnUhr()
{
var snapshot = Snapshot(
Hour(7, temperature: 5, rain: 3, probability: 95, sunshineMinutes: 0),
Hour(8, temperature: 12, probability: 20, sunshineMinutes: 30),
Hour(16, temperature: 17, probability: 65, sunshineMinutes: 45),
Hour(17, temperature: 25, rain: 4, probability: 99, sunshineMinutes: 60));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(12, summary!.MinTemperatureC);
Assert.Equal(17, summary.MaxTemperatureC);
Assert.Equal(0, summary.PrecipitationMm);
Assert.Equal(65, summary.MaxHourlyPrecipitationProbabilityPercent);
Assert.Equal(1.3, summary.SunshineDurationHours);
Assert.Contains("0816 Uhr", summary.Tooltip);
Assert.Contains("Regenwahrscheinlichkeit: bis 65 % (stündlich)", summary.Tooltip);
Assert.Contains($"Sonnenschein: {summary.SunshineDurationHours:0.#} h", summary.Tooltip);
}
[Fact]
public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip()
{
var snapshot = Snapshot(Hour(10, cloud: 20));
snapshot.Warnings.Add(new WeatherWarning
{
Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe",
Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)),
});
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
Assert.Contains("Amtliche Warnung", summary.Tooltip);
Assert.Contains("Aufenthalt im Freien", summary.Tooltip);
}
[Fact]
public void From_RegenSummiertStundenUndZeigtTemperaturspanne()
{
var snapshot = Snapshot(
Hour(8, temperature: 12, rain: 0.1),
Hour(12, temperature: 17, rain: 0.4));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind);
Assert.Equal(0.5, summary.PrecipitationMm);
Assert.Contains("1217 °C", summary.Tooltip);
}
[Fact]
public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage()
{
var warning = new WeatherWarning
{
Onset = Day.ToDateTime(new TimeOnly(18, 0)),
Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)),
};
Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1)));
Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3)));
}
[Fact]
public void WeekCellItem_WetterOderWarnung_AktiviertGemeinsameHoverFlaeche()
{
var cell = WeekCellItem.WeekdayHeader(DayOfWeek.Monday, Day, false);
Assert.False(cell.HasWeatherInformation);
cell.SetWeather(SchoolDayWeatherSummary.From(Snapshot(Hour(10)), Day), []);
Assert.True(cell.HasWeatherInformation);
cell.SetWeather(null, [new WeatherWarning { Headline = "Amtliche Warnung" }]);
Assert.True(cell.HasWeatherInformation);
Assert.Contains("Amtliche Warnung", cell.WeatherTooltip);
}
private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new()
{
RetrievedAt = DateTime.UtcNow,
Forecast = hours.ToList(),
};
private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20,
double? rain = 0, double? gust = 20, double? cloud = 20,
double? probability = null, double? sunshineMinutes = null) => new()
{
ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code,
TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust,
CloudCoverPercent = cloud, PrecipitationProbabilityPercent = probability,
SunshineDurationMinutes = sunshineMinutes,
};
}
@@ -472,6 +472,7 @@ public sealed class SeatingPlanViewModelTests
vm.SetRatingByNumber(5); vm.SetRatingByNumber(5);
vm.ApplyAttendanceShortcut(1, clear: false); vm.ApplyAttendanceShortcut(1, clear: false);
vm.ApplyHomeworkShortcut(7, clear: false); vm.ApplyHomeworkShortcut(7, clear: false);
vm.ApplyDayHighlightShortcut(1, clear: false);
var session = Assert.Single(sessions.GetByGroup(groupId)); var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date); Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
@@ -481,6 +482,56 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(AttendanceStatus.Present, entry.Attendance); Assert.Equal(AttendanceStatus.Present, entry.Attendance);
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework); Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
Assert.True(entry.HomeworkMissing); 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] [Fact]
@@ -517,6 +568,60 @@ public sealed class SeatingPlanViewModelTests
Assert.Null(vm.SelectedSession); 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] [Fact]
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn() public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
{ {
@@ -543,6 +648,50 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(session.Id, vm.SelectedSession?.Id); Assert.Equal(session.Id, vm.SelectedSession?.Id);
} }
[Fact]
public async Task AssessStudent_SitzungBereitsUeberAndereInstanzVerknuepft_LegtKeineZweiteAn()
{
// Regressionstest: Der Unterrichtsmodus läuft auf einer eigenen, per DI frisch aufgelösten
// SeatingPlanTabViewModel-Instanz (TimetableView.ShowTeachingMode), getrennt von der
// Instanz im Sitzplan-Tab der Kursübersicht — beide teilen sich nur das Repository. Wurde
// (wie hier simuliert) die Sitzung bereits über den Unterrichtsmodus mit der Stunde
// verknüpft, bevor im echten Sitzplan-Tab bewertet wird, darf letzterer keine zweite,
// unverknüpfte "Sitzplan"-Sitzung für denselben Tag anlegen.
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 memberships = new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]);
// Instanz A: Sitzplan-Tab der Kursübersicht, bereits initialisiert (kein SelectedSession).
var tabVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
tabVm.Initialize(groupId, isReadOnly: false);
tabVm.OnAssessStudent = _ => Task.CompletedTask;
// Instanz B: eigene, frische Instanz wie im Unterrichtsmodus — verknüpft die Sitzung mit
// der Stunde, ohne dass Instanz A davon erfährt.
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var teachingModeVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
teachingModeVm.Initialize(groupId, isReadOnly: false);
teachingModeVm.SelectOrCreateSessionForLesson(lesson);
var linked = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, linked.LessonId);
await tabVm.AssessStudent(tabVm.Seats.Single());
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(linked.Id, session.Id);
Assert.Equal(lesson.Id, session.LessonId);
Assert.Equal(session.Id, tabVm.SelectedSession?.Id);
}
[Fact] [Fact]
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan() public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
{ {
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using System.Linq; using System.Linq;
using Xunit; using Xunit;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -28,12 +29,15 @@ public sealed class SettingsViewModelTests
return new SettingsViewModel( return new SettingsViewModel(
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
@@ -273,6 +277,34 @@ public sealed class SettingsViewModelTests
Assert.Empty(vm.SchoolHolidayEntries); Assert.Empty(vm.SchoolHolidayEntries);
} }
[Fact]
public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler()
{
var vm = BuildViewModel();
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
Assert.NotEmpty(vm.SchoolNameError);
Assert.NotEmpty(vm.SchoolStreetError);
Assert.NotEmpty(vm.SchoolPostalCodeError);
Assert.NotEmpty(vm.SchoolCityError);
}
[Fact]
public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf()
{
var vm = BuildViewModel();
vm.SchoolName = "Testschule";
vm.SchoolStreet = "Schulweg 1";
vm.SchoolPostalCode = "1234";
vm.SchoolCity = "Teststadt";
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
Assert.Contains("fünfstellige", vm.SchoolPostalCodeError);
Assert.Empty(vm.SchoolLocationStatus);
}
[Fact] [Fact]
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings() public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
{ {
@@ -284,11 +316,14 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -309,11 +344,14 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -338,11 +376,14 @@ public sealed class SettingsViewModelTests
var vm = new SettingsViewModel( var vm = new SettingsViewModel(
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
new FakeSchemes(), new GradingService(), new BackupService(tempPath), new FakeSchemes(), new GradingService(), new BackupService(tempPath),
new BackupSettingsService(tempPath),
new DatabaseEncryptionService(), new AppLockService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath),
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel); Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
} }
[Fact]
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
{
var vm = BuildViewModel();
vm.SearchText = "nicht vorhanden";
Assert.True(vm.HasNoStudents);
Assert.False(vm.HasStudents);
Assert.Contains("Suche", vm.EmptyListMessage);
}
private StudentListViewModel BuildViewModel() => new( private StudentListViewModel BuildViewModel() => new(
new FakeStudents([_anna, _ben]), new FakeStudents([_anna, _ben]),
new FakeGroups([_group]), new FakeGroups([_group]),
@@ -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);
}
}
@@ -13,6 +13,17 @@ public sealed class TeachingModeViewModelTests
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []), new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects()); sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
/// Dieselben Fakes wie <see cref="BuildSeatingPlan"/>, aber für ParticipationTabViewModel —
/// beide brauchen eigene IParticipationSessionRepository/IParticipationRepository-Instanzen
/// im Produktivcode (siehe TeachingModeWindow-Kommentar zu ReloadSeatBadgesFromRepository),
/// hier reicht für die Tests aber eine gemeinsame FakeSessions-Instanz, damit beide
/// ViewModels dieselbe verknüpfte Sitzung sehen.
private static ParticipationTabViewModel BuildParticipation(LearningGroup group,
FakeSessions? sessions = null) =>
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
new FakeCompetencyDomains());
[Fact] [Fact]
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe() public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
{ {
@@ -24,7 +35,8 @@ public sealed class TeachingModeViewModelTests
}; };
var seatingPlan = BuildSeatingPlan(group.Id); var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan); var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group));
Assert.Equal("Q1 Chemie", vm.GroupName); Assert.Equal("Q1 Chemie", vm.GroupName);
Assert.Equal("Redox", vm.LessonInfo.Topic); Assert.Equal("Redox", vm.LessonInfo.Topic);
@@ -41,13 +53,37 @@ public sealed class TeachingModeViewModelTests
var sessions = new FakeSessions([]); var sessions = new FakeSessions([]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions); var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan); var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
var created = Assert.Single(sessions.GetByGroup(group.Id)); var created = Assert.Single(sessions.GetByGroup(group.Id));
Assert.Equal(lesson.Id, created.LessonId); Assert.Equal(lesson.Id, created.LessonId);
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id); Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
} }
/// Nutzer-Feedback: Schnellbewertungs-Dialoge (Mitarbeit, Anwesenheit/Hausaufgabe) sollen sich
/// im Unterrichtsmodus direkt öffnen lassen — das setzt voraus, dass die dafür verwendete
/// ParticipationTabViewModel-Instanz von Anfang an auf der zu dieser Stunde gehörenden
/// Sitzung steht, nicht auf einer beliebigen anderen Sitzung der Gruppe.
[Fact]
public void Konstruktor_SelektiertDieselbeSitzungAuchInDerParticipationViewModel()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var otherSession = new ParticipationSession
{ GroupId = group.Id, Date = lesson.Date.AddDays(-1), Comment = "Andere Stunde" };
var sessions = new FakeSessions([otherSession]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
Assert.NotNull(vm.SeatingPlan.SelectedSession);
Assert.Equal(vm.SeatingPlan.SelectedSession!.Id, vm.Participation.SelectedSession?.Id);
Assert.NotEqual(otherSession.Id, vm.Participation.SelectedSession?.Id);
}
[Fact] [Fact]
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet() public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
{ {
@@ -59,7 +95,8 @@ public sealed class TeachingModeViewModelTests
var sessions = new FakeSessions([existing]); var sessions = new FakeSessions([existing]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions); var seatingPlan = BuildSeatingPlan(group.Id, sessions);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan); var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
Assert.Single(sessions.GetByGroup(group.Id)); Assert.Single(sessions.GetByGroup(group.Id));
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id); Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id);
@@ -75,8 +112,92 @@ public sealed class TeachingModeViewModelTests
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" }; { GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var seatingPlan = BuildSeatingPlan(group.Id); var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan); var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
new FakeLessons(), seatingPlan, BuildParticipation(group));
Assert.False(vm.SeatingPlan.IsEditable); Assert.False(vm.SeatingPlan.IsEditable);
} }
// ── Hausaufgabe: letzte Stunde ansehen/abhaken, aktuelle einsehen/ändern ─────
[Fact]
public void Konstruktor_FindetHausaufgabeDerVorherigenStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson
{
GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren",
Homework = "S. 42, Aufgabe 3", HomeworkChecked = false,
};
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
Assert.True(vm.Homework.HasPreviousHomework);
Assert.Equal("S. 42, Aufgabe 3", vm.Homework.PreviousHomeworkText);
Assert.Contains("Säuren", vm.Homework.PreviousLessonLabel);
Assert.False(vm.Homework.PreviousHomeworkChecked);
}
[Fact]
public void Konstruktor_IgnoriertVorherigeStundeOhneHausaufgabe()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson { GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
Assert.False(vm.Homework.HasPreviousHomework);
}
[Fact]
public void PreviousHomeworkChecked_SpeichertSofortAnDerVorherigenStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var previous = new Lesson
{ GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren", Homework = "S. 42" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(previous);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
vm.Homework.PreviousHomeworkChecked = true;
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, today.AddDays(-120), today.AddDays(-1)));
Assert.True(saved.HomeworkChecked);
}
[Fact]
public void SaveCurrentHomework_SchreibtAufDieAktuelleStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var seatingPlan = BuildSeatingPlan(group.Id);
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
lessons, seatingPlan, BuildParticipation(group));
vm.Homework.CurrentHomework = "AB 7 fertigstellen";
vm.Homework.SaveCurrentHomeworkCommand.Execute(null);
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, lesson.Date, lesson.Date));
Assert.Equal("AB 7 fertigstellen", saved.Homework);
Assert.Equal("Gespeichert.", vm.Homework.SaveStatus);
}
} }
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using Xunit; using Xunit;
@@ -12,10 +13,12 @@ public sealed class TimetableViewModelTests
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null, FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null, FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null, SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null) FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
PeriodScheduleService? periodSchedule = null)
{ {
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf // Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine( var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}"); System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
@@ -25,7 +28,9 @@ public sealed class TimetableViewModelTests
holidays ?? new FakeSchoolHolidays(), holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath), calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(), new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries()); supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
periodSchedule ?? new PeriodScheduleService(tempPath));
} }
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt — /// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -272,7 +277,23 @@ public sealed class TimetableViewModelTests
Assert.Equal(group.Id, navigatedTo); Assert.Equal(group.Id, navigatedTo);
Assert.False(viewerOpened); Assert.False(viewerOpened);
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel); Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
}
[Fact]
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Q1 Chemie" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
TimetableLessonRequest? requested = null;
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
} }
// ── Unterrichtsmodus (14.x) ──────────────────────────────────────────────── // ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
@@ -333,6 +354,88 @@ public sealed class TimetableViewModelTests
Assert.Equal(lesson.Id, openedLesson?.Id); Assert.Equal(lesson.Id, openedLesson?.Id);
} }
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
[Fact]
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
// Bewusst nicht der reale DateTime.Today-Wochentag: an einem Sa/So gäbe es dafür keine
// Kachel im Mo-Fr-Raster. Stattdessen ein beliebiger Wochentag der laufenden Kalenderwoche
// (Kachel existiert immer), dessen Datum per VM.Clock als "heute" simuliert wird (siehe
// TimetableViewModel.Clock - genau für solche Tests eingeführt).
var weekday = DayOfWeek.Monday;
var lessonDate = DateInCurrentWeek(weekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = lessonDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
// Gleiche Uhrzeit wie BuildPeriodSchedule (reales DateTime.Now), aber am simulierten
// "heutigen" Datum - die Stundenzeit-Prüfung (Uhrzeit) bleibt so unabhängig vom Datum.
vm.Clock = () => lessonDate.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == weekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
Assert.Null(viewerLesson);
}
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
[Fact]
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
var otherDate = DateInCurrentWeek(otherWeekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, viewerLesson?.Id);
Assert.Null(teachingModeLesson);
}
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var service = new PeriodScheduleService(tempPath);
var now = TimeOnly.FromDateTime(DateTime.Now);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
return service;
}
[Fact] [Fact]
public void OpenSettings_RuftOnNavigateToSettingsAuf() public void OpenSettings_RuftOnNavigateToSettingsAuf()
{ {
@@ -609,6 +712,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("1", period2.BadgeText); 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] [Fact]
public void Load_KeineAnstehendenFerien_KeinBadge() public void Load_KeineAnstehendenFerien_KeinBadge()
{ {
@@ -727,6 +855,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation); Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation);
} }
[Fact]
public void Load_ZusaetzlicheAufsichtInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" - dieselbe Anzeige-Infrastruktur wie beim Ausfall oben, hier für
// eine automatisch erkannte zusätzliche Aufsicht ohne reguläre SupervisionDuty.
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Monday).AddDays(7);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Supervision, AfterPeriod = 1,
Description = "Automatisch über WebUntis-Abgleich erkannt: Zusätzliche Aufsicht laut WebUntis.",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
Assert.DoesNotContain(vm.WeekItems, c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
vm.NextWeekCommand.Execute(null);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.True(mondayCell.IsSubstitutionSupervision);
Assert.Contains("Zusätzliche Aufsicht", mondayCell.SupervisionLocation);
}
[Fact] [Fact]
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige() public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
{ {
@@ -930,6 +1083,38 @@ public sealed class TimetableViewModelTests
Assert.Equal("6a auf Klassenfahrt", cell.Topic); Assert.Equal("6a auf Klassenfahrt", cell.Topic);
} }
[Fact]
public void Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Nächste Woche habe ich eine Stunde Ausfall [...] wäre es doch auch
// schön, wenn das im Stundenplan für die nächste Woche irgendwie erkenntlich ist" - genau
// der Weg, den ein automatisch von WebUntis erkannter Ausfall nimmt (derselbe
// SubstitutionEntry-Mechanismus wie bei Handeinträgen), hier erstmals mit WeekOffset != 0
// geprüft.
var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" };
var group = new LearningGroup { Name = "6a", SubjectId = subject.Id };
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Thursday).AddDays(7);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Thursday, PeriodNumber = 3 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Cancelled, PeriodNumber = 3,
Description = "Automatisch über WebUntis-Abgleich erkannt.",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions);
// Aktuelle Woche: an dieser Stelle regulär, noch kein Ausfall sichtbar.
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.False(currentWeekCell.IsCancelled);
vm.NextWeekCommand.Execute(null);
var nextWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.True(nextWeekCell.IsCancelled);
Assert.Equal("Automatisch über WebUntis-Abgleich erkannt.", nextWeekCell.Topic);
}
[Fact] [Fact]
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde() public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
{ {
@@ -1041,4 +1226,119 @@ public sealed class TimetableViewModelTests
Assert.Single(vm.UpcomingExams); Assert.Single(vm.UpcomingExams);
} }
// ── WebUntis-Abweichung ───────────────────────────────────────────────────
[Fact]
public void Load_UntisAbgleichNichtAktiviert_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_UntisAktivSlotOhneBestaetigteZuordnung_ZeigtAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisSettings: untisSettings);
Assert.True(vm.HasUntisMismatch);
Assert.Contains("1 Stundenplan-Eintrag", vm.UntisMismatchLabel);
}
[Fact]
public void Load_UntisAktivMitBestaetigterZuordnung_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_DoppelstundenZuordnungBestaetigtBeideTimetableSlots()
{
// Regression: "ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
// Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" - eine WebUntis-Doppelstunde
// bestätigt beide zugehörigen TimetableSlots, nicht nur den ersten.
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, CoveredPeriods = [1, 2],
GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
}
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
public sealed class WeekCellItemMenuVisibilityTests
{
[Fact]
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
{
var lesson = new Lesson { Topic = "Redox" };
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
Assert.True(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void ForSlot_OhneLesson_HatKeinHasLesson()
{
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
Assert.False(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
{
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
Assert.False(cell.HasGroupId);
}
} }
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisMappingReviewDialogViewModelTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untisreview-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
private static UntisSyncService BuildUntisSyncService(FakeUntisSlotMappings mappings) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
new FakeUntisSnapshots(), mappings, new FakeSubstitutionEntries(), new FakeGroups([]),
new FakeTimetableSlots(), new FakeSupervisionDuties(), new FakeSchoolHolidays(),
new PublicHolidayService(), new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static UntisMappingReviewDialogViewModel BuildDialogVm(FakeUntisSlotMappings mappings,
List<LearningGroup>? groups = null) =>
new(BuildUntisSyncService(mappings), mappings, groups ?? [], new FakeSubjects([]));
private static List<UntisGroupOption> Options(params LearningGroup[] groups) =>
groups.Select(g => new UntisGroupOption(g, null)).ToList();
private static UntisSlotMatch BuildLessonMatch(Guid? groupId, string classToken = "10c") => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", ClassTokens = [classToken], OccurrenceCount = 40,
},
PeriodNumber = 1, SuggestedGroupId = groupId, IsConfident = groupId is not null,
};
private static UntisSlotMatch BuildSupervisionMatch() => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), EndTime = new TimeOnly(9, 40),
Summary = null, ClassTokens = [], OccurrenceCount = 40,
},
AfterPeriod = 2,
};
[Fact]
public void UntisMappingRow_VorschlagWirdVorausgewaehlt()
{
var group = new LearningGroup { Name = "10c" };
var row = new UntisMappingRow(BuildLessonMatch(group.Id), Options(group), existing: null);
Assert.Equal(group.Id, row.SelectedGroup?.Group.Id);
Assert.True(row.IsConfident);
Assert.False(row.IsSupervisionCandidate);
}
[Fact]
public void UntisMappingRow_OhneVorschlag_BleibtLeer()
{
var row = new UntisMappingRow(BuildLessonMatch(null), [], existing: null);
Assert.Null(row.SelectedGroup);
Assert.False(row.IsConfident);
}
[Fact]
public void UntisGroupOption_GleicherNameVerschiedeneFaecher_ZeigtFachImDisplayLabel()
{
// Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das Fach dabei, kann ich nicht
// sicher die richtige Lerngruppe hier auswählen."
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, "Chemie");
Assert.Equal("10c (Chemie)", option.DisplayLabel);
}
[Fact]
public void UntisGroupOption_OhneFach_ZeigtNurDenNamen()
{
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, null);
Assert.Equal("10c", option.DisplayLabel);
}
[Fact]
public void UntisMappingRow_AufsichtsMuster_WirdAlsSupervisionCandidateErkanntUndKannAufgeloestWerden()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten [...] vom Zeitraster und von der
// Dauer her könnten die erfasst werden" - kein Klassenbezug, aber AfterPeriod auflösbar.
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null);
Assert.True(row.IsSupervisionCandidate);
Assert.True(row.CanResolve);
Assert.False(row.ConfirmAsSupervision);
}
[Fact]
public void Save_SchreibtNurZeilenMitAusgewaehlterGruppeAlsBestaetigt()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(null, "9z"), Options(groupA, groupB), existing: null)); // ignoriert
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.True(saved.Confirmed);
Assert.Equal(SubstitutionKind.Lesson, saved.Kind);
Assert.Equal(groupA.Id, saved.GroupId);
Assert.Equal(1, saved.PeriodNumber);
}
[Fact]
public void Save_AufsichtBestaetigt_SchreibtSupervisionMapping()
{
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings);
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null) { ConfirmAsSupervision = true };
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(SubstitutionKind.Supervision, saved.Kind);
Assert.Null(saved.GroupId);
Assert.Equal(2, saved.AfterPeriod);
Assert.True(saved.Confirmed);
}
[Fact]
public void Save_ErneutesSpeichernDerselbenZeile_AktualisiertStattDupliziert()
{
// Regression für "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" -
// wiederholtes Bestätigen desselben Slots darf keine zweite Zeile anlegen (sonst
// ArgumentException in UntisDiffService.Diff beim nächsten Poll, siehe dortigen Test).
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm1 = BuildDialogVm(mappings, [groupA, groupB]);
vm1.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm1.SaveCommand.Execute(null);
var firstSaved = Assert.Single(mappings.GetAll());
// Dialog erneut geöffnet - die bereits bestätigte Gruppe muss vorbefüllt sein.
var existing = mappings.GetAll().Single();
var row2 = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing);
Assert.Equal(groupA.Id, row2.SelectedGroup?.Group.Id);
row2.SelectedGroup = Options(groupB).Single(); // Nutzer korrigiert
var vm2 = BuildDialogVm(mappings, [groupA, groupB]);
vm2.Rows.Add(row2);
vm2.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(firstSaved.Id, saved.Id);
Assert.Equal(groupB.Id, saved.GroupId);
}
[Fact]
public void Save_NutzerAendertVorschlag_UebernimmtNeueAuswahl()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
var row = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null);
row.SelectedGroup = Options(groupB).Single();
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
Assert.Equal(groupB.Id, Assert.Single(mappings.GetAll()).GroupId);
}
}
@@ -0,0 +1,21 @@
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisNameMatchingTests
{
[Theory]
[InlineData("Ben Schmidt", "Schmidt Ben")]
[InlineData("Ada Müller", "MÜLLER ADA")]
[InlineData("Max Mustermann", "mustermann max")]
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
Assert.True(UntisNameMatching.NamesMatch(a, b));
[Theory]
[InlineData("Ben Schmidt", "Ben Müller")]
[InlineData(null, "Ben Schmidt")]
[InlineData("Ben Schmidt", "")]
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
}
@@ -0,0 +1,208 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisReportCacheServiceTests
{
private static readonly DateOnly Today = new(2026, 8, 25);
private static readonly DateOnly HotStart = Today.AddDays(-14);
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
[Fact]
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
{
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
Assert.Equal(HotStart, plan.HotWindowStart);
Assert.Equal(Today, plan.HotWindowEnd);
Assert.False(plan.FetchColdRange);
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
{
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
};
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
Assert.True(plan.RefreshHotWindow);
}
[Fact]
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
{
var requestStart = HotStart.AddDays(-30);
var requestEnd = HotStart.AddDays(-5);
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(requestStart, plan.ColdRangeStart);
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
}
[Fact]
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
{
var coveredSince = HotStart.AddDays(-60);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.False(plan.FetchColdRange);
}
[Fact]
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
{
var coveredSince = HotStart.AddDays(-30);
var earlierStart = HotStart.AddDays(-90);
var state = new UntisCacheFetchState
{
ClassName = "10c", Kind = UntisCacheKind.Absences,
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
};
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
Today, forceRefresh: false, DateTime.UtcNow);
Assert.False(plan.RefreshHotWindow);
Assert.True(plan.FetchColdRange);
Assert.Equal(earlierStart, plan.ColdRangeStart);
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
}
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
[Fact]
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
var csv = Encoding.UTF8.GetBytes(
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
var settings = TestSupport.BuildWebUntisSettingsService();
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var absenceCache = new FakeUntisAbsenceCache();
var classRegisterCache = new FakeUntisClassRegisterCache();
var rosterCache = new FakeUntisStudentRosterCache();
var fetchStates = new FakeUntisCacheFetchStates();
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates);
var today = DateOnly.FromDateTime(DateTime.Today);
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
Assert.Single(first);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
Assert.Single(second);
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
}
[Fact]
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
{
var csv = Encoding.UTF8.GetBytes(
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
"1\t9001\t10c\tMUST\tMustermann\tMax\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"s\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
var settings = TestSupport.BuildWebUntisSettingsService();
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
var first = await cache.GetStudentRosterAsync("10c");
Assert.Single(first);
Assert.Equal("Max Mustermann", first[0].DisplayName);
Assert.Equal(4, handler.Requests.Count);
var second = await cache.GetStudentRosterAsync("10c");
Assert.Single(second);
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<string> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri?.ToString() ?? "");
await Task.Yield();
return _responses.Dequeue();
}
}
}
@@ -0,0 +1,319 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den HTTP-freien Verarbeitungskern von UntisSyncService — kein echter Abruf nötig,
/// synthetischer ICS-Text nach dem realen WebUntis-Format (siehe Planungsdokument).
public sealed class UntisSyncServiceTests
{
private static string BuildIcs(string uid, string dtstart, string? summary, string description, string status = "CONFIRMED") =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" +
"BEGIN:VEVENT\n" +
$"UID:{uid}\n" +
$"STATUS:{status}\n" +
$"DTSTART;TZID=Europe/Berlin:{dtstart}\n" +
$"DTEND;TZID=Europe/Berlin:{dtstart}\n" +
(summary is null ? "" : $"SUMMARY:{summary}\n") +
$"DESCRIPTION:{description}\n" +
"END:VEVENT\nEND:VCALENDAR\n";
private static string EmptyIcs() => "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
private static UntisSyncService BuildService(FakeUntisSnapshots? snapshots = null,
FakeUntisSlotMappings? mappings = null, FakeSubstitutionEntries? substitutions = null,
FakeGroups? groups = null, FakeTimetableSlots? timetableSlots = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSchoolHolidays? schoolHolidays = null) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
snapshots ?? new FakeUntisSnapshots(), mappings ?? new FakeUntisSlotMappings(),
substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]),
timetableSlots ?? new FakeTimetableSlots(), supervisionDuties ?? new FakeSupervisionDuties(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(),
new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untissync-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void ProcessIcsText_UnveraendertesEreignis_SchreibtKeineVertretungAberSnapshot()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var service = BuildService(snapshots: snapshots, mappings: mappings);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, result.EventCount);
Assert.Equal(0, result.SubstitutionCount);
Assert.Single(snapshots.GetAll());
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions);
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Erneuter Poll mit demselben (noch immer abweichenden) Termin darf keinen zweiten
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus mehreren
// Klassen zusammengesetzt sind" — nachdem der eigentliche Vergleichsfehler (ClassToken/
// Trennzeichen) behoben war, blieben bereits erzeugte falsche Vertretungen trotzdem stehen:
// ProcessIcsText schrieb bislang nur NEUE/weiterhin abweichende Kandidaten, räumte aber nie
// eine zuvor automatisch erzeugte Zeile ab, sobald der nächste Poll gar keine Abweichung mehr
// fand. Der Nutzer sah dadurch dieselbe (jetzt stale) Karteileiche, egal wie oft er neu
// zuordnete oder pollte.
[Fact]
public void ProcessIcsText_AbweichungLoestSichSpaeterAuf_EntferntDieZuvorErzeugteVertretung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Poll 1: Fach weicht (noch) ab - Vertretung wird angelegt.
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Poll 2: derselbe Termin weicht jetzt nicht mehr ab (z.B. weil sich die WebUntis-Daten
// geändert haben oder ein Vergleichsfehler zwischenzeitlich behoben wurde) - die zuvor
// erzeugte Vertretung darf nicht als Karteileiche stehen bleiben.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(0, second.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
// ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht
// im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen
// Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates).
[Fact]
public void ProcessIcsText_VonAnfangAnFehlendeStunde_WirdAlsAusfallGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Kein Termin für missingDate im Feed (die 8c ist weg) - ein unbeteiligter, etwas
// späterer Termin belegt lediglich den Feed-Abdeckungshorizont darüber hinaus.
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
}
[Fact]
public void ProcessIcsText_FehlendeStundeAnFerientag_WirdNichtGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday
{
StartDate = DateOnly.FromDateTime(missingDate), EndDate = DateOnly.FromDateTime(missingDate),
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, schoolHolidays: schoolHolidays);
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_OhneBestaetigteZuordnung_SchreibtNieEineVertretung()
{
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void BuildMatchPreview_LiefertVorschlagFuerBekannteGruppe()
{
var group = new LearningGroup { Name = "10c" };
var service = BuildService(groups: new FakeGroups([group]));
var preview = service.BuildMatchPreview(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, preview.EventCount);
var match = Assert.Single(preview.Matches.Matches);
Assert.Equal(group.Id, match.SuggestedGroupId);
}
// ── Nutzer-Feedback: "Ich habe eine Aufsicht vertretungsweise bekommen [...] Die fehlt
// natürlich in der Woche drauf wieder. Wird dann bis zum Ende des gültigen Stundenplans diese
// Stunde als entfallene Aufsicht geführt?" — die "entfallen"-Meldung ist an die konkrete,
// tatsächlich gesehene Snapshot-Zeile eines Datums gekoppelt, nicht an eine dauerhaft
// erwartete wöchentliche Wiederholung: sobald sie einmal als entfallen gemeldet und aus dem
// Snapshot entfernt wurde, gibt es nichts mehr, das in einer Folgewoche erneut "verschwinden"
// könnte, solange WebUntis für diesen Slot keinen neuen Termin mehr listet. Mit einer
// passenden regulären SupervisionDuty, damit dieser Test ausschließlich den
// Verschwinden-Mechanismus prüft, unabhängig von der "Zusätzliche Aufsicht"-Erkennung unten.
[Fact]
public void ProcessIcsText_RegulaereAufsichtVerschwindetEinmalig_MeldetEntfallenNurEinmal()
{
var eventDate = DateTime.Today.AddDays(3); // innerhalb des 14-Tage-Lookaheads
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
// Poll 1: Feed enthält die reguläre Aufsicht wie erwartet - keine Meldung nötig.
var first = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, first.SubstitutionCount);
Assert.Single(snapshots.GetAll());
// Poll 2: WebUntis listet an diesem Slot keinen Termin mehr (einmalig von jemand anderem
// übernommen). Genau EINE "entfallen"-Meldung, Snapshot-Zeile wird bereinigt.
var second = service.ProcessIcsText(EmptyIcs());
Assert.Equal(1, second.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Empty(snapshots.GetAll());
// Poll 3, 4, ...: weiterhin kein Termin an diesem Slot - keine erneute Meldung, "bis zum
// Ende des gültigen Stundenplans" bleibt es bei dem einen Eintrag.
var third = service.ProcessIcsText(EmptyIcs());
Assert.Equal(0, third.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" — eine bestätigte Aufsichts-Zuordnung OHNE passende reguläre
// SupervisionDuty (Einstellungen "Aufsichten") ist selbst schon die meldenswerte Vertretung
// und soll deshalb sofort bei jedem Vorkommen als SubstitutionEntry sichtbar werden, nicht
// erst bei einer Abweichung davon.
[Fact]
public void ProcessIcsText_ZusaetzlicheAufsichtOhneRegulaereDuty_WirdSofortGemeldet()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Contains("Zusätzliche Aufsicht", entry.Description);
// 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.Single(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_AufsichtMitRegulaererDuty_MeldetNichtsBeiNormalerAnwesenheit()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ConfirmMappings_SpeichertAlsBestaetigt()
{
var mappings = new FakeUntisSlotMappings();
var service = BuildService(mappings: mappings);
var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, GroupId = Guid.NewGuid() };
service.ConfirmMappings([mapping]);
Assert.True(Assert.Single(mappings.GetAll()).Confirmed);
}
}
@@ -0,0 +1,88 @@
using System.Net;
using System.Text;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisIntegrationServiceTests
{
[Fact]
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
Json("{\"result\":{}}"));
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var teachers = await service.GetTeachersAsync();
Assert.Equal("KUE", Assert.Single(teachers).Name);
Assert.Equal(3, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
}
[Fact]
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
{
var csv = Encoding.UTF8.GetBytes(
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
Json("{\"data\":{\"finished\":true,\"error\":false," +
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
Json("{\"result\":{}}")); // logout (Dispose)
var settings = TestSupport.BuildWebUntisSettingsService();
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
await service.ConnectAsync(new WebUntisCredentials(
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
var entries = await service.GetClassAbsencesAsync("10c",
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
var entry = Assert.Single(entries);
Assert.Equal("Muster Max", entry.StudentName);
Assert.Equal("nicht entsch.", entry.Status);
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
Assert.Single(handler.Requests, request =>
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
}
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new(request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed record CapturedRequest(string Uri, string Body);
}
@@ -0,0 +1,124 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisSettingsServiceTests
{
private const string SampleUrl = "https://example.webuntis.com/WebUntis/ical_export?school=example&id=test&elemId=1&token=geheim";
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void SetEnabled_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetEnabled(true);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.Enabled);
}
[Fact]
public void SetIcalUrl_IstVerschluesseltAbrufbar()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
[Fact]
public void IcalUrl_UeberlebtNeueInstanzMitDemselbenPfad()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetIcalUrl(SampleUrl);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.IsConfigured);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
}
[Fact]
public void KonfigurationsdateiEnthaeltNichtDasTokenImKlartext()
{
var path = BuildTempPath();
var service = new WebUntisSettingsService(path);
service.SetIcalUrl(SampleUrl);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
Assert.DoesNotContain("token=geheim", raw);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.Null(service.GetIcalUrl());
Assert.False(service.Enabled);
}
[Fact]
public void SetLastSync_PersistiertZeitstempelUndStatus()
{
var path = BuildTempPath();
var at = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
new WebUntisSettingsService(path).SetLastSync(at, "3 Vertretungen erkannt");
var reloaded = new WebUntisSettingsService(path);
Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
}
[Fact]
public void ApiCredentials_SindVerschluesseltUndPersistieren()
{
var path = BuildTempPath();
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
var service = new WebUntisSettingsService(path);
service.SetApiCredentials(credentials);
service.SetTeacherUntisId(4711);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
var reloaded = new WebUntisSettingsService(path);
Assert.DoesNotContain("sehr geheim", raw);
Assert.True(reloaded.ApiIsConfigured);
Assert.Equal(credentials, reloaded.GetApiCredentials());
Assert.Equal(4711, reloaded.TeacherUntisId);
}
[Fact]
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetApiCredentials(new("schule", "", "user", "password"));
service.SetTeacherUntisId(12);
service.ClearApiCredentials();
Assert.False(service.ApiIsConfigured);
Assert.Null(service.GetApiCredentials());
Assert.Null(service.TeacherUntisId);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
}
+31
View File
@@ -2,6 +2,25 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App" x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
</ResourceDictionary.MergedDictionaries>
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 Z</StreamGeometry>
<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>
<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>
</Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
@@ -16,5 +35,17 @@
<Setter Property="Opacity" Value="0.4"/> <Setter Property="Opacity" Value="0.4"/>
<Setter Property="FontSize" Value="13"/> <Setter Property="FontSize" Value="13"/>
</Style> </Style>
<Style Selector="Button.touchTarget">
<Setter Property="MinWidth" Value="40"/>
<Setter Property="MinHeight" Value="40"/>
</Style>
<Style Selector="Button:focus-visible">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>
+108 -4
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
@@ -10,7 +11,9 @@ using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Students; using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.ViewModels.Workload;
using LehrerApp.Desktop.Views; using LehrerApp.Desktop.Views;
using LehrerApp.Desktop.Views.Workload;
using LehrerApp.Sync; using LehrerApp.Sync;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -79,6 +82,15 @@ public class App : Application
var mainVm = Services.GetRequiredService<MainWindowViewModel>(); var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm); 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();
var main = new MainWindow { DataContext = mainVm }; var main = new MainWindow { DataContext = mainVm };
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>()); main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
if (Services.GetService<SyncEngine>() is { } syncEngine) if (Services.GetService<SyncEngine>() is { } syncEngine)
@@ -100,11 +112,12 @@ public class App : Application
private static void DisposeServices() private static void DisposeServices()
{ {
if (_serviceProvider is null) return; var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
if (serviceProvider is null) return;
try try
{ {
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint(); serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -112,8 +125,19 @@ public class App : Application
} }
finally finally
{ {
_serviceProvider.Dispose(); // Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
_serviceProvider = null; // Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
try
{
Task.Run(async () =>
await serviceProvider.DisposeAsync().ConfigureAwait(false))
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
}
} }
} }
@@ -130,6 +154,28 @@ public class App : Application
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit" dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren" dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung" 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);
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
var search = Services.GetRequiredService<GlobalSearchViewModel>();
search.OnNavigate = result =>
{
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
main.NavigateToStudent(studentId);
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
main.NavigateToGroupDetail(groupId);
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
main.NavigateToGroupDetail(examGroupId, 4);
else if (result.Kind == GlobalSearchResultKind.Task)
main.NavigateToWorkload();
};
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
search.OnQuickAddStudent = ShowAddStudentDialog;
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
// StudentList → StudentDetail + Anlegen // StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>(); var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -150,4 +196,62 @@ public class App : Application
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
await dialog.ShowDialog<bool>(owner); await dialog.ShowDialog<bool>(owner);
} }
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
if (result is null) return;
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
dashboard.RefreshCommand.Execute(null);
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
{ MainWindow: { } owner }) return;
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
.ToDictionary(s => s.Id);
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
.Where(g => g.IsActive)
.OrderBy(g => g.Name)
.Select(g => new GroupDocumentationOption(g.Id,
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
: g.Name));
var vm = new GroupDocumentationQuickViewModel(options);
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
}
} }
+57 -3
View File
@@ -5,6 +5,8 @@ using LehrerApp.Data;
using LehrerApp.Data.Repositories; using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using LehrerApp.Desktop.ViewModels.Exams;
using LehrerApp.Desktop.ViewModels.Groups; using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
@@ -14,6 +16,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models; using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop; namespace LehrerApp.Desktop;
@@ -115,13 +118,22 @@ public static class AppBootstrapper
services.AddSingleton<NotificationService>(); services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>(); services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>(); services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
// ── Datensicherheit (13.3) ─────────────────────────────────────────── // ── Datensicherheit (13.3) ───────────────────────────────────────────
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
var backup = new BackupService(appData); var backupSettings = new BackupSettingsService(appData);
backup.CreateBackup(DbPath); var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
var backupPath = backup.CreateBackup(DbPath);
// 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))
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
services.AddSingleton(backup); services.AddSingleton(backup);
services.AddSingleton(backupSettings);
services.AddSingleton(_ => new AppLockService(appData)); services.AddSingleton(_ => new AppLockService(appData));
services.AddSingleton<DatabaseEncryptionService>(); services.AddSingleton<DatabaseEncryptionService>();
services.AddSingleton(_ => new PrivacySettingsService(appData)); services.AddSingleton(_ => new PrivacySettingsService(appData));
@@ -165,6 +177,13 @@ public static class AppBootstrapper
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>(); services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>(); services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>(); services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
// ── Services ────────────────────────────────────────────────────────── // ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>(); services.AddSingleton<GradingService>();
@@ -178,7 +197,6 @@ public static class AppBootstrapper
services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton<PlanningExchangeService>(); services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ────── // ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
@@ -186,10 +204,42 @@ public static class AppBootstrapper
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
services.AddSingleton<AiPlanningService>(); services.AddSingleton<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
services.AddSingleton(annualPlanSettings);
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new AnnualPlanSyncService(
new HttpClient(), annualPlanSettings,
sp.GetRequiredService<IAnnualPlanEventRepository>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ──────────────────── // ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData); var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings); services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient())); services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
services.AddSingleton<UntisReportCacheService>();
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten) // 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 // 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. // Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -251,6 +301,7 @@ public static class AppBootstrapper
// Singleton: einmal erstellt, überall dieselbe Instanz // Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<AppLockViewModel>(); services.AddSingleton<AppLockViewModel>();
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<GlobalSearchViewModel>();
services.AddSingleton<DashboardViewModel>(); services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp => services.AddSingleton(sp =>
new SyncStatusViewModel( new SyncStatusViewModel(
@@ -263,6 +314,9 @@ public static class AppBootstrapper
services.AddSingleton<TimeTrackingViewModel>(); services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>(); services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>(); services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten) // Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>(); services.AddTransient<GroupDetailViewModel>();
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
namespace LehrerApp.Desktop.Converters;
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
/// statt Grid-Sternspalten.</summary>
public sealed class FractionWidthConverter : IMultiValueConverter
{
public static readonly FractionWidthConverter Instance = new();
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
return Math.Max(0d, trackWidth * fraction);
}
}
@@ -9,6 +9,8 @@
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" /> <ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" /> <ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" /> <ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
@@ -0,0 +1,87 @@
using System.Text.Json;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Services;
internal sealed class AnnualPlanSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
}
/// <summary>
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
/// </summary>
public sealed class AnnualPlanSettingsService
{
private readonly string _configPath;
private readonly byte[] _urlKey;
private AnnualPlanSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public AnnualPlanSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
_config = Load();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() => _config.EncryptedIcalUrl is null
? null
: SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
Save();
}
private byte[] GenerateAndSaveKey(string keyPath)
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
}
private AnnualPlanSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
?? new AnnualPlanSettingsConfig();
}
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
return new AnnualPlanSettingsConfig();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
@@ -0,0 +1,147 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
{
public int ChangeCount => Added + Updated + Deleted;
}
/// <summary>
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
/// </summary>
public sealed class AnnualPlanSyncService : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
private readonly HttpClient _http;
private readonly AnnualPlanSettingsService _settings;
private readonly IAnnualPlanEventRepository _events;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
public event Action? DataChanged;
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
IAnnualPlanEventRepository events, AppLogger? logger = null)
{
_http = http;
_settings = settings;
_events = events;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrWhiteSpace(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
AnnualPlanPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
if (result.ChangeCount > 0) DataChanged?.Invoke();
}
finally
{
_gate.Release();
}
}
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
public AnnualPlanPollResult ProcessIcsText(string icsText)
{
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
var imported = AnnualPlanIcsParser.Parse(icsText);
var existing = _events.GetAll();
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
var added = 0;
var updated = 0;
foreach (var entry in imported)
{
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
{
_events.Save(entry);
added++;
continue;
}
entry.Id = previous.Id;
if (SameContent(previous, entry)) continue;
_events.Save(entry);
updated++;
}
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
foreach (var entry in stale) _events.Delete(entry.Id);
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
}
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
left.ExternalId == right.ExternalId &&
left.Title == right.Title &&
left.Description == right.Description &&
left.Location == right.Location &&
left.StartDate == right.StartDate &&
left.EndDate == right.EndDate &&
left.StartTime == right.StartTime &&
left.EndTime == right.EndTime &&
left.IsAllDay == right.IsAllDay &&
left.CalendarGroup == right.CalendarGroup &&
left.Color == right.Color &&
left.Status == right.Status &&
left.Sequence == right.Sequence &&
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
private static bool SameInstant(DateTime? left, DateTime? right) =>
left is null && right is null ||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
public void Clear()
{
var existing = _events.GetAll();
foreach (var entry in existing) _events.Delete(entry.Id);
if (existing.Count > 0) DataChanged?.Invoke();
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -0,0 +1,36 @@
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal sealed class BackupSettingsConfig
{
public string? SecondaryDirectory { get; set; }
}
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
public sealed class BackupSettingsService
{
private readonly string _configPath;
public BackupSettingsService(string appDataPath) =>
_configPath = Path.Combine(appDataPath, "backupsettings.json");
public string? LoadSecondaryDirectory()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
?.SecondaryDirectory;
}
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
return null;
}
public void SaveSecondaryDirectory(string? path) =>
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
}
@@ -0,0 +1,73 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
public sealed class SchoolWeatherException(string message) : Exception(message);
/// <summary>Client für das serverseitige Schulstandort-/Wetterprofil. Liest URL und Token je
/// Aufruf, damit ein Login oder Serverwechsel in den Einstellungen sofort berücksichtigt wird.</summary>
public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings)
{
public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn;
public async Task<SchoolLocationProfile?> GetLocationAsync(CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Get, "/api/school/location", null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken);
}
public async Task<SchoolLocationProfile> SaveLocationAsync(SchoolLocationRequest location,
CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Put, "/api/school/location", location, cancellationToken);
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken)
?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben.");
}
public async Task<WeatherSnapshot?> GetWeatherAsync(CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Get, "/api/school/weather", null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<WeatherSnapshot>(cancellationToken);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(syncSettings.ServerUrl) || !syncSettings.IsLoggedIn)
throw new SchoolWeatherException("Bitte zuerst im Reiter „Synchronisation“ am Server anmelden.");
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
var token = syncSettings.GetToken();
if (token is not null)
request.Headers.Authorization = new("Bearer", token);
if (body is not null) request.Content = JsonContent.Create(body);
try { return await http.SendAsync(request, cancellationToken); }
catch (HttpRequestException)
{
throw new SchoolWeatherException("Der LehrerApp-Server ist nicht erreichbar.");
}
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new SchoolWeatherException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
try
{
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (document.RootElement.TryGetProperty("detail", out var detail) &&
!string.IsNullOrWhiteSpace(detail.GetString()))
throw new SchoolWeatherException(detail.GetString()!);
}
catch (JsonException) { }
throw new SchoolWeatherException("Der Schulstandort konnte nicht gespeichert werden.");
}
}
@@ -0,0 +1,171 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis von <see cref="UntisReportCacheService.Plan"/>: was für eine Anfrage
/// [<c>RequestStart</c>..<c>RequestEnd</c>, nicht Teil dieses Records, siehe Aufrufer] tatsächlich
/// live nachgeladen werden muss, und welcher Deckungsstand danach gilt. Bewusst als reine, ohne
/// Repository/HTTP-Zugriff testbare Funktion ausgelagert (gleiches Muster wie
/// <see cref="ClassTeacher.ClassAbsenceDaySummaryRow.GroupByStudentAndDay"/>).</summary>
public readonly record struct UntisCacheRefreshPlan(
bool RefreshHotWindow, DateOnly HotWindowStart, DateOnly HotWindowEnd,
bool FetchColdRange, DateOnly ColdRangeStart, DateOnly ColdRangeEnd,
int? ResultingColdCoverageStartDate);
/// <summary>
/// Lokaler Cache für die beiden Klassenlehrer-Berichte (Fehlzeiten, Klassenbucheinträge anderer
/// Lehrkräfte, siehe TODO.md) - Nutzer-Feedback: Warnungen sollen sofort da sein, ohne bei jedem
/// Öffnen der Ansicht neu abzurufen, und der Bericht darf nicht mehrfach pro Stunde abgerufen
/// werden (Sorge, bei WebUntis aufzufallen, wenn dieselbe Ansicht mehrfach geöffnet wird). Ein
/// festes "heißes" Fenster der letzten <see cref="HotWindowDays"/> Tage wird höchstens stündlich
/// aufgefrischt (dort kann sich der Status noch ändern, z. B. "ausstehend" → "entschuldigt");
/// alles Ältere gilt als endgültig und wird, einmal abgerufen, dauerhaft aus dem Cache bedient.
/// Die Cache-Tabellen selbst sind bewusst nicht synchronisiert (siehe
/// <see cref="IUntisAbsenceCacheRepository"/>-Implementierung) - jedes Gerät füllt seinen Cache
/// über diesen Service selbst.
/// </summary>
public sealed class UntisReportCacheService(
WebUntisIntegrationService untis,
IUntisAbsenceCacheRepository absenceCache,
IUntisClassRegisterCacheRepository classRegisterCache,
IUntisStudentRosterCacheRepository rosterCache,
IUntisCacheFetchStateRepository fetchState)
{
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14;
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
/// Behebt, dass die Klassenlehrer-Übersicht bei jedem Öffnen spürbar verzögert wirkte: der
/// Roster-Abruf lief bislang komplett am Cache vorbei live gegen WebUntis (Nutzer-Feedback).
public async Task<IReadOnlyList<UntisStudentRosterCacheEntry>> GetStudentRosterAsync(string className,
bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Roster);
if (NeedsRefresh(state, forceRefresh, DateTime.UtcNow))
{
var fresh = await untis.GetStudentsAsync(className, token);
rosterCache.ReplaceAll(className, fresh.Students.Select(s => new UntisStudentRosterCacheEntry
{
ClassName = className, ExternKey = s.ExternKey, DisplayName = s.DisplayName,
}));
fetchState.Save(new UntisCacheFetchState
{
Id = state?.Id ?? Guid.NewGuid(), ClassName = className, Kind = UntisCacheKind.Roster,
HotWindowFetchedAt = DateTime.UtcNow,
});
}
return rosterCache.GetByClass(className);
}
private static bool NeedsRefresh(UntisCacheFetchState? state, bool forceRefresh, DateTime utcNow) =>
forceRefresh || state?.HotWindowFetchedAt is null || utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval;
public async Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetAbsencesAsync(string className,
DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.Absences);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetClassAbsencesAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
absenceCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetClassAbsencesAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
absenceCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.Absences, plan));
return absenceCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
public async Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, bool forceRefresh = false, CancellationToken token = default)
{
var state = fetchState.Get(className, UntisCacheKind.ClassRegister);
var plan = Plan(state, start, end, Today(), forceRefresh, DateTime.UtcNow);
if (plan.RefreshHotWindow)
{
var fresh = await untis.GetForeignClassRegisterEventsAsync(className, plan.HotWindowStart, plan.HotWindowEnd, token);
classRegisterCache.ReplaceRange(className, Int(plan.HotWindowStart), Int(plan.HotWindowEnd), fresh.Select(ToEntry));
}
if (plan.FetchColdRange)
{
var older = await untis.GetForeignClassRegisterEventsAsync(className, plan.ColdRangeStart, plan.ColdRangeEnd, token);
classRegisterCache.InsertRange(older.Select(ToEntry));
}
if (plan.RefreshHotWindow || plan.FetchColdRange)
fetchState.Save(NextState(state, className, UntisCacheKind.ClassRegister, plan));
return classRegisterCache.GetByClassAndRange(className, Int(start), Int(end)).Select(ToDto).ToList();
}
/// Reine Entscheidungslogik ohne Repository-/HTTP-Zugriff: was muss für eine Anfrage
/// [<paramref name="requestStart"/>..<paramref name="requestEnd"/>] live nachgeladen werden,
/// gegeben den zuletzt gespeicherten Zustand. Das heiße Fenster ist immer fest
/// [<paramref name="today"/> <see cref="HotWindowDays"/>, <paramref name="today"/>], unabhängig
/// von der angefragten Spanne. Nach einem heißen Refresh reicht die bekannte Abdeckung mindestens
/// bis zum Fensteranfang zurück; ein zusätzlicher kalter Abruf erweitert sie nur so weit wie
/// für <paramref name="requestStart"/> nötig, nie weiter.
public static UntisCacheRefreshPlan Plan(UntisCacheFetchState? state, DateOnly requestStart, DateOnly requestEnd,
DateOnly today, bool forceRefresh, DateTime utcNow)
{
var hotStart = today.AddDays(-HotWindowDays);
var refreshHot = requestEnd >= hotStart && (forceRefresh || state?.HotWindowFetchedAt is null
|| utcNow - state.HotWindowFetchedAt >= HotWindowRefreshInterval);
int? coverageStart = state?.ColdCoverageStartDate;
if (refreshHot)
coverageStart = coverageStart is { } existing ? Math.Min(existing, Int(hotStart)) : Int(hotStart);
var coldEnd = coverageStart is { } covered ? FromInt(covered).AddDays(-1) : hotStart.AddDays(-1);
var fetchCold = (coverageStart is null || requestStart < FromInt(coverageStart.Value)) && requestStart <= coldEnd;
if (fetchCold) coverageStart = Int(requestStart);
return new UntisCacheRefreshPlan(refreshHot, hotStart, today, fetchCold, requestStart, coldEnd, coverageStart);
}
private static UntisCacheFetchState NextState(UntisCacheFetchState? previous, string className,
UntisCacheKind kind, UntisCacheRefreshPlan plan) => new()
{
Id = previous?.Id ?? Guid.NewGuid(),
ClassName = className,
Kind = kind,
HotWindowFetchedAt = plan.RefreshHotWindow ? DateTime.UtcNow : previous?.HotWindowFetchedAt,
ColdCoverageStartDate = plan.ResultingColdCoverageStartDate,
};
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
private static DateOnly FromInt(int value) => new(value / 10000, value / 100 % 100, value % 100);
private static UntisAbsenceCacheEntry ToEntry(UntisClassAbsenceEntryDto x) => new()
{
ClassName = x.ClassName, StudentName = x.StudentName, ExternKey = x.ExternKey, Date = x.Date,
AbsentPeriods = x.AbsentPeriods, AbsentMinutes = x.AbsentMinutes, TeacherUsernames = x.TeacherUsernames,
Subject = x.Subject, AbsenceReason = x.AbsenceReason, Note = x.Note, EntryId = x.EntryId,
HandledOn = x.HandledOn, Counts = x.Counts, ExcuseNote = x.ExcuseNote, PeriodNumber = x.PeriodNumber,
Status = x.Status, CountsAsFullDay = x.CountsAsFullDay,
};
private static UntisClassAbsenceEntryDto ToDto(UntisAbsenceCacheEntry e) => new(
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);
private static UntisClassRegisterCacheEntry ToEntry(UntisForeignClassRegisterEventDto x) => new()
{
ClassName = x.ClassName, Date = x.Date, Subject = x.Subject, StudentName = x.StudentName,
TeacherUsername = x.TeacherUsername, CategoryName = x.CategoryName, CategoryGroup = x.CategoryGroup,
Text = x.Text,
};
private static UntisForeignClassRegisterEventDto ToDto(UntisClassRegisterCacheEntry e) => new(
e.ClassName, e.Date, e.Subject, e.StudentName, e.TeacherUsername, e.CategoryName, e.CategoryGroup, e.Text);
}
@@ -0,0 +1,195 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für
/// Statusanzeige/Logging.</summary>
public sealed record UntisPollResult(int EventCount, int SubstitutionCount);
/// <summary>Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für
/// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird.</summary>
public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches);
/// <summary>
/// Orchestriert den periodischen WebUntis-iCal-Abgleich (siehe TODO.md/Planungsdokument): Abruf
/// per HTTP, Parsen (IcsParser), Musterabgleich (UntisMatchingService) sowie laufender
/// Schnappschuss-Abgleich (UntisDiffService), dessen Ergebnis über die Repositories geschrieben
/// wird. Gleiches Timer/Gate/Dispose-Muster wie LehrerApp.Sync.SyncEngine.
///
/// Der reine Verarbeitungskern (<see cref="ProcessIcsText"/>) ist bewusst ohne HTTP-Zugriff
/// gehalten (public statt internal, da diese Codebasis kein InternalsVisibleTo nutzt), damit er
/// direkt mit vorgefertigtem ICS-Text getestet werden kann, ohne einen echten Abruf zu brauchen.
/// </summary>
public class UntisSyncService : IDisposable
{
private const int PollIntervalMinutes = 60;
private readonly HttpClient _http;
private readonly WebUntisSettingsService _settings;
private readonly IUntisSnapshotRepository _snapshots;
private readonly IUntisSlotMappingRepository _mappings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IGroupRepository _groups;
private readonly ITimetableSlotRepository _timetableSlots;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly PeriodScheduleService _periodSchedule;
private readonly UntisMatchingService _matchingService;
private readonly UntisDiffService _diffService;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
/// Feuert, wenn ein Poll tatsächlich Vertretungen geschrieben hat — Timetable/Dashboard können
/// dann bei Bedarf neu laden (EventApplier-Muster: direktes Schreiben an ViewModels vorbei).
public event Action? DataChanged;
public UntisSyncService(HttpClient http, WebUntisSettingsService settings, IUntisSnapshotRepository snapshots,
IUntisSlotMappingRepository mappings, ISubstitutionEntryRepository substitutions, IGroupRepository groups,
ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties,
ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays,
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null)
{
_http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings;
_substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots;
_supervisionDuties = supervisionDuties;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null,
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("WebUntis-Abgleich: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
UntisPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
// Darf NICHT aus PollAsync herausfallen: der Timer-Callback fängt keine
// Ausnahmen ab, eine unbehandelte Exception hier würde den gesamten Prozess
// beenden (gleiche Begründung wie EventApplier in LehrerApp.Sync).
_logger?.Error("WebUntis-Abgleich: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.SubstitutionCount} Änderung(en) erkannt ({result.EventCount} Termine geprüft).");
_logger?.Info($"WebUntis-Abgleich: {result.SubstitutionCount} Änderung(en) aus {result.EventCount} Terminen.");
if (result.SubstitutionCount > 0) DataChanged?.Invoke();
}
finally { _gate.Release(); }
}
// Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo
// in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal).
public UntisPollResult ProcessIcsText(string icsText)
{
var events = IcsParser.Parse(icsText);
var previousSnapshot = _snapshots.GetAll();
var confirmedMappings = _mappings.GetAll();
var today = DateOnly.FromDateTime(DateTime.Today);
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
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;
_substitutions.Save(candidate);
}
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
// ExternalId, ist das ein no-op.
foreach (var externalId in diffResult.SubstitutionExternalIdsToDelete)
{
var stale = _substitutions.GetByExternalId(externalId);
if (stale is not null) _substitutions.Delete(stale.Id);
}
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);
}
// 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
// meldet, an denen WebUntis ohnehin keine Termine führt. Gleiche Logik wie
// TimetableViewModel.IsFreeDay, hier separat gehalten statt geteilt, da UntisDiffService
// (LehrerApp.Core) bewusst framework-frei bleibt und keine Desktop-ViewModels referenziert.
private HashSet<DateOnly> BuildFreeDates(DateOnly today)
{
var horizonEnd = today.AddDays(90);
var freeDates = new HashSet<DateOnly>();
foreach (var year in new[] { today.Year, today.Year + 1 })
foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State))
freeDates.Add(h.Date);
var schoolHolidays = _schoolHolidays.GetAll();
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
if (schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate))
freeDates.Add(date);
return freeDates;
}
/// Für den Zuordnungs-Review-Dialog (Stufe 1, ohne etwas zu speichern).
public UntisMatchPreview BuildMatchPreview(string icsText)
{
var events = IcsParser.Parse(icsText);
var groups = _groups.GetAll();
var timetableSlots = _timetableSlots.GetAll();
var matches = _matchingService.BuildMatches(events, groups, timetableSlots, _periodSchedule);
return new UntisMatchPreview(events.Count, matches);
}
public async Task<UntisMatchPreview?> FetchAndBuildMatchPreviewAsync()
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return null;
var icsText = await _http.GetStringAsync(url);
return BuildMatchPreview(icsText);
}
public void ConfirmMappings(IEnumerable<UntisSlotMapping> confirmed)
{
foreach (var mapping in confirmed)
{
mapping.Confirmed = true;
_mappings.Save(mapping);
}
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -0,0 +1,243 @@
using LehrerApp.WebUntis;
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)
{
public string DisplayName => string.IsNullOrWhiteSpace(LongName)
? Name
: $"{ForeName} {LongName} ({Name})".Trim();
}
public sealed record UntisEntityDto(int Id, string Name, int? OriginalId, string? OriginalName, string? ExternalKey);
public sealed record UntisTimeUnitDto(string Name, int StartTime, int EndTime);
public sealed record UntisTimeGridDayDto(int Day, IReadOnlyList<UntisTimeUnitDto> TimeUnits);
public sealed record UntisTimetablePeriodDto(int Id, int Date, int StartTime, int EndTime, string? Code,
string? ActivityType, string? Info, string? LessonText, string? SubstitutionText, string? StudentGroup,
IReadOnlyList<UntisEntityDto> Classes, IReadOnlyList<UntisEntityDto> Teachers,
IReadOnlyList<UntisEntityDto> Subjects, IReadOnlyList<UntisEntityDto> Rooms);
public sealed record UntisStudentAddressDto(string? Email, string? Mobile, string? Phone, string? City,
string? PostCode, string? Street);
public sealed record UntisStudentDto(int UntisId, int? ExternKey, string ClassName, string? Name, string? LongName,
string? ForeName, string DisplayName, string? Gender, int? BirthDate, string? BirthDateRaw, int? EntryDate,
string? EntryDateRaw, int? ExitDate, string? ExitDateRaw, string? Text, string? MedicalReportDuty,
string? Schulpflicht, string? Majority, UntisStudentAddressDto Address, string? AttributeIL);
public sealed record UntisStudentReportDto(int Count, string? ClassNameFilter, IReadOnlyList<UntisStudentDto> Students);
public sealed record UntisLessonAbsenceDto(string StudentName, int Date, int AbsentPeriods,
int UnexcusedAbsentPeriods, int AbsentMinutes, int UnexcusedAbsentMinutes, int? StartTime, int? EndTime,
string? Reason, int? ExternKey, bool ExternKeyInParentheses, string? HandledOn, bool Counts);
public sealed record UntisClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisForeignClassRegisterEventDto(string ClassName, int Date, string? Subject,
string StudentName, string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text);
public sealed record UntisClassAbsenceEntryDto(string StudentName, int? ExternKey, string ClassName, int Date,
int AbsentPeriods, int AbsentMinutes, string? TeacherUsernames, string? Subject, string? AbsenceReason,
string? Note, int? EntryId, string? HandledOn, bool Counts, string? ExcuseNote, int? PeriodNumber,
string? Status, bool CountsAsFullDay);
/// <summary>Direkter WebUntis-Client des Desktops. Personenbezogene Antworten und der
/// unverschlüsselte CSV-Report passieren zu keinem Zeitpunkt den LehrerApp-Server.</summary>
public sealed class WebUntisIntegrationService(HttpClient http, WebUntisSettingsService settings) : IAsyncDisposable
{
private readonly SemaphoreSlim _clientGate = new(1, 1);
private WebUntisClient? _client;
public bool IsAvailable => settings.ApiIsConfigured;
public async Task ConnectAsync(WebUntisCredentials credentials, CancellationToken token = default)
{
var candidate = CreateClient(credentials);
try
{
await candidate.GetSchoolYearsAsync(token);
}
catch (Exception exception) when (IsUntisError(exception))
{
await candidate.DisposeAsync();
throw Translate(exception);
}
WebUntisClient? previous;
await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = candidate; }
finally { _clientGate.Release(); }
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
}
public async Task DisconnectAsync(CancellationToken token = default)
{
WebUntisClient? previous;
await _clientGate.WaitAsync(token).ConfigureAwait(false);
try { previous = _client; _client = null; }
finally { _clientGate.Release(); }
if (previous is not null) await previous.DisposeAsync().ConfigureAwait(false);
}
public Task<IReadOnlyList<UntisSchoolYearDto>> GetSchoolYearsAsync(CancellationToken token = default) => ExecuteAsync(
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);
public Task<IReadOnlyList<UntisTeacherDto>> GetTeachersAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTeacherDto>)(await client.GetTeachersAsync(token))
.Select(x => new UntisTeacherDto(x.UntisId, x.Name, x.ForeName, x.LongName, x.Title, x.Active,
x.DepartmentUntisIds)).ToList(), token);
public Task<IReadOnlyList<UntisTimeGridDayDto>> GetTimeGridAsync(CancellationToken token = default) => ExecuteAsync(
async client => (IReadOnlyList<UntisTimeGridDayDto>)(await client.GetTimeGridAsync(token))
.Select(x => new UntisTimeGridDayDto(x.Day,
x.TimeUnits.Select(t => new UntisTimeUnitDto(t.Name, t.StartTime, t.EndTime)).ToList())).ToList(), token);
public Task<IReadOnlyList<UntisTimetablePeriodDto>> GetTimetableAsync(int teacherId, DateOnly start,
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
(IReadOnlyList<UntisTimetablePeriodDto>)(await client.GetTimetableAsync(
UntisTimetableElementType.Teacher, teacherId, Date(start), Date(end), token))
.Select(x => new UntisTimetablePeriodDto(x.Id, x.Date, x.StartTime, x.EndTime, x.Code,
x.ActivityType, x.Info, x.LessonText, x.SubstitutionText, x.StudentGroup,
Entities(x.Classes), Entities(x.Teachers), Entities(x.Subjects), Entities(x.Rooms))).ToList(), token);
public Task<UntisStudentReportDto> GetStudentsAsync(string className, CancellationToken token = default) => ExecuteAsync(
async client =>
{
var report = await client.GetStudentReportAsync(className, token);
return new UntisStudentReportDto(report.Count, report.ClassNameFilter, report.Students.Select(x =>
new UntisStudentDto(x.UntisId, x.ExternKey, x.ClassName, x.Name, x.LongName, x.ForeName,
x.DisplayName, x.Gender, x.BirthDate, x.BirthDateRaw, x.EntryDate, x.EntryDateRaw,
x.ExitDate, x.ExitDateRaw, x.Text, x.MedicalReportDuty, x.Schulpflicht, x.Majority,
new UntisStudentAddressDto(x.Address.Email, x.Address.Mobile, x.Address.Phone, x.Address.City,
x.Address.PostCode, x.Address.Street), x.AttributeIL)).ToList());
}, token);
public Task<IReadOnlyList<UntisLessonAbsenceDto>> GetLessonAbsencesAsync(int lessonId,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var absences = await client.GetLessonAbsencesAsync(lessonId, Date(start), Date(end), token);
return (IReadOnlyList<UntisLessonAbsenceDto>)absences.Select(x => new UntisLessonAbsenceDto(
x.StudentName, x.Date, x.AbsentPeriods, x.UnexcusedAbsentPeriods, x.AbsentMinutes,
x.UnexcusedAbsentMinutes, x.StartTime, x.EndTime, x.Reason, x.ExternKey, x.ExternKeyInParentheses,
x.HandledOn, x.Counts)).ToList();
}, token);
// "-alle-"-Bericht, hier auf eigene Einträge gefiltert (Benutzer == eigener WebUntis-Login) - Einträge
// anderer Lehrkräfte zu Schülern der eigenen Klasse gehören zu einem eigenständigen, noch nicht
// gebauten "Klassenlehrer"-Feature (siehe TODO.md), nicht zum reinen Dokumentations-Abgleich.
public Task<IReadOnlyList<UntisClassRegisterEventDto>> GetOwnClassRegisterEventsAsync(DateOnly start,
DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisClassRegisterEventDto>)entries
.Where(x => ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase))
.Select(x => new UntisClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Spiegelbild von GetOwnClassRegisterEventsAsync für das "Klassenlehrer"-Feature (siehe TODO.md):
// dieselben "-alle-"-Rohdaten, aber Einträge anderer Lehrkräfte statt der eigenen, zusätzlich auf
// die übergebene Klasse eingeschränkt (der "-alle-"-Bericht liefert sonst auch andere Klassen mit,
// sobald eine Lehrkraft mehrere Klassen unterrichtet).
public Task<IReadOnlyList<UntisForeignClassRegisterEventDto>> GetForeignClassRegisterEventsAsync(
string className, DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var ownUsername = settings.GetApiCredentials()?.Username;
var entries = await client.GetClassRegisterEventsReportAsync(Date(start), Date(end), token);
return (IReadOnlyList<UntisForeignClassRegisterEventDto>)entries
.Where(x => string.Equals(x.ClassName, className, StringComparison.OrdinalIgnoreCase)
// Nicht einfach "!=" gegen ownUsername: wäre ownUsername unbekannt (null), würde
// ein ebenfalls fehlender TeacherUsername sonst fälschlich als "eigen" erkannt
// (Equals(null,null)==true) und rausgefiltert statt als fremd angezeigt zu werden.
&& !(ownUsername is not null
&& string.Equals(x.TeacherUsername, ownUsername, StringComparison.OrdinalIgnoreCase)))
.Select(x => new UntisForeignClassRegisterEventDto(x.ClassName, x.Date, x.Subject, x.StudentName,
x.TeacherUsername, x.CategoryName, x.CategoryGroup, x.Text))
.ToList();
}, token);
// Fehlzeiten der ganzen Klasse über alle Fächer, Ergänzung zu GetForeignClassRegisterEventsAsync
// für das "Klassenlehrer"-Feature. Der Bericht adressiert die Klasse über "KL"+getKlassen-ID statt
// eines Namens, siehe ResolveClassIdAsync.
public Task<IReadOnlyList<UntisClassAbsenceEntryDto>> GetClassAbsencesAsync(string className,
DateOnly start, DateOnly end, CancellationToken token = default) => ExecuteAsync(async client =>
{
var classId = await ResolveClassIdAsync(client, className, token);
var entries = await client.GetClassAbsencesAsync(classId, Date(start), Date(end), token);
return (IReadOnlyList<UntisClassAbsenceEntryDto>)entries.Select(x => new UntisClassAbsenceEntryDto(
x.StudentName, x.ExternKey, x.ClassName, x.Date, x.AbsentPeriods, x.AbsentMinutes,
x.TeacherUsernames, x.Subject, x.AbsenceReason, x.Note, x.EntryId, x.HandledOn, x.Counts,
x.ExcuseNote, x.PeriodNumber, x.Status, x.CountsAsFullDay))
.ToList();
}, token);
// "KL" + getKlassen-ID, dasselbe Element-Kürzel-Schema wie kl/te/su/ro bei anderen Berichten
// (siehe Entities(...) unten). Unbestätigt (kein offizieller Report-Parameter dokumentiert), aber
// aus einem echten Browser-Request rückgeschlossen - schlägt die Zuordnung fehl, liefert der
// Bericht erkennbar leere/falsche Daten statt eines stillen Fehlers.
private static async Task<string> ResolveClassIdAsync(WebUntisClient client, string className,
CancellationToken token)
{
var years = await client.GetSchoolYearsAsync(token);
var today = Date(DateOnly.FromDateTime(DateTime.Today));
var year = years.FirstOrDefault(y => y.StartDate <= today && y.EndDate >= today)
?? years.OrderByDescending(y => y.StartDate).FirstOrDefault()
?? throw new WebUntisIntegrationException("WebUntis hat kein Schuljahr geliefert.");
var classes = await client.GetClassesAsync(year.UntisId, token);
var matches = classes.Where(c => string.Equals(c.Name, className, StringComparison.OrdinalIgnoreCase)).ToList();
return matches.Count switch
{
1 => $"KL{matches[0].UntisId}",
0 => throw new WebUntisIntegrationException($"WebUntis kennt keine Klasse namens \"{className}\"."),
_ => throw new WebUntisIntegrationException(
$"Mehrere WebUntis-Klassen heißen \"{className}\" - bitte manuell prüfen."),
};
}
private async Task<T> ExecuteAsync<T>(Func<WebUntisClient, Task<T>> operation, CancellationToken token)
{
try { return await operation(await GetClientAsync(token)); }
catch (Exception exception) when (IsUntisError(exception)) { throw Translate(exception); }
}
private async Task<WebUntisClient> GetClientAsync(CancellationToken token)
{
if (_client is not null) return _client;
await _clientGate.WaitAsync(token);
try
{
if (_client is not null) return _client;
var credentials = settings.GetApiCredentials()
?? throw new WebUntisIntegrationException("Bitte zuerst die WebUntis-Anmeldedaten einrichten.");
return _client = CreateClient(credentials);
}
finally { _clientGate.Release(); }
}
private WebUntisClient CreateClient(WebUntisCredentials credentials) => new(http, new WebUntisOptions
{
School = credentials.School, Host = credentials.Host, Username = credentials.Username,
Password = credentials.Password, Client = "LehrerApp-Desktop", SessionIdleTimeoutMinutes = 10,
});
private static int Date(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
private static IReadOnlyList<UntisEntityDto> Entities(IReadOnlyList<UntisEntity> values) => values
.Select(x => new UntisEntityDto(x.Id, x.Name, x.OriginalId, x.OriginalName, x.ExternalKey)).ToList();
private static bool IsUntisError(Exception exception) => exception is WebUntisException
or WebUntisConfigurationException or InvalidDataException;
private static WebUntisIntegrationException Translate(Exception exception) =>
new(exception.Message);
public async ValueTask DisposeAsync() => await DisconnectAsync().ConfigureAwait(false);
}
@@ -0,0 +1,155 @@
using LehrerApp.Sync.Crypto;
using System.Text.Json;
namespace LehrerApp.Desktop.Services;
internal class WebUntisSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
public string? EncryptedApiCredentials { get; set; }
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
/// Verschlüsselung über <see cref="SyncCrypto"/> aus LehrerApp.Sync läuft — Core bleibt bewusst
/// frei von Abhängigkeiten außerhalb von .NET selbst (siehe CLAUDE.md).
///
/// Die iCal-URL trägt ein eingebettetes Auth-Token und wird deshalb wie ein Passwort behandelt:
/// nie im Klartext persistiert, nur AES-256-GCM-verschlüsselt (gleicher Mechanismus wie beim
/// KI-Backend-Token) mit einem eigenen, dateirechte-geschützten Schlüssel.
/// </summary>
public class WebUntisSettingsService
{
private readonly string _configPath;
private readonly string _keyPath;
private readonly byte[] _urlKey;
private WebUntisSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public bool ApiIsConfigured => _config.EncryptedApiCredentials is not null;
public int? TeacherUntisId => _config.TeacherUntisId;
public int? HomeroomClassUntisId => _config.HomeroomClassUntisId;
public string? HomeroomClassName => _config.HomeroomClassName;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public WebUntisSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "webuntis-settings.json");
_keyPath = Path.Combine(appDataPath, "webuntis-url.key");
_urlKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey();
_config = Load();
}
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() =>
_config.EncryptedIcalUrl is null ? null : SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetApiCredentials(WebUntisCredentials credentials)
{
_config.EncryptedApiCredentials = SyncCrypto.EncryptObject(credentials, _urlKey);
Save();
}
public WebUntisCredentials? GetApiCredentials() => _config.EncryptedApiCredentials is null
? null
: SyncCrypto.DecryptObject<WebUntisCredentials>(_config.EncryptedApiCredentials, _urlKey);
public void ClearApiCredentials()
{
_config.EncryptedApiCredentials = null;
_config.TeacherUntisId = null;
_config.HomeroomClassUntisId = null;
_config.HomeroomClassName = null;
Save();
}
public void SetTeacherUntisId(int? teacherUntisId)
{
_config.TeacherUntisId = teacherUntisId;
Save();
}
// Die Klasse, deren Klassenlehrer/-in man ist - unabhängig von jeder LearningGroup (siehe
// TODO.md "Klassenlehrer"-Feature): man ist es für die ganze Klasse, nicht für einen einzelnen
// Unterricht/Kurs. HomeroomClassName ist wie TeacherUntisId unverschlüsselt, da kein Geheimnis.
public void SetHomeroomClass(int? untisId, string? name)
{
_config.HomeroomClassUntisId = untisId;
_config.HomeroomClassName = name;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
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();
SyncCrypto.SaveKey(key, _keyPath);
return key;
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
private WebUntisSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<WebUntisSettingsConfig>(File.ReadAllText(_configPath))
?? new WebUntisSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new WebUntisSettingsConfig();
}
}
@@ -0,0 +1,74 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Semantische Pinsel für Flächen und Statusfarben (Klassenlehrer-Bereich, perspektivisch auch
die übrigen Ansichten). Hart codierte Hex-Werte in den Views waren auf das dunkle Theme
zugeschnitten und blieben beim Umschalten auf "Hell" stehen — dunkle Kartenflächen auf hellem
Fenstergrund, Statustexte ohne Kontrast. Über ThemeDictionaries tauscht Avalonia die Werte beim
Theme-Wechsel live aus, sofern die Views sie per DynamicResource (nicht StaticResource) holen.
-->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Dark">
<!-- Flächen -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#11151B"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#2A3038"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#0E1217"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#171D25"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#262C34"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#252B33"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#1C222A"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#4A515C"/>
<!-- Akzent -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#5795F5"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#66A3FF"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#343B45"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#173765"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#3C86F7"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#FFFFFF"/>
<!-- Status: Nutzer-Feedback (August 2026) — Warnung (Amber) und Gefahr (Korallrot) waren
auf dem Bildschirm kaum auseinanderzuhalten. Per scripts/validate_palette.js der
dataviz-Skill gegen AppTrackBackgroundBrush/AppCardBackgroundBrush geprüft (adjacent
pairs, wie sie im gestapelten Balken tatsächlich aneinanderstoßen): die alte Kombi
lag bei ΔE nur 4.610.8 (protan/deutan), jetzt 10.224.4 — deutlich über der
Zielschwelle von 8. Ok/Warnung mussten zusätzlich im Tagesüberblick-Balken die
Reihenfolge tauschen (Info dazwischen), weil Grün und das validierte Dunkel-Gelb sonst
direkt aneinanderstießen (ΔE nur 3.0 unter Protanopie) — siehe
ClassTeacherOverviewView.axaml.
good=#008300, warning(dunkel)=#c98500, warning(hell)=#eda100, critical=#d03b3b
sind dieselben Stufen wie die validierte Kategorial-/Statuspalette der dataviz-Skill;
bewusst nicht wieder frei gewählt, um nicht erneut ins selbe Problem zu laufen. -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#3987E5"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#C98500"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<!-- Flächen: Karten heben sich hier heller vom grauen Fenstergrund ab, nicht dunkler. -->
<SolidColorBrush x:Key="AppCardBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppCardBorderBrush" Color="#DCE1E8"/>
<SolidColorBrush x:Key="AppListRowBackgroundBrush" Color="#FFFFFF"/>
<SolidColorBrush x:Key="AppListRowHoverBrush" Color="#EDF2F9"/>
<SolidColorBrush x:Key="AppListRowBorderBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppTrackBackgroundBrush" Color="#E3E7ED"/>
<SolidColorBrush x:Key="AppChipBackgroundBrush" Color="#EDF0F5"/>
<SolidColorBrush x:Key="AppAvatarBorderBrush" Color="#B7BEC9"/>
<!-- Akzent: dunklere Töne, die Akzentfarben aus dem Dark-Theme wären auf Weiß zu blass. -->
<SolidColorBrush x:Key="AppAccentTextBrush" Color="#1B5FC7"/>
<SolidColorBrush x:Key="AppAccentSoftBackgroundBrush" Color="#DCE9FC"/>
<SolidColorBrush x:Key="AppAccentOnSoftBrush" Color="#14509F"/>
<SolidColorBrush x:Key="AppFilterBorderBrush" Color="#C7CDD6"/>
<SolidColorBrush x:Key="AppFilterActiveBackgroundBrush" Color="#D7E6FC"/>
<SolidColorBrush x:Key="AppFilterActiveBorderBrush" Color="#2C6FD8"/>
<SolidColorBrush x:Key="AppFilterActiveForegroundBrush" Color="#10243F"/>
<!-- Status: siehe Dark-Kommentar oben — dieselbe Validierung, helle Stufen. -->
<SolidColorBrush x:Key="AppStatusOkBrush" Color="#008300"/>
<SolidColorBrush x:Key="AppStatusInfoBrush" Color="#2A78D6"/>
<SolidColorBrush x:Key="AppStatusWarningBrush" Color="#EDA100"/>
<SolidColorBrush x:Key="AppStatusDangerBrush" Color="#D03B3B"/>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
@@ -0,0 +1,252 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Eine Zeile aus dem "-alle-"-Klassenbuchbericht, die eine andere Lehrkraft angelegt hat.
/// Rein zur Ansicht - anders als beim Dokumentations-Abgleich (eigene Einträge) gibt es hier kein
/// "Übernehmen", da es keine eigenen Einträge zum lokalen Synchronisieren sind.</summary>
public sealed record ClassTeacherClassRegisterRow(DateOnly Date, string? Subject, string StudentName,
string? TeacherUsername, string? CategoryName, string? CategoryGroup, string? Text)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName => DisplayName(StudentName);
public ClassTeacherStatusKind CategoryKind =>
CategoryGroup?.Contains("Negativ", StringComparison.OrdinalIgnoreCase) == true
? ClassTeacherStatusKind.Danger : ClassTeacherStatusKind.Info;
public bool IsDangerStatus => CategoryKind == ClassTeacherStatusKind.Danger;
public bool IsInfoStatus => CategoryKind == ClassTeacherStatusKind.Info;
private static string DisplayName(string value)
{
var parts = value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? value : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
/// <summary>Zusammenfassung "wie oft welche Klassenbuch-Kategorie im Zeitraum, und bei wem am
/// häufigsten" — Nutzer-Feedback: die reine Gesamtzahl ("12 Klassenbucheinträge") sagt wenig,
/// eine Aufschlüsselung nach Kategorie ist die eigentlich interessante Information ("Hausaufgaben
/// fehlen: 12×, davon 5× Ben"). Rein clientseitig aus den bereits geladenen <see cref="Entries"/>
/// berechnet, kein zusätzlicher WebUntis-Abruf.</summary>
public sealed record ClassTeacherCategoryAggregateRow(string CategoryName, int Count, string TopStudentsLabel)
{
public string SummaryLabel => string.IsNullOrEmpty(TopStudentsLabel)
? $"{Count}×" : $"{Count}× — {TopStudentsLabel}";
public static IReadOnlyList<ClassTeacherCategoryAggregateRow> Build(
IReadOnlyList<ClassTeacherClassRegisterRow> entries, int topStudentsPerCategory = 2) =>
entries.Where(e => !string.IsNullOrWhiteSpace(e.CategoryName))
.GroupBy(e => e.CategoryName!)
.Select(g =>
{
var topStudents = g.GroupBy(e => e.StudentDisplayName)
.Select(sg => (Name: sg.Key, Count: sg.Count()))
.OrderByDescending(sg => sg.Count).ThenBy(sg => sg.Name)
.Take(topStudentsPerCategory)
.Select(sg => $"{sg.Count}× {sg.Name}");
return new ClassTeacherCategoryAggregateRow(g.Key, g.Count(), string.Join(", ", topStudents));
})
.OrderByDescending(r => r.Count).ThenBy(r => r.CategoryName)
.ToList();
}
/// <summary>Fehlzeiten eines/einer Schüler*in an einem Tag, über alle Fächer zusammengefasst -
/// der WebUntis-Bericht liefert eine Zeile pro Fehlstunde, was bei einer ganzen Klasse über mehrere
/// Wochen zu unübersichtlich wäre (Nutzer-Feedback: kompakte "auf einen Blick"-Übersicht statt
/// Rohdaten). Die Rohzeilen (<see cref="UntisClassAbsenceEntryDto"/>) bleiben trotzdem erreichbar,
/// falls später ein Detail-Drill-down pro Schüler*in dazukommt (siehe TODO.md).</summary>
public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName, int? ExternKey,
int TotalAbsentPeriods, int TotalAbsentMinutes, IReadOnlyList<string> Subjects,
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
string? Note, string? ExcuseNote, bool CountsAsFullDay)
{
public string DateLabel => Date.ToString("dd.MM.yyyy");
public string StudentDisplayName
{
get
{
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length < 2 ? StudentName : string.Join(" ", parts.Skip(1).Append(parts[0]));
}
}
public string SubjectsLabel => string.Join(", ", Subjects);
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons);
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase));
public bool IsUnexcused => Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
IsLate ? ClassTeacherStatusKind.Warning : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public string DetailLabel => string.Join(" · ", new[] { ReasonLabel, Note, ExcuseNote }
.Where(value => !string.IsNullOrWhiteSpace(value)));
public static IReadOnlyList<ClassAbsenceDaySummaryRow> GroupByStudentAndDay(
IEnumerable<UntisClassAbsenceEntryDto> entries) => entries
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.GroupBy(x => (Date: x.Date!.Value, x.Entry.StudentName, x.Entry.ExternKey))
.Select(g => new ClassAbsenceDaySummaryRow(
g.Key.Date, g.Key.StudentName, g.Key.ExternKey,
g.Sum(x => x.Entry.AbsentPeriods), g.Sum(x => x.Entry.AbsentMinutes),
Distinct(g.Select(x => x.Entry.Subject)),
g.Select(x => x.Entry.PeriodNumber).Where(p => p is not null).Select(p => p!.Value).Distinct().ToList(),
Distinct(g.Select(x => x.Entry.Status)),
Distinct(g.Select(x => x.Entry.AbsenceReason)),
g.Select(x => x.Entry.Note).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Select(x => x.Entry.ExcuseNote).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n)),
g.Any(x => x.Entry.CountsAsFullDay)))
.OrderByDescending(r => r.Date).ThenBy(r => r.StudentName)
.ToList();
private static List<string> Distinct(IEnumerable<string?> values) =>
values.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).Distinct().ToList();
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
/// <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
/// eigenen Benutzerkennung wie beim bestehenden Dokumentations-Abgleich), sowie die Fehlzeiten der
/// Klasse über alle Fächer — beide über <see cref="UntisReportCacheService"/> (lokal gecacht statt
/// bei jedem Öffnen neu abgerufen, siehe TODO.md). Beides rein zur Ansicht, kein Übernehmen in
/// lokale Daten. Bewusst unabhängig von jeder <see cref="Core.Models.LearningGroup"/> —
/// Klassenlehrer ist man für die ganze Klasse, nicht für einen einzelnen Unterricht; die Klasse
/// kommt aus <see cref="ClassTeacherOverviewViewModel"/> (letztlich aus den WebUntis-Einstellungen),
/// nicht aus einer Gruppen-ID.
/// </summary>
public partial class ClassTeacherDetailsViewModel : ObservableObject
{
private readonly UntisReportCacheService _cache;
private string _className = "";
public ObservableCollection<ClassTeacherClassRegisterRow> Entries { get; } = [];
public ObservableCollection<ClassAbsenceDaySummaryRow> AbsenceEntries { get; } = [];
public ObservableCollection<ClassTeacherCategoryAggregateRow> CategoryAggregates { get; } = [];
[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;
public bool HasEntries => Entries.Count > 0;
public bool HasAbsenceEntries => AbsenceEntries.Count > 0;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public string ActiveFilterLabel => string.IsNullOrWhiteSpace(StudentFilter)
? "Alle Schüler*innen" : StudentFilter;
public ClassTeacherDetailsViewModel(UntisReportCacheService cache)
{
_cache = cache;
}
public void Initialize(string className)
{
_className = className;
StudentFilter = "";
Entries.Clear();
AbsenceEntries.Clear();
CategoryAggregates.Clear();
Status = "Zeitraum wählen und laden.";
NotifyListState();
}
partial void OnStudentFilterChanged(string value) => OnPropertyChanged(nameof(ActiveFilterLabel));
partial void OnQuickRangeIndexChanged(int value)
{
var days = value switch { 0 => 0, 1 => 6, 2 => 29, _ => 6 };
EndDate = DateTimeOffset.Now;
StartDate = DateTimeOffset.Now.AddDays(-days);
}
[RelayCommand]
private Task Load() => LoadInternal(forceRefresh: false);
/// 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]
private Task Refresh() => LoadInternal(forceRefresh: true);
[RelayCommand]
private Task ApplyFilter() => LoadInternal(forceRefresh: false);
[RelayCommand]
private async Task ClearStudentFilter()
{
StudentFilter = "";
await LoadInternal(forceRefresh: false);
}
private async Task LoadInternal(bool forceRefresh)
{
var start = DateOnly.FromDateTime(StartDate.LocalDateTime);
var end = DateOnly.FromDateTime(EndDate.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();
try
{
var classRegisterTask = _cache.GetClassRegisterEventsAsync(_className, start, end, forceRefresh);
var absencesTask = _cache.GetAbsencesAsync(_className, start, end, forceRefresh);
await Task.WhenAll(classRegisterTask, absencesTask);
var ordered = classRegisterTask.Result
.Where(MatchesStudentFilter)
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
.Where(x => x.Date is not null)
.OrderByDescending(x => x.Date).ThenBy(x => x.Entry.StudentName);
foreach (var (entry, date) in ordered)
{
Entries.Add(new ClassTeacherClassRegisterRow(date!.Value, entry.Subject, entry.StudentName,
entry.TeacherUsername, entry.CategoryName, entry.CategoryGroup, entry.Text));
}
foreach (var row in ClassTeacherCategoryAggregateRow.Build(Entries)) CategoryAggregates.Add(row);
var absences = absencesTask.Result.Where(MatchesStudentFilter);
foreach (var row in ClassAbsenceDaySummaryRow.GroupByStudentAndDay(absences))
AbsenceEntries.Add(row);
Status = $"{Entries.Count} Klassenbucheinträge anderer Lehrkräfte, " +
$"{AbsenceEntries.Count} Fehlzeiten-Tage im Zeitraum.";
NotifyListState();
}
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
finally { Busy = false; NotifyListState(); }
}
// 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.
private bool MatchesStudentFilter(UntisForeignClassRegisterEventDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private bool MatchesStudentFilter(UntisClassAbsenceEntryDto entry) =>
string.IsNullOrWhiteSpace(StudentFilter) || UntisNameMatching.NamesMatch(entry.StudentName, StudentFilter);
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
private void NotifyListState()
{
OnPropertyChanged(nameof(HasEntries));
OnPropertyChanged(nameof(HasAbsenceEntries));
OnPropertyChanged(nameof(HasCategoryAggregates));
}
}
@@ -0,0 +1,792 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>Semantische Statusstufe einer Zeile bzw. eines Hinweises. Bewusst ohne Farbwert im
/// ViewModel: Die Zuordnung zu einem Pinsel passiert über Style-Klassen in XAML
/// (<c>Styles/SemanticBrushes.axaml</c>), damit ein Theme-Wechsel hell/dunkel sie live mitnimmt —
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
{
public ClassAbsenceDaySummaryRow? TodayAbsence { get; init; }
public bool HasClassRegisterToday { get; init; }
public bool IsLate => TodayAbsence is { } a &&
(a.TotalAbsentPeriods == 0 || a.AbsenceReasons.Any(IsLateReason));
public bool IsUnexcused => TodayAbsence?.Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)) == true;
public bool NeedsAttention => HasAbsenceToday || HasRecentClassRegisterEntry;
public int AttentionRank => IsUnexcused ? 0 : IsLate ? 1 : HasAbsenceToday ? 2 :
HasRecentClassRegisterEntry ? 3 : 4;
public string Initials
{
get
{
var parts = StudentName.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return string.Concat(parts.Take(2).Select(p => char.ToUpperInvariant(p[0])));
}
}
public string StatusText
{
get
{
if (TodayAbsence is null)
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
var core = IsLate
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
: TodayAbsence.CountsAsFullDay
? "Ganzer Fehltag"
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
return IsUnexcused ? $"{core} · Unentschuldigt" : core;
}
}
public ClassTeacherStatusKind StatusKind => IsUnexcused ? ClassTeacherStatusKind.Danger :
HasAbsenceToday ? ClassTeacherStatusKind.Warning :
HasRecentClassRegisterEntry ? ClassTeacherStatusKind.Info : ClassTeacherStatusKind.Ok;
public bool IsDangerStatus => StatusKind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => StatusKind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => StatusKind == ClassTeacherStatusKind.Info;
public string ClassRegisterLabel => HasClassRegisterToday ? "Eintrag heute" :
HasRecentClassRegisterEntry ? "Eintrag diese Woche" : "";
/// Quick-Win Barrierefreiheit: der Status wurde bislang ausschließlich über die Farbe des
/// linken Balkens/Texts transportiert. Ein kleines vorangestelltes Symbol macht Warnung/Gefahr
/// auch ohne verlässliche Farbunterscheidung erkennbar (und beim schnellen Scannen der Liste).
public string StatusTextWithGlyph => StatusKind switch
{
ClassTeacherStatusKind.Danger => $"✕ {StatusText}",
ClassTeacherStatusKind.Warning => $"△ {StatusText}",
_ => StatusText,
};
/// Kumulierte Fehlzeiten seit Schuljahresbeginn (Nutzer-Feedback: der Heute-Snapshot allein
/// sagt für Zeugnis/Attestpflicht wenig aus). <see cref="SchoolDaysElapsed"/> zählt Werktage
/// abzüglich der über WebUntis geladenen Ferien (<see cref="ClassTeacherOverviewViewModel.CountSchoolWeekdays"/>).
public int YearAbsenceDayCount { get; init; }
public int YearUnexcusedDayCount { get; init; }
public int SchoolDaysElapsed { get; init; }
/// 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;
public int YearAbsenceRatePercent =>
SchoolDaysElapsed <= 0 ? 0 : (int)Math.Round(100d * YearAbsenceDayCount / SchoolDaysElapsed);
public string YearSummaryLabel => HasYearSummary
? $"{YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn" : "";
public string? YearSummaryTooltip => !HasYearSummary ? null :
$"{YearAbsenceDayCount} von {SchoolDaysElapsed} Schultagen seit {TermStart:dd.MM.} mit Fehlzeit" +
(YearUnexcusedDayCount > 0 ? $" · {YearUnexcusedDayCount} unentschuldigt" : "") +
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
private static bool IsLateReason(string reason) =>
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
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 holidayWeekdaysExcluded = 0,
DateOnly termStart = default)
{
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());
var absenceByName = todayAbsences.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
.ToDictionary(g => g.Key, g => g.First());
var recentByName = 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());
var yearByName = yearRows.GroupBy(a => UntisNameMatching.NameKey(a.StudentName))
.ToDictionary(g => g.Key, g => g.ToList());
return students.Select(student =>
{
var nameKey = UntisNameMatching.NameKey(student.DisplayName);
var absence = (student.ExternKey is { } key ? absenceByKey.GetValueOrDefault(key) : null)
?? absenceByName.GetValueOrDefault(nameKey);
var registerEntries = recentByName.GetValueOrDefault(nameKey) ?? [];
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
?? yearByName.GetValueOrDefault(nameKey) ?? [];
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,
YearUnexcusedDayCount = yearEntries.Count(r => r.IsUnexcused),
SchoolDaysElapsed = schoolDaysElapsed,
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
TermStart = termStart,
};
})
.OrderBy(r => r.AttentionRank).ThenBy(r => r.StudentName).ToList();
}
private static bool TryDate(int value, out DateOnly date) =>
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
}
/// Ein gestapelter Tages-Balken statt drei unabhängig skalierter (Nutzer-Feedback: die vorherige
/// Version zeigte Alerts/Unentschuldigt/Verspätet als drei separate, gleich hohe Balken, obwohl
/// Unentschuldigt und Verspätet Teilmengen von Alerts sind — das suggerierte fälschlich drei
/// unabhängige Größen). Die drei Segmente sind überschneidungsfrei (Unentschuldigt hat Vorrang
/// vor Verspätet, wie bei <see cref="ClassTeacherRosterRow.AttentionRank"/>).
public sealed record ClassTeacherTrendDay(string DayLabel, int AlertCount, int UnexcusedCount,
int LateExcusedCount, int ExcusedCount, double UnexcusedFraction, double LateExcusedFraction,
double ExcusedFraction)
{
/// Anteile 0…1 des höchsten Tages der letzten 7 Schultage statt fester Pixelbreiten
/// (Quick-Win, Nutzer-Feedback: die Balkenbreite hing bisher an einer im ViewModel hart
/// codierten Pixelkonstante, die mit der tatsächlichen Breite der Seitenspalte in XAML
/// synchron gehalten werden musste). Die View multipliziert diese Anteile per MultiBinding
/// (<see cref="LehrerApp.Desktop.Converters.FractionWidthConverter"/>) mit der tatsächlich gerenderten Breite
/// der Track-Leiste — bleibt damit auch dann korrekt, wenn die Seitenspalte mal eine andere
/// Breite bekommt. <c>Grid.ColumnDefinitions</c> ließ sich dafür nicht binden (kein Setter bei
/// kompilierten Bindings, AVLN3000), deshalb Pixelbreite statt Grid-Sternspalten.
public double TotalFraction => UnexcusedFraction + LateExcusedFraction + ExcusedFraction;
public string DetailTooltip =>
$"{UnexcusedCount} unentschuldigt · {LateExcusedCount} verspätet · {ExcusedCount} entschuldigt";
}
public sealed record ClassTeacherPatternNotice(string StudentName, string Message,
ClassTeacherStatusKind Kind, bool CanCreateReminder = false)
{
public bool IsDangerStatus => Kind == ClassTeacherStatusKind.Danger;
public bool IsWarningStatus => Kind == ClassTeacherStatusKind.Warning;
public bool IsInfoStatus => Kind == ClassTeacherStatusKind.Info;
}
/// <summary>Ein Fehltag, der aktuell (Stand letztem Abruf) als unentschuldigt geführt wird —
/// Nutzer-Feedback: die Übersicht zeigte bislang nur den Heute-Snapshot und einen 7-Tage-Trend,
/// aber keine Liste konkreter offener Fälle, die man abarbeiten könnte. Läuft über die ohnehin
/// für die Jahresfehlquote geladenen Daten (<see cref="ClassTeacherOverviewViewModel.Load"/>),
/// kein zusätzlicher Abruf.</summary>
public sealed record ClassTeacherOpenExcuseRow(string StudentName, DateOnly Date, int DaysOpen)
{
/// Verbreitete Regelung in vielen Bundesländern: Entschuldigung "innerhalb von drei Tagen"
/// nach der Fehlzeit (z.B. § 43 Abs. 2 SchulG NRW). Nur ein Richtwert für die optische
/// Hervorhebung hier — WebUntis liefert keine schulspezifische Frist, und die tatsächliche
/// Regelung kann je nach Bundesland/Schulordnung abweichen.
public const int DeadlineDays = 3;
public string DateLabel => Date.ToString("dd.MM.");
public bool IsOverdue => DaysOpen > DeadlineDays;
public string DaysOpenLabel => $"seit {DaysOpen} Tag{(DaysOpen == 1 ? "" : "en")} unentschuldigt";
public static IReadOnlyList<ClassTeacherOpenExcuseRow> Build(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today) =>
// 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();
}
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 IParticipationRepository _participation;
private readonly IParticipationSessionRepository _participationSessions;
private readonly AppLogger? _logger;
public ClassTeacherDetailsViewModel DetailsTab { 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; } = [];
[ObservableProperty] private string? _homeroomClassName;
[ObservableProperty] private int _activeTabIndex;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _busy;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private int _selectedRosterFilter;
[ObservableProperty] private string _primarySectionTitle = "Heute auffällig";
[ObservableProperty] private string _secondarySectionTitle = "Weitere Schüler*innen";
[ObservableProperty] private int _studentCount;
[ObservableProperty] private int _todayAlertCount;
[ObservableProperty] private int _todayUnexcusedCount;
[ObservableProperty] private int _recentClassRegisterCount;
[ObservableProperty] private int _presentCount;
[ObservableProperty] private int _lateCount;
[ObservableProperty] private int _excusedAbsenceCount;
[ObservableProperty] private int _unexcusedAbsenceCount;
[ObservableProperty] private string _lastUpdatedLabel = "Noch nicht aktualisiert";
[ObservableProperty] private int _openExcuseOverflowCount;
public bool HomeroomClassConfigured => !string.IsNullOrWhiteSpace(HomeroomClassName);
public bool HasPrimaryRoster => PrimaryRoster.Count > 0;
public bool HasSecondaryRoster => SecondaryRoster.Count > 0;
public bool HasNoFilterResults => !Busy && PrimaryRoster.Count == 0 && SecondaryRoster.Count == 0;
public bool HasPatternNotices => PatternNotices.Count > 0;
public bool HasOpenExcuses => OpenExcuses.Count > 0;
public bool HasOpenExcuseOverflow => OpenExcuseOverflowCount > 0;
public bool AlertsFilterSelected => SelectedRosterFilter == 0;
public bool ClassRegisterFilterSelected => SelectedRosterFilter == 1;
public bool AllFilterSelected => SelectedRosterFilter == 2;
public int TodayUnexcusedPercent => Percent(TodayUnexcusedCount);
public int LatePercent => Percent(LateCount);
public int PresentPercent => Percent(PresentCount);
public int ExcusedAbsencePercent => Percent(ExcusedAbsenceCount);
public int UnexcusedAbsencePercent => Percent(UnexcusedAbsenceCount);
/// Quick-Win: <see cref="TodayUnexcusedPercent"/> wurde berechnet, aber nirgends gebunden —
/// jetzt als zweite Zeile in der "unentschuldigt"-Kennzahlkarte sichtbar.
public string TodayUnexcusedSummaryLabel => $"unentschuldigt · {TodayUnexcusedPercent} %";
/// Beschriftungen für den kompakten Tagesüberblick (Quick-Win: ein gestapelter 100-%-Balken
/// statt vier Einzelzeilen mit separater Prozentspalte — Zahl und Anteil jetzt in einer
/// Zeile). Die vier Kategorien sind exklusiv und summieren sich exakt zu StudentCount, siehe
/// die Herleitung von Present-/Late-/Excused-/UnexcusedAbsenceCount in <see cref="Load"/>.
public string PresentSummaryLabel => $"{PresentCount} Anwesend · {PresentPercent} %";
public string LateSummaryLabel => $"{LateCount} Verspätet · {LatePercent} %";
public string ExcusedAbsenceSummaryLabel => $"{ExcusedAbsenceCount} Entschuldigt · {ExcusedAbsencePercent} %";
public string UnexcusedAbsenceSummaryLabel => $"{UnexcusedAbsenceCount} Unentschuldigt · {UnexcusedAbsencePercent} %";
/// Anteile 0…1 für den gestapelten Tagesüberblick-Balken (View multipliziert per MultiBinding
/// mit der gerenderten Track-Breite, siehe <see cref="ClassTeacherTrendDay.TotalFraction"/>
/// für dasselbe Muster beim Trend-Chart). Die vier Kategorien sind exklusiv und summieren sich
/// zu 1, da StudentCount == Present + Late + ExcusedAbsence + UnexcusedAbsence.
public double PresentFraction => StudentCount == 0 ? 0 : (double)PresentCount / StudentCount;
public double LateFraction => StudentCount == 0 ? 0 : (double)LateCount / StudentCount;
public double ExcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)ExcusedAbsenceCount / StudentCount;
public double UnexcusedAbsenceFraction => StudentCount == 0 ? 0 : (double)UnexcusedAbsenceCount / StudentCount;
public string DayOverviewTooltip => $"{PresentCount} anwesend · {LateCount} verspätet · " +
$"{ExcusedAbsenceCount} entschuldigt · {UnexcusedAbsenceCount} unentschuldigt";
public Func<Task>? OnNavigateToSettings { get; set; }
public Func<Task>? OnNavigateToWorkload { get; set; }
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
IStudentRepository students, IParticipationRepository participation,
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
AppLogger? logger = null)
{
_settings = settings;
_untis = untis;
_cache = cache;
_schoolYear = schoolYear;
_workTasks = workTasks;
_students = students;
_participation = participation;
_participationSessions = participationSessions;
_logger = logger;
DetailsTab = detailsTab;
}
partial void OnHomeroomClassNameChanged(string? value) => OnPropertyChanged(nameof(HomeroomClassConfigured));
partial void OnOpenExcuseOverflowCountChanged(int value) => OnPropertyChanged(nameof(HasOpenExcuseOverflow));
partial void OnSearchTextChanged(string value) => ApplyRosterFilter();
partial void OnSelectedRosterFilterChanged(int value)
{
OnPropertyChanged(nameof(AlertsFilterSelected));
OnPropertyChanged(nameof(ClassRegisterFilterSelected));
OnPropertyChanged(nameof(AllFilterSelected));
ApplyRosterFilter();
}
[RelayCommand]
private async Task Load()
{
HomeroomClassName = _settings.HomeroomClassName;
ActiveTabIndex = 0;
Roster.Clear(); PrimaryRoster.Clear(); SecondaryRoster.Clear(); TrendDays.Clear(); PatternNotices.Clear();
OpenExcuses.Clear(); OpenExcuseOverflowCount = 0;
if (!HomeroomClassConfigured)
{
Status = "Noch keine Klasse ausgewählt.";
NotifyRosterState();
return;
}
var className = HomeroomClassName!;
DetailsTab.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 studentsTask = _cache.GetStudentRosterAsync(className);
var absencesTask = _cache.GetAbsencesAsync(className, yearStart, today);
var classRegisterTask = _cache.GetClassRegisterEventsAsync(className, sevenDayStart, 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 termStart = EstimateTermStart(absenceDaysYear, yearStart);
var rawWeekdaysElapsed = CountSchoolWeekdays(termStart, today, []);
var schoolDaysElapsed = CountSchoolWeekdays(termStart, today, holidaysTask.Result);
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
classRegisterTask.Result, today, absenceDaysYear, schoolDaysElapsed,
holidayWeekdaysExcluded, termStart)) 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(absenceDaysYear, sevenDayStart);
BuildWeekdayPatternNotices(absenceDaysYear);
BuildAttendanceParticipationNotices();
BuildOpenExcuses(absenceDaysYear, today);
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(); }
}
[RelayCommand] private void ShowAlerts() => SelectedRosterFilter = 0;
[RelayCommand] private void ShowClassRegister() => SelectedRosterFilter = 1;
[RelayCommand] private void ShowAll() => SelectedRosterFilter = 2;
[RelayCommand]
private void OpenClassRegister()
{
DetailsTab.StudentFilter = "";
ActiveTabIndex = 1;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void OpenAbsences()
{
DetailsTab.StudentFilter = "";
ActiveTabIndex = 2;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand]
private void ShowDetailsForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = row.HasAbsenceToday ? 2 : 1;
DetailsTab.LoadCommand.Execute(null);
}
[RelayCommand] private async Task GoToSettings()
{ if (OnNavigateToSettings is not null) await OnNavigateToSettings(); }
[RelayCommand] private async Task GoToWorkload()
{ if (OnNavigateToWorkload is not null) await OnNavigateToWorkload(); }
private void ApplyRosterFilter()
{
PrimaryRoster.Clear(); SecondaryRoster.Clear();
var query = Roster.Where(r => string.IsNullOrWhiteSpace(SearchText) ||
r.StudentName.Contains(SearchText.Trim(), StringComparison.CurrentCultureIgnoreCase));
if (SelectedRosterFilter == 0)
{
PrimarySectionTitle = "Heute auffällig";
foreach (var row in query.Where(r => r.HasAbsenceToday)) PrimaryRoster.Add(row);
}
else if (SelectedRosterFilter == 1)
{
PrimarySectionTitle = "Klassenbucheinträge der letzten 7 Tage";
foreach (var row in query.Where(r => r.HasRecentClassRegisterEntry)) PrimaryRoster.Add(row);
}
else
{
PrimarySectionTitle = "Heute auffällig";
SecondarySectionTitle = "Weitere Schüler*innen";
foreach (var row in query.Where(r => r.NeedsAttention)) PrimaryRoster.Add(row);
foreach (var row in query.Where(r => !r.NeedsAttention)) SecondaryRoster.Add(row);
}
NotifyRosterState();
}
/// Letzte <paramref name="days"/> statt letzte 7 Kalendertage (Nutzer-Feedback: an zwei von
/// sieben Tagen war der Trend bislang systematisch leer, weil Wochenenden mitzählten). Ohne
/// Ferienkalender — siehe <see cref="LastSchoolDays"/>.
private void BuildTrend(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, IReadOnlyList<DateOnly> days)
{
var today = days[^1];
var counts = days.Select(date =>
{
var rows = absenceDays.Where(r => r.Date == date).ToList();
var unexcused = rows.Count(r => r.IsUnexcused);
var lateExcused = rows.Count(r => !r.IsUnexcused && r.IsLate);
var excused = rows.Count(r => !r.IsUnexcused && !r.IsLate);
return (Date: date, Unexcused: unexcused, LateExcused: lateExcused, Excused: excused,
Total: unexcused + lateExcused + excused);
}).ToList();
var max = Math.Max(1, counts.Max(c => c.Total));
foreach (var day in counts)
TrendDays.Add(new ClassTeacherTrendDay(day.Date == today ? "Heute" : day.Date.ToString("ddd"),
day.Total, day.Unexcused, day.LateExcused, day.Excused,
(double)day.Unexcused / max, (double)day.LateExcused / max, (double)day.Excused / max));
}
/// 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.
private static IReadOnlyList<DateOnly> LastSchoolDays(DateOnly end, int count)
{
var days = new List<DateOnly>();
for (var cursor = end; days.Count < count; cursor = cursor.AddDays(-1))
if (cursor.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
days.Add(cursor);
days.Reverse();
return days;
}
/// 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) &&
!holidays.Any(h => d >= h.Start && d <= h.End))
count++;
return count;
}
/// 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()
{
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 = 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));
}
return notices;
}
/// Erweiterte Mustererkennung (Nutzer-Feedback): die obigen Regeln schauen nur auf die letzten
/// 7 Tage und nur auf unentschuldigt/verspätet. Ein Wochentags-Schwerpunkt über einen längeren
/// Zeitraum (z.B. "immer montags") ist ein eigenes, oft erst über mehrere Wochen sichtbares
/// Signal — deshalb mit den seit Schuljahresbeginn geladenen Daten, nicht nur den letzten 7
/// Tagen. Wer schon eine Notiz aus <see cref="BuildPatternNotices"/> hat, wird hier
/// ausgelassen, damit die Liste nicht zwei Hinweise für dieselbe Person zeigt.
private void BuildWeekdayPatternNotices(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays)
{
var displayNames = Roster.GroupBy(r => UntisNameMatching.NameKey(r.StudentName))
.ToDictionary(g => g.Key, g => g.First().StudentName);
var alreadyNoted = PatternNotices.Select(n => n.StudentName).ToHashSet();
foreach (var notice in DetectWeekdayPatterns(absenceDays, displayNames, alreadyNoted))
PatternNotices.Add(notice);
OnPropertyChanged(nameof(HasPatternNotices));
}
/// Reine, ohne ViewModel-Zustand testbare Kernlogik (gleiches Muster wie
/// <see cref="ClassTeacherRosterRow.Build"/>). <paramref name="minSampleSize"/> verhindert,
/// dass zwei zufällig auf denselben Wochentag fallende Fehltage schon als "Muster" gelten;
/// <paramref name="clusterThreshold"/> verlangt eine deutliche Häufung, nicht nur eine leichte
/// Mehrheit.
public static IReadOnlyList<ClassTeacherPatternNotice> DetectWeekdayPatterns(
IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays,
IReadOnlyDictionary<string, string> displayNamesByKey,
ISet<string> excludeDisplayNames,
int minSampleSize = 3, double clusterThreshold = 0.6)
{
var notices = new List<ClassTeacherPatternNotice>();
foreach (var group in absenceDays.GroupBy(row => UntisNameMatching.NameKey(row.StudentName)))
{
var rows = group.ToList();
if (rows.Count < minSampleSize) continue;
var displayName = displayNamesByKey.GetValueOrDefault(group.Key) ?? rows[0].StudentDisplayName;
if (excludeDisplayNames.Contains(displayName)) continue;
var top = rows.GroupBy(r => r.Date.DayOfWeek)
.Select(g => (Day: g.Key, Count: g.Count()))
.OrderByDescending(g => g.Count).First();
if ((double)top.Count / rows.Count < clusterThreshold) continue;
notices.Add(new ClassTeacherPatternNotice(displayName,
$"{top.Count} von {rows.Count} Fehltagen an einem {WeekdayLabel(top.Day)}",
ClassTeacherStatusKind.Info));
}
return notices;
}
/// Nutzer-Feedback: "Hohe Fehlquote bei gleichzeitig fallender Mitarbeit ist ein Signal, das
/// WebUntis allein nicht liefert." Verknüpft die ohnehin geladene Jahresfehlquote
/// (<see cref="ClassTeacherRosterRow.YearAbsenceRatePercent"/>) mit dem Mitarbeit-Trend aus
/// dem Dokumentations-/Mitarbeitsmodul (<see cref="IParticipationRepository"/>, gruppen-
/// übergreifend über <c>GetByStudent</c>, gleiches Muster wie die Fehlzeitenbilanz in
/// StudentDetailViewModel). Bewusst zurückhaltend: nur bei bereits spürbar erhöhter Fehlquote
/// UND eindeutig fallendem Trend (erste vs. zweite Hälfte der letzten Bewertungen, gleiche
/// simple Heuristik wie <c>ParticipationGradeRow.ComputeTrend</c>), sonst würde die Liste bei
/// jeder kleinen Schwankung anschlagen. Reine Beobachtung, keine Kausalitätsaussage — deshalb
/// als neutraler Hinweis (Info), nicht als Warnung/Gefahr. Die WebUntis-Rohbewertungen werden
/// hier bewusst NICHT nach Aspekt-Gewichtung normiert (anders als in ParticipationGradeRow):
/// die Aspekt-Definitionen sind je Lerngruppe konfigurierbar, der Klassenlehrer-Bereich kennt
/// aber keine einzelne Gruppe — ein einfacher Rohwert-Durchschnitt über alle Bewertungen ist
/// hier die pragmatischere Näherung als ein Konfigurations-Mismatch zu riskieren.
private const int CorrelationAbsenceRateThreshold = 15;
private void BuildAttendanceParticipationNotices()
{
var students = _students.GetAll();
foreach (var row in Roster.Where(r => r.HasYearSummary &&
r.YearAbsenceRatePercent >= CorrelationAbsenceRateThreshold))
{
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
var student = MatchStudent(row.StudentName, students);
if (student is null) continue;
var points = _participation.GetByStudent(student.Id)
.Select(e => (Session: _participationSessions.GetById(e.SessionId), Entry: e))
.Where(x => x.Session is not null && x.Entry.Ratings.Count > 0)
.OrderBy(x => x.Session!.Date)
.TakeLast(8)
.Select(x => x.Entry.Ratings.Average(r => r.Value))
.ToList();
if (points.Count < 4) continue;
var mid = points.Count / 2;
var diff = points.Skip(mid).Average() - points.Take(mid).Average();
if (diff <= -0.4)
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
$"{row.YearAbsenceRatePercent} % Fehlzeit seit Schuljahresbeginn, dabei zuletzt sinkende Mitarbeit",
ClassTeacherStatusKind.Info));
}
OnPropertyChanged(nameof(HasPatternNotices));
}
/// 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
/// ("Müller," bliebe ein eigenes Wort statt mit "Müller" aus dem WebUntis-Namen zu matchen).
public static Student? MatchStudent(string webUntisDisplayName, IReadOnlyList<Student> students)
{
var key = UntisNameMatching.NameKey(webUntisDisplayName);
return students.FirstOrDefault(s => UntisNameMatching.NameKey($"{s.FirstName} {s.LastName}") == key);
}
private static string WeekdayLabel(DayOfWeek day) => day switch
{
DayOfWeek.Monday => "Montag",
DayOfWeek.Tuesday => "Dienstag",
DayOfWeek.Wednesday => "Mittwoch",
DayOfWeek.Thursday => "Donnerstag",
DayOfWeek.Friday => "Freitag",
DayOfWeek.Saturday => "Samstag",
_ => "Sonntag",
};
/// Zeigt höchstens <see cref="OpenExcuseDisplayLimit"/> Zeilen — Nutzer-Feedback zu den
/// anderen Seitenpanels: eine lange Liste soll nicht die ganze Spalte einnehmen. Der Rest
/// zählt in <see cref="OpenExcuseOverflowCount"/> und wird als "+N weitere" angezeigt.
private const int OpenExcuseDisplayLimit = 6;
private void BuildOpenExcuses(IReadOnlyList<ClassAbsenceDaySummaryRow> absenceDays, DateOnly today)
{
var all = ClassTeacherOpenExcuseRow.Build(absenceDays, today);
foreach (var row in all.Take(OpenExcuseDisplayLimit)) OpenExcuses.Add(row);
OpenExcuseOverflowCount = Math.Max(0, all.Count - OpenExcuseDisplayLimit);
}
[RelayCommand]
private void ShowDetailsForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
DetailsTab.StudentFilter = row.StudentName;
ActiveTabIndex = 2;
DetailsTab.LoadCommand.Execute(null);
}
/// Nutzer-Feedback: "Aufgaben & Wiedervorlagen öffnen" führt nur ins Modul, das eigentliche
/// Erstellen ("Eltern anrufen Ada Müller") musste man dort noch einmal von Hand eintippen.
/// Legt direkt eine Wiedervorlage an (Kind=Reminder, wie 6.1.1) statt einen Dialog zu öffnen —
/// der bearbeitet sie bei Bedarf im Aufgaben-Modul weiter, wo `OnEditTask` (siehe
/// WorkTaskListView) ohnehin nur dort verdrahtet ist. Bewusst ohne GroupId: Klassenlehrer ist
/// man für die ganze Klasse, nicht für einen einzelnen Kurs (siehe Klassendoku oben).
[RelayCommand]
private void CreateReminderForStudent(ClassTeacherRosterRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, row.StatusText, urgent: row.IsUnexcused);
}
[RelayCommand]
private void CreateReminderForOpenExcuse(ClassTeacherOpenExcuseRow? row)
{
if (row is null) return;
SaveReminder(row.StudentName, $"{row.DaysOpenLabel} (Fehltag vom {row.DateLabel}).",
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
{
Title = $"Eltern kontaktieren {studentName}",
Notes = notes,
Kind = TaskKind.Reminder,
Priority = urgent ? TaskPriority.High : TaskPriority.Normal,
DueDate = DateOnly.FromDateTime(DateTime.Today).AddDays(1),
};
_workTasks.Save(task);
Status = $"Wiedervorlage „{task.Title}\" angelegt zu finden unter Aufgaben & Wiedervorlagen.";
}
private int Percent(int value) => StudentCount == 0 ? 0 : (int)Math.Round(100d * value / StudentCount);
private void NotifySummary()
{
OnPropertyChanged(nameof(TodayUnexcusedPercent)); OnPropertyChanged(nameof(LatePercent));
OnPropertyChanged(nameof(PresentPercent)); OnPropertyChanged(nameof(ExcusedAbsencePercent));
OnPropertyChanged(nameof(UnexcusedAbsencePercent));
OnPropertyChanged(nameof(TodayUnexcusedSummaryLabel));
OnPropertyChanged(nameof(PresentSummaryLabel)); OnPropertyChanged(nameof(LateSummaryLabel));
OnPropertyChanged(nameof(ExcusedAbsenceSummaryLabel)); OnPropertyChanged(nameof(UnexcusedAbsenceSummaryLabel));
OnPropertyChanged(nameof(PresentFraction)); OnPropertyChanged(nameof(LateFraction));
OnPropertyChanged(nameof(ExcusedAbsenceFraction)); OnPropertyChanged(nameof(UnexcusedAbsenceFraction));
OnPropertyChanged(nameof(DayOverviewTooltip));
}
private void NotifyRosterState()
{
OnPropertyChanged(nameof(HasPrimaryRoster)); OnPropertyChanged(nameof(HasSecondaryRoster));
OnPropertyChanged(nameof(HasNoFilterResults)); OnPropertyChanged(nameof(HasOpenExcuses));
}
}
@@ -0,0 +1,22 @@
namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
/// <summary>
/// WebUntis liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge — der
/// Schülerreport baut "Vorname Nachname" (<see cref="Services.UntisStudentDto.DisplayName"/>),
/// die Fehlzeiten-/Klassenbuchberichte liefern typischerweise "Nachname Vorname". Ein exakter
/// String-Vergleich zwischen diesen Quellen schlägt deshalb praktisch immer fehl (Bug: gefilterte
/// Listen im Klassenlehrer-Bereich blieben leer, Roster-Symbole waren durchgängig falsch). Der
/// Vergleich hier ist deshalb reihenfolge-unabhängig: beide Namen werden in Wörter zerlegt und als
/// sortierte Menge verglichen (kein Abgleich über eine externe Schülerkennung möglich, da der
/// Klassenbuch-Bericht keine liefert, siehe UntisForeignClassRegisterEventDto).
/// </summary>
public static class UntisNameMatching
{
public static bool NamesMatch(string? a, string? b) =>
!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b);
public static string NameKey(string value) => string.Join(' ',
value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Trim().ToLowerInvariant())
.OrderBy(w => w, StringComparer.Ordinal));
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -34,6 +35,9 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays; private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings; private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions; private readonly ISubstitutionEntryRepository _substitutions;
private readonly ITimeEntryRepository _timeEntries;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -47,13 +51,35 @@ public partial class DashboardViewModel : ObservableObject
/// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine /// hat rein rechnerisch schon 100 %, das ist noch kein auffälliges Muster, nur eine zu kleine
/// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize. /// Stichprobe. Dieselbe Konstante wie GroupOverviewViewModel.AttendanceMinSampleSize.
private const int AttendanceMinSampleSize = 8; 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 _greeting = "";
[ObservableProperty] private string _currentDate = ""; [ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = ""; [ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today); [ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = ""; [ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private bool _isDashboardSettingsOpen; [ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = "";
[ObservableProperty] private string _weatherDetails = "";
[ObservableProperty] private string _weatherStatus = "";
[ObservableProperty] private bool _hasWeatherWarnings;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De); public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
@@ -63,6 +89,8 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<CalendarDayCell> CalendarDays { get; } = []; public ObservableCollection<CalendarDayCell> CalendarDays { get; } = [];
public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = []; public ObservableCollection<OpenExcuseItem> OpenExcuses { get; } = [];
public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = []; public ObservableCollection<AttendanceWarningItem> AttendanceWarnings { get; } = [];
public ObservableCollection<ExamWeekLoadItem> ExamWeekLoads { get; } = [];
public ObservableCollection<MissingTeachingTimeItem> MissingTeachingTimeEntries { get; } = [];
public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = []; public ObservableCollection<SupportPlanDueItem> SupportPlanReviews { get; } = [];
public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = []; public ObservableCollection<UpcomingDateItem> UpcomingDates { get; } = [];
public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = []; public ObservableCollection<CorrectionProgressItem> OpenCorrections { get; } = [];
@@ -70,6 +98,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<DashboardAlertItem> Alerts { get; } = []; public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = []; public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = []; public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet // Navigation-Callback wird von App.axaml.cs verdrahtet
@@ -86,6 +115,10 @@ public partial class DashboardViewModel : ObservableObject
// Sprungziel für eine ungeplante Stunde — führt direkt in den Verlaufsplan-Tab der Gruppe // 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. // (nicht den Standard-Tab von OnNavigateToGroup), damit das Thema gleich ergänzt werden kann.
public Action<Guid>? OnNavigateToUnplannedLesson { get; set; } 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 TodayCard => Card("today");
public DashboardCardOption TasksCard => Card("tasks"); public DashboardCardOption TasksCard => Card("tasks");
@@ -96,8 +129,19 @@ public partial class DashboardViewModel : ObservableObject
public DashboardCardOption UnplannedCard => Card("unplanned"); public DashboardCardOption UnplannedCard => Card("unplanned");
public DashboardCardOption AlertsCard => Card("alerts"); public DashboardCardOption AlertsCard => Card("alerts");
public DashboardCardOption AttendanceCard => Card("attendance"); public DashboardCardOption AttendanceCard => Card("attendance");
public DashboardCardOption ExamLoadCard => Card("examload");
public DashboardCardOption MissingTeachingTimeCard => Card("missingteachingtime");
public DashboardCardOption SupportCard => Card("support"); public DashboardCardOption SupportCard => Card("support");
public DashboardCardOption GroupsCard => Card("groups"); public DashboardCardOption GroupsCard => Card("groups");
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 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";
public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons, public DashboardViewModel(IGroupRepository groups, ISubjectRepository subjects, ILessonRepository lessons,
IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades, IExamRepository exams, IExamResultRepository examResults, IGradeRepository grades,
@@ -108,7 +152,9 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions) ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -117,7 +163,15 @@ public partial class DashboardViewModel : ObservableObject
_timetableSlots = timetableSlots; _periodSchedule = periodSchedule; _timetableSlots = timetableSlots; _periodSchedule = periodSchedule;
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_timeEntries = timeEntries;
_substitutions = substitutions; _substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
}
LoadDashboardCards(); LoadDashboardCards();
Load(); Load();
} }
@@ -133,6 +187,7 @@ public partial class DashboardViewModel : ObservableObject
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear(); CurrentSchoolYear = _sy.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend"; Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
_ = LoadWeatherAsync();
TodaysLessons.Clear(); TodaysLessons.Clear();
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id); var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
@@ -169,7 +224,7 @@ public partial class DashboardViewModel : ObservableObject
IsHighPriority = t.Priority == TaskPriority.High }); IsHighPriority = t.Priority == TaskPriority.High });
CurrentGroups.Clear(); CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name)) foreach (var g in groups.Values)
CurrentGroups.Add(new() CurrentGroups.Add(new()
{ {
GroupId = g.Id, GroupId = g.Id,
@@ -181,13 +236,93 @@ public partial class DashboardViewModel : ObservableObject
LoadCalendar(); LoadCalendar();
LoadOpenExcuses(groups.Values.ToList(), today); LoadOpenExcuses(groups.Values.ToList(), today);
LoadAttendanceWarnings(today); LoadAttendanceWarnings(today);
LoadExamWeekLoads(groups, today);
LoadMissingTeachingTime(today);
LoadSupportPlanReviews(today); LoadSupportPlanReviews(today);
LoadUpcomingDates(groups, today); LoadUpcomingDates(groups, today);
LoadOpenCorrections(groups, today); LoadOpenCorrections(groups, today);
LoadUnplannedLessons(groups, today); LoadUnplannedLessons(groups, today);
LoadAlerts(groups, today); LoadAlerts(groups, today);
UpdateDashboardSummary();
} }
private void UpdateDashboardSummary()
{
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;
ExamLoadCard.IsEmpty = ExamWeekLoads.Count == 0;
MissingTeachingTimeCard.IsEmpty = MissingTeachingTimeEntries.Count == 0;
SupportCard.IsEmpty = SupportPlanReviews.Count == 0;
GroupsCard.IsEmpty = CurrentGroups.Count == 0;
OnPropertyChanged(nameof(TodayLessonCount));
OnPropertyChanged(nameof(OpenTaskCount));
OnPropertyChanged(nameof(UpcomingCount));
OnPropertyChanged(nameof(AttentionCount));
OnPropertyChanged(nameof(TodayLessonSummary));
OnPropertyChanged(nameof(OpenTaskSummary));
OnPropertyChanged(nameof(AttentionSummary));
OnPropertyChanged(nameof(UpcomingSummary));
}
private async Task LoadWeatherAsync()
{
if (_schoolWeather?.IsAvailable != true) return;
try
{
var snapshot = await _schoolWeather.GetWeatherAsync();
if (snapshot is null) return;
var current = snapshot.Forecast
.Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1))
.MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes));
WeatherWarnings.Clear();
foreach (var warning in snapshot.Warnings)
WeatherWarnings.Add(new DashboardWeatherWarningItem(warning));
HasWeatherWarnings = WeatherWarnings.Count > 0;
if (current is not null)
{
var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt";
WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}";
var details = new List<string>();
if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h");
if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h");
if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm");
details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)");
WeatherDetails = string.Join(" · ", details);
}
WeatherStatus = snapshot.IsStale
? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr"
: "";
IsWeatherPanelVisible = current is not null || HasWeatherWarnings;
}
catch (SchoolWeatherException ex)
{
WeatherStatus = ex.Message;
IsWeatherPanelVisible = true;
}
}
private static string WeatherDescription(int? code) => code switch
{
0 => "klar",
>= 1 and <= 3 => "bewölkt",
45 or 48 => "Nebel",
>= 51 and <= 67 => "Regen",
>= 71 and <= 77 => "Schnee",
>= 80 and <= 82 => "Regenschauer",
85 or 86 => "Schneeschauer",
>= 95 and <= 99 => "Gewitter",
_ => "Wettervorhersage",
};
// ── Fehlzeiten-Warnung (5.2.3) ──────────────────────────────────────────── // ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
private void LoadAttendanceWarnings(DateOnly today) private void LoadAttendanceWarnings(DateOnly today)
@@ -197,12 +332,16 @@ public partial class DashboardViewModel : ObservableObject
var from = _sy.SchoolYearStart(schoolYear); var from = _sy.SchoolYearStart(schoolYear);
var to = _sy.SchoolYearEnd(schoolYear); var to = _sy.SchoolYearEnd(schoolYear);
// Ein Bulk-Laden aller Sitzungen vermeidet, für jeden Mitarbeits-Eintrag jedes Schülers
// einzeln GetById aufzurufen (N+1 bei vielen Schülern/Einträgen).
var sessionDates = _participationSessions.GetAll().ToDictionary(s => s.Id, s => s.Date);
var items = new List<AttendanceWarningItem>(); var items = new List<AttendanceWarningItem>();
foreach (var student in _students.GetAll()) foreach (var student in _students.GetAll())
{ {
var entries = _participationEntries.GetByStudent(student.Id) var entries = _participationEntries.GetByStudent(student.Id)
.Select(e => _participationSessions.GetById(e.SessionId) is { } session .Select(e => sessionDates.TryGetValue(e.SessionId, out var date)
? ((DateOnly?)session.Date, e.Attendance) : (null, e.Attendance)) ? ((DateOnly?)date, e.Attendance) : (null, e.Attendance))
.Where(t => t.Item1.HasValue) .Where(t => t.Item1.HasValue)
.Select(t => (t.Item1!.Value, t.Attendance)); .Select(t => (t.Item1!.Value, t.Attendance));
@@ -214,6 +353,75 @@ public partial class DashboardViewModel : ObservableObject
AttendanceWarnings.Add(item); AttendanceWarnings.Add(item);
} }
// ── 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)
{
MissingTeachingTimeEntries.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(DateTime.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))
MissingTeachingTimeEntries.Add(item);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────
private void LoadSupportPlanReviews(DateOnly today) private void LoadSupportPlanReviews(DateOnly today)
@@ -283,12 +491,10 @@ public partial class DashboardViewModel : ObservableObject
.Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded) .Where(e => e.Status is ExamStatus.Conducted or ExamStatus.Graded)
.OrderBy(e => e.Date)) .OrderBy(e => e.Date))
{ {
var expected = _memberships.GetByGroup(group.Id) var (expected, evaluated) = ExamCorrectionCounter.Count(exam,
.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date)); _memberships.GetByGroup(group.Id), _examResults.GetByExam(exam.Id));
var evaluated = _examResults.GetByExam(exam.Id)
.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title, OpenCorrections.Add(new CorrectionProgressItem(exam.Id, group.Id, exam.Title,
group.Name, exam.Date, Math.Min(evaluated, expected), expected, today)); group.Name, exam.Date, evaluated, expected, today));
} }
} }
@@ -441,7 +647,8 @@ public partial class DashboardViewModel : ObservableObject
"excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine", "excuses" => "Offene Entschuldigungen", "upcoming" => "Anstehende Termine",
"corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten", "corrections" => "Offene Korrekturen", "unplanned" => "Ungeplante Stunden", "alerts" => "Auffälligkeiten",
"attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage", "attendance" => "Fehlzeiten-Warnung", "support" => "Förderplan-Wiedervorlage",
"groups" => "Meine Lerngruppen", _ => key, "groups" => "Meine Lerngruppen", "examload" => "Klausurwochen",
"missingteachingtime" => "Unterrichtszeit nacherfassen", _ => key,
}; };
private void ApplyCardLayout() private void ApplyCardLayout()
@@ -491,6 +698,15 @@ public partial class DashboardViewModel : ObservableObject
[RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item) [RelayCommand] private void OpenStudentSupportPlan(SupportPlanDueItem? item)
{ if (item is not null) OnNavigateToStudent?.Invoke(item.StudentId); } { 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));
UpdateDashboardSummary();
}
private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today) private void LoadOpenExcuses(List<LearningGroup> groups, DateOnly today)
{ {
OpenExcuses.Clear(); OpenExcuses.Clear();
@@ -523,6 +739,7 @@ public partial class DashboardViewModel : ObservableObject
entry.Attendance = status; entry.Attendance = status;
_participationEntries.Save(entry); _participationEntries.Save(entry);
OpenExcuses.Remove(item); OpenExcuses.Remove(item);
UpdateDashboardSummary();
} }
private void LoadCalendar() private void LoadCalendar()
@@ -580,6 +797,27 @@ public partial class DashboardViewModel : ObservableObject
} }
} }
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
// betroffenen Kalendertag.
if (_annualPlanEvents is not null)
{
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
{
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
{
var agg = Agg(date);
agg.HasAnnualPlanEvent = true;
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
annualEvent.Description));
}
}
}
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung // "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den // existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht. // aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
@@ -615,7 +853,7 @@ public partial class DashboardViewModel : ObservableObject
byDay.TryGetValue(date, out var agg); byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today, CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false, agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
} }
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth)); ?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -626,17 +864,44 @@ public partial class DashboardViewModel : ObservableObject
{ {
if (day is null) return; if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day; foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedCalendarDate = day.Date;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De); SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear(); SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item); foreach (var item in day.Events) SelectedDayEvents.Add(item);
SortCurrentGroups(day.Date);
}
private void SortCurrentGroups(DateOnly date)
{
var schoolHolidays = _schoolHolidays.GetAll();
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
.Select(h => h.Date).ToHashSet();
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
foreach (var chip in CurrentGroups)
{
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
}
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
CurrentGroups.Clear();
foreach (var chip in sorted) CurrentGroups.Add(chip);
} }
[RelayCommand] [RelayCommand]
private void OpenCalendarEvent(CalendarEventItem? item) private void OpenCalendarEvent(CalendarEventItem? item)
{ {
if (item is null) return; if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId); if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit" if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
} }
[RelayCommand] [RelayCommand]
@@ -699,9 +964,61 @@ public partial class DashboardViewModel : ObservableObject
public bool HasLesson; public bool HasLesson;
public bool HasExam; public bool HasExam;
public bool HasSession; public bool HasSession;
public bool HasAnnualPlanEvent;
public bool IsOwnClassDay; public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = []; public List<CalendarEventItem> Details { get; } = [];
} }
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
{
string time;
if (entry.IsAllDay)
{
time = entry.StartDate == entry.EndDate
? "Ganztägig"
: $"{entry.StartDate:dd.MM.}{entry.EndDate:dd.MM.yyyy} · ganztägig";
}
else if (entry.StartDate == entry.EndDate)
{
time = entry.EndTime is { } end
? $"{entry.StartTime:HH\\:mm}{end:HH\\:mm}"
: $"{entry.StartTime:HH\\:mm}";
}
else
{
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}" +
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
}
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
.Where(part => !string.IsNullOrWhiteSpace(part)));
}
}
public sealed class DashboardWeatherWarningItem
{
public string Headline { get; }
public string Description { get; }
public string Instruction { get; }
public string PeriodDisplay { get; }
public string SeverityColor { get; }
public DashboardWeatherWarningItem(WeatherWarning warning)
{
Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
Description = warning.Description;
Instruction = warning.Instruction;
PeriodDisplay = (warning.Onset, warning.Expires) switch
{
({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}{expires.ToLocalTime():HH:mm} Uhr",
({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr",
_ => "",
};
SeverityColor = warning.Severity switch
{
"Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835",
};
}
} }
public class LessonItem public class LessonItem
@@ -715,7 +1032,13 @@ public class LessonItem
public bool HasRoom => !string.IsNullOrWhiteSpace(Room); public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
} }
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } } public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; } public class GroupChip
{
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Subject { get; set; } = "";
public bool IsOnSelectedDay { get; set; }
}
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ──────────────────────── // ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
@@ -753,6 +1076,23 @@ public class AttendanceWarningItem(Guid studentId, string studentName, double ab
public double AbsenceRatePercent { get; } = absenceRatePercent; public double AbsenceRatePercent { get; } = absenceRatePercent;
} }
public class ExamWeekLoadItem(int isoWeek, DateOnly weekStart, DateOnly weekEnd, int examCount)
{
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 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);
}
// ── Förderplan-Wiedervorlage (5.3.2) ────────────────────────────────────────── // ── Förderplan-Wiedervorlage (5.3.2) ──────────────────────────────────────────
public class SupportPlanDueItem public class SupportPlanDueItem
@@ -783,12 +1123,14 @@ public partial class CalendarDayCell : ObservableObject
public bool HasLesson { get; } public bool HasLesson { get; }
public bool HasExam { get; } public bool HasExam { get; }
public bool HasSession { get; } public bool HasSession { get; }
public bool HasAnnualPlanEvent { get; }
public bool IsOwnClassDay { get; } public bool IsOwnClassDay { get; }
public string Tooltip { get; } public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; } public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details) bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
bool isOwnClassDay, List<CalendarEventItem> details)
{ {
Date = date; Date = date;
DayNumber = date.Day; DayNumber = date.Day;
@@ -797,6 +1139,7 @@ public partial class CalendarDayCell : ObservableObject
HasLesson = hasLesson; HasLesson = hasLesson;
HasExam = hasExam; HasExam = hasExam;
HasSession = hasSession; HasSession = hasSession;
HasAnnualPlanEvent = hasAnnualPlanEvent;
IsOwnClassDay = isOwnClassDay; IsOwnClassDay = isOwnClassDay;
Events = details; Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
@@ -804,20 +1147,22 @@ public partial class CalendarDayCell : ObservableObject
} }
} }
public enum CalendarEventKind { Lesson, Exam, ParticipationSession } public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId) string subtitle, Guid? groupId, string description = "")
{ {
public CalendarEventKind Kind { get; } = kind; public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date; public DateOnly Date { get; } = date;
public string Title { get; } = title; public string Title { get; } = title;
public string Subtitle { get; } = subtitle; public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId; public Guid? GroupId { get; } = groupId;
public string Description { get; } = description;
public string KindLabel => Kind switch public string KindLabel => Kind switch
{ {
CalendarEventKind.Exam => "Klausur", CalendarEventKind.Exam => "Klausur",
CalendarEventKind.ParticipationSession => "Sitzung", CalendarEventKind.ParticipationSession => "Sitzung",
CalendarEventKind.AnnualPlan => "Jahresplan",
_ => "Unterricht" _ => "Unterricht"
}; };
} }
@@ -886,18 +1231,29 @@ public sealed class DashboardAlertItem(Guid studentId, Guid? groupId, string stu
public partial class DashboardCardOption : ObservableObject public partial class DashboardCardOption : ObservableObject
{ {
[ObservableProperty] private bool _isVisible; [ObservableProperty] private bool _isVisible;
[ObservableProperty] private bool _isEmpty;
[ObservableProperty] private int _row; [ObservableProperty] private int _row;
[ObservableProperty] private int _column; [ObservableProperty] private int _column;
public string Key { get; } public string Key { get; }
public string Title { get; } public string Title { get; }
public bool HideWhenEmpty { get; }
public bool EffectiveIsVisible => IsVisible && (!HideWhenEmpty || !IsEmpty);
public Action? OnVisibilityChanged { get; set; } public Action? OnVisibilityChanged { get; set; }
public DashboardCardOption(string key, string title, bool isVisible) public DashboardCardOption(string key, string title, bool isVisible)
{ {
Key = key; Key = key;
Title = title; Title = title;
HideWhenEmpty = key is "excuses" or "upcoming" or "corrections" or "unplanned"
or "alerts" or "attendance" or "support";
_isVisible = isVisible; _isVisible = isVisible;
} }
partial void OnIsVisibleChanged(bool value) => OnVisibilityChanged?.Invoke(); partial void OnIsVisibleChanged(bool value)
{
OnPropertyChanged(nameof(EffectiveIsVisible));
OnVisibilityChanged?.Invoke();
}
partial void OnIsEmptyChanged(bool value) => OnPropertyChanged(nameof(EffectiveIsVisible));
} }
@@ -0,0 +1,259 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Exams;
/// Klausuren-Hauptseite (Sidebar "Klausuren"): gruppenübergreifende Liste aller Klausuren des
/// aktuellen Schuljahres, sortiert nach einem unsichtbaren Prioritäts-Score
/// (`ExamPriorityService`) statt nach Datum — unbearbeitete/überfällige Korrekturen oben,
/// erledigte unten. Oben ein Detailbereich zur ausgewählten Klausur, der sich je nach Status
/// unterscheidet (Korrekturfortschritt vs. Notenspiegel), inklusive Umschalter für Parallelkurse
/// (gleiche Arbeit, mehrere Kurse — siehe `Exam.SharedExamGroupId`).
public partial class ExamsOverviewViewModel : ObservableObject
{
private readonly IExamRepository _exams;
private readonly IExamResultRepository _examResults;
private readonly IGroupRepository _groups;
private readonly IGroupMembershipRepository _memberships;
private readonly GradingService _grading;
private readonly SchoolYearService _schoolYear;
public ObservableCollection<ExamListRowViewModel> Rows { get; } = [];
public ObservableCollection<ExamListRowViewModel> Siblings { get; } = [];
public ObservableCollection<GradeBarItem> DetailGradeDistribution { get; } = [];
[ObservableProperty] private ExamListRowViewModel? _selectedRow;
[ObservableProperty] private bool _hasSelection;
[ObservableProperty] private string _emptyHint = "";
[ObservableProperty] private bool _showCorrectionProgress;
[ObservableProperty] private string _progressLabel = "";
[ObservableProperty] private double _progressBarWidth;
[ObservableProperty] private bool _showGradeSummary;
[ObservableProperty] private string _averageLabel = "";
[ObservableProperty] private string _approvalLabel = "";
[ObservableProperty] private string _announcementLabel = "";
public Func<Exam, Task>? OnGradeExam { get; set; }
public Func<Exam, Task>? OnEvaluateExam { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Action? OnNavigateToGroups { get; set; }
public bool HasNoExams => Rows.Count == 0;
public ExamsOverviewViewModel(IExamRepository exams, IExamResultRepository examResults,
IGroupRepository groups, IGroupMembershipRepository memberships, GradingService grading,
SchoolYearService schoolYear)
{
_exams = exams; _examResults = examResults; _groups = groups;
_memberships = memberships; _grading = grading; _schoolYear = schoolYear;
}
[RelayCommand]
public void Load()
{
var selectedId = SelectedRow?.Exam.Id;
Rows.Clear();
var groups = _groups.GetBySchoolYear(_schoolYear.CurrentSchoolYear());
var today = DateOnly.FromDateTime(DateTime.Today);
var rows = new List<ExamListRowViewModel>();
foreach (var group in groups)
{
var groupMemberships = _memberships.GetByGroup(group.Id);
foreach (var exam in _exams.GetByGroup(group.Id))
{
var (expected, evaluated) = ExamCorrectionCounter.Count(exam, groupMemberships,
_examResults.GetByExam(exam.Id));
var info = ExamPriorityService.Evaluate(exam, expected, evaluated, today);
rows.Add(new ExamListRowViewModel(exam, group, expected, evaluated, info, today));
}
}
// Parallelkurse markieren, damit die Liste einen Verknüpfungs-Hinweis zeigen kann.
foreach (var group in rows.GroupBy(r => r.Exam.SharedExamGroupId ?? r.Exam.Id))
{
if (group.Count() <= 1) continue;
foreach (var row in group) row.HasSibling = true;
}
foreach (var row in rows.OrderByDescending(r => r.Score))
Rows.Add(row);
EmptyHint = Rows.Count == 0 ? "Keine Klausuren angelegt." : "";
OnPropertyChanged(nameof(HasNoExams));
SelectedRow = selectedId is { } id
? Rows.FirstOrDefault(r => r.Exam.Id == id) ?? Rows.FirstOrDefault()
: Rows.FirstOrDefault();
}
partial void OnSelectedRowChanged(ExamListRowViewModel? value) => UpdateDetail(value);
private void UpdateDetail(ExamListRowViewModel? row)
{
HasSelection = row is not null;
Siblings.Clear();
DetailGradeDistribution.Clear();
ShowCorrectionProgress = false;
ShowGradeSummary = false;
if (row is null) return;
ApprovalLabel = row.Exam.ApprovalGrantedAt is { } a
? $"Genehmigt am {a.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
: "Genehmigung noch ausstehend";
AnnouncementLabel = row.Exam.AnnouncedAt is { } n
? $"Angekündigt am {n.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)}"
: "Noch nicht angekündigt";
if (row.HasSibling)
{
var anchor = row.Exam.SharedExamGroupId ?? row.Exam.Id;
foreach (var sibling in Rows.Where(r => r != row && (r.Exam.SharedExamGroupId ?? r.Exam.Id) == anchor))
Siblings.Add(sibling);
}
ShowCorrectionProgress = row.Status is ExamListStatus.AwaitingCorrection
or ExamListStatus.CorrectionInProgress or ExamListStatus.CorrectionStuck;
if (ShowCorrectionProgress)
{
ProgressLabel = $"{row.Evaluated} / {row.Expected} korrigiert";
var fraction = row.Expected <= 0 ? 0 : Math.Clamp((double)row.Evaluated / row.Expected, 0, 1);
ProgressBarWidth = fraction * 240.0;
}
ShowGradeSummary = row.Status is ExamListStatus.AwaitingReturn or ExamListStatus.Returned;
if (ShowGradeSummary) LoadGradeSummary(row);
}
private void LoadGradeSummary(ExamListRowViewModel row)
{
var grades = _examResults.GetByExam(row.Exam.Id)
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
.Select(r => r.Grade!).ToList();
if (grades.Count == 0) { AverageLabel = "Noch keine Noten erfasst."; return; }
AverageLabel = "Ø " + _grading.WeightedAverage(grades.Select(g => (Grade: g, Weight: 1.0)).ToList())
.ToString("0.00", CultureInfo.InvariantCulture);
var counts = grades.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
var maxCount = counts.Count == 0 ? 0 : counts.Values.Max();
foreach (var entry in row.Exam.GradingKey.OrderByDescending(e => e.MinPercent))
{
counts.TryGetValue(entry.Grade, out var count);
DetailGradeDistribution.Add(new GradeBarItem(entry.Grade, count, grades.Count, maxCount));
}
}
[RelayCommand]
private void SelectExam(ExamListRowViewModel row) => SelectedRow = row;
[RelayCommand]
private async Task GradeSelected()
{
if (SelectedRow is null || OnGradeExam is null) return;
await OnGradeExam(SelectedRow.Exam);
Load();
}
[RelayCommand]
private async Task EvaluateSelected()
{
if (SelectedRow is null || OnEvaluateExam is null) return;
await OnEvaluateExam(SelectedRow.Exam);
Load();
}
[RelayCommand]
private void GoToGroup()
{
if (SelectedRow is null) return;
OnNavigateToGroup?.Invoke(SelectedRow.GroupId);
}
[RelayCommand]
private void GoToGroups() => OnNavigateToGroups?.Invoke();
[RelayCommand]
private void ToggleApproval() => ToggleDate(SelectedRow?.Exam, e => e.ApprovalGrantedAt,
(e, v) => e.ApprovalGrantedAt = v);
[RelayCommand]
private void ToggleAnnouncement() => ToggleDate(SelectedRow?.Exam, e => e.AnnouncedAt,
(e, v) => e.AnnouncedAt = v);
private void ToggleDate(Exam? exam, Func<Exam, DateOnly?> get, Action<Exam, DateOnly?> set)
{
if (exam is null) return;
set(exam, get(exam) is null ? DateOnly.FromDateTime(DateTime.Today) : null);
exam.UpdatedAt = DateTime.UtcNow;
_exams.Save(exam);
UpdateDetail(SelectedRow);
}
}
// ── Zeile in der Klausurliste ────────────────────────────────────────────────
public class ExamListRowViewModel
{
public Exam Exam { get; }
public Guid GroupId { get; }
public string Title { get; }
public string GroupLabel { get; }
public int Expected { get; }
public int Evaluated { get; }
public ExamListStatus Status { get; }
public double Score { get; }
public string StatusLabel { get; }
public string SubLabel { get; }
public bool HasSibling { get; set; }
public bool IsOk { get; }
public bool IsInfo { get; }
public bool IsWarning { get; }
public bool IsDanger { get; }
public ExamListRowViewModel(Exam exam, LearningGroup group, int expected, int evaluated,
ExamPriorityInfo info, DateOnly today)
{
Exam = exam; GroupId = group.Id; Title = exam.Title; GroupLabel = group.Name;
Expected = expected; Evaluated = evaluated; Status = info.Status; Score = info.Score;
(StatusLabel, SubLabel, IsOk, IsInfo, IsWarning, IsDanger) = Status switch
{
ExamListStatus.Planned => ("Geplant", $"geplant für {RelativeDay(exam.Date, today)}",
false, false, false, false),
ExamListStatus.AwaitingCorrection when today.DayNumber - exam.Date.DayNumber <= 3 =>
("Korrektur ausstehend", $"geschrieben {RelativeDay(exam.Date, today)}",
false, false, true, false),
ExamListStatus.AwaitingCorrection =>
("überfällig", $"geschrieben {RelativeDay(exam.Date, today)}", false, false, false, true),
ExamListStatus.CorrectionInProgress =>
("Korrektur läuft", $"{evaluated}/{expected} korrigiert", false, false, true, false),
ExamListStatus.CorrectionStuck =>
("hängt fest", $"{evaluated}/{expected} korrigiert", false, false, false, false),
ExamListStatus.AwaitingReturn => ("Korrigiert", "Rückgabe aussteht", false, true, false, false),
ExamListStatus.Returned => ("Abgeschlossen",
exam.ReturnedAt is { } r ? $"zurückgegeben {r:dd.MM.yyyy}" : "zurückgegeben", true, false, false, false),
_ => ("", "", false, false, false, false),
};
}
private static string RelativeDay(DateOnly date, DateOnly today)
{
var diff = date.DayNumber - today.DayNumber;
return diff switch
{
0 => "heute",
1 => "morgen",
-1 => "gestern",
> 1 => $"in {diff} Tagen",
_ => $"vor {-diff} Tagen",
};
}
}
@@ -0,0 +1,215 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>
/// Globale Suche und Schnellerfassung (14.2). Die Projektion bleibt bewusst klein und lokal:
/// durchsucht werden die wichtigsten täglichen Ziele, ohne dafür einen zusätzlichen Suchindex
/// oder eine Netzwerkabhängigkeit einzuführen.
/// </summary>
public partial class GlobalSearchViewModel : ObservableObject
{
private const int MaxDataResults = 12;
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
[ObservableProperty] private string _query = "";
[ObservableProperty] private GlobalSearchResult? _selectedResult;
public ObservableCollection<GlobalSearchResult> Results { get; } = [];
public bool HasResults => Results.Count > 0;
public bool ShowNoResults => !string.IsNullOrWhiteSpace(Query) && Results.Count == 0;
public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? OnQuickAddStudent { get; set; }
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
public Action? OnClose { get; set; }
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
{
_students = students;
_groups = groups;
_subjects = subjects;
_exams = exams;
_tasks = tasks;
RefreshResults();
}
partial void OnQueryChanged(string value) => RefreshResults();
public void Reset()
{
Query = "";
RefreshResults();
}
private void RefreshResults()
{
Results.Clear();
var query = Query.Trim();
AddQuickActions(query);
if (query.Length > 0)
{
var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
? subjects.GetValueOrDefault(subjectId)
: null;
static string SubjectLabel(Subject? subject) => subject is null ? ""
: string.IsNullOrWhiteSpace(subject.ShortName)
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
? subject.Name
: $"{subject.ShortName} {subject.Name}";
var candidates = new List<GlobalSearchResult>();
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
.Where(s => Matches(s.FullName, query))
.Select(s => GlobalSearchResult.ForStudent(s)));
candidates.AddRange(groups
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
candidates.AddRange(_exams.GetAll()
.Where(e => groupNames.ContainsKey(e.GroupId))
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
candidates.AddRange(_tasks.GetAll()
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
foreach (var item in candidates
.OrderByDescending(x => x.Title.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
.ThenBy(x => x.KindSortOrder)
.ThenBy(x => x.Title)
.Take(MaxDataResults))
Results.Add(item);
}
SelectedResult = Results.FirstOrDefault();
OnPropertyChanged(nameof(HasResults));
OnPropertyChanged(nameof(ShowNoResults));
}
private void AddQuickActions(string query)
{
var actions = new[]
{
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
};
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
Results.Add(action);
}
private static bool Matches(string? value, string query) =>
value?.Contains(query, StringComparison.CurrentCultureIgnoreCase) == true;
[RelayCommand]
private async Task Execute(GlobalSearchResult? result)
{
if (result is null) return;
switch (result.Action)
{
case GlobalSearchAction.NewTask:
if (OnQuickAddTask is not null) await OnQuickAddTask(false);
break;
case GlobalSearchAction.NewReminder:
if (OnQuickAddTask is not null) await OnQuickAddTask(true);
break;
case GlobalSearchAction.NewStudent:
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
break;
case GlobalSearchAction.NewGroupDocumentation:
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
break;
default:
OnNavigate?.Invoke(result);
break;
}
OnClose?.Invoke();
}
}
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
public sealed class GlobalSearchResult
{
public GlobalSearchResultKind Kind { get; private init; }
public GlobalSearchAction Action { get; private init; }
public Guid? EntityId { get; private init; }
public Guid? GroupId { get; private init; }
public string Title { get; private init; } = "";
public string Subtitle { get; private init; } = "";
public string Icon { get; private init; } = "";
public int KindSortOrder => Kind switch
{
GlobalSearchResultKind.Student => 0,
GlobalSearchResultKind.Group => 1,
GlobalSearchResultKind.Exam => 2,
GlobalSearchResultKind.Task => 3,
_ => -1,
};
public string KindLabel => Kind switch
{
GlobalSearchResultKind.Student => "Schüler",
GlobalSearchResultKind.Group => "Lerngruppe",
GlobalSearchResultKind.Exam => "Klausur",
GlobalSearchResultKind.Task => "Aufgabe",
_ => "Schnellaktion",
};
public static GlobalSearchResult ForAction(GlobalSearchAction action, string title, string subtitle, string icon) =>
new() { Kind = GlobalSearchResultKind.Action, Action = action, Title = title, Subtitle = subtitle, Icon = icon };
public static GlobalSearchResult ForStudent(Student student) => new()
{
Kind = GlobalSearchResultKind.Student, EntityId = student.Id,
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
};
public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
{
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
Title = group.Name,
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
.Where(x => !string.IsNullOrWhiteSpace(x))),
Icon = "G",
};
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
{
Kind = GlobalSearchResultKind.Exam, EntityId = exam.Id, GroupId = exam.GroupId,
Title = exam.Title, Subtitle = $"{groupName} · {exam.Date:dd.MM.yyyy}", Icon = "K",
};
public static GlobalSearchResult ForTask(WorkTask task, string groupName) => new()
{
Kind = GlobalSearchResultKind.Task, EntityId = task.Id, GroupId = task.GroupId,
Title = task.Title,
Subtitle = string.Join(" · ", new[] { groupName, task.DueDate?.ToString("dd.MM.yyyy") ?? "" }
.Where(x => !string.IsNullOrWhiteSpace(x))),
Icon = task.Kind == TaskKind.Reminder ? "E" : "A",
};
}
@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
@@ -52,6 +53,16 @@ public partial class ExamGradingDialogViewModel : ObservableObject
private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id)); private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id));
/// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren
/// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand
/// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte
/// Zeilen an — bereits eingetragene Punkte/Kommentare bleiben unangetastet.
[RelayCommand]
private void MarkRemainingAbsent()
{
foreach (var row in Rows.Where(r => !r.Absent && r.Cells.All(c => c.Value is null)))
row.Absent = true;
}
} }
// ── Zeile im Punkteraster ────────────────────────────────────────────────── // ── Zeile im Punkteraster ──────────────────────────────────────────────────
@@ -21,6 +21,7 @@ public partial class ExamDialogViewModel : ObservableObject
private readonly int _gradeLevel; private readonly int _gradeLevel;
private readonly GradingSystem _gradingSystem; private readonly GradingSystem _gradingSystem;
private readonly Exam? _editingExam; private readonly Exam? _editingExam;
private readonly Exam? _duplicateSource;
private readonly string _subjectName; private readonly string _subjectName;
[ObservableProperty] private string _title = ""; [ObservableProperty] private string _title = "";
@@ -31,6 +32,7 @@ public partial class ExamDialogViewModel : ObservableObject
[ObservableProperty] private string _titleError = ""; [ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateTextError = ""; [ObservableProperty] private string _dateTextError = "";
[ObservableProperty] private string _returnedAtTextError = ""; [ObservableProperty] private string _returnedAtTextError = "";
[ObservableProperty] private string _weekLoadHint = "";
[ObservableProperty] private double _totalPoints; [ObservableProperty] private double _totalPoints;
[ObservableProperty] private bool _hasCompetencyCatalog; [ObservableProperty] private bool _hasCompetencyCatalog;
[ObservableProperty] private bool _useWeighting; [ObservableProperty] private bool _useWeighting;
@@ -68,6 +70,7 @@ public partial class ExamDialogViewModel : ObservableObject
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel; _groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
_gradingSystem = gradingSystem; _gradingSystem = gradingSystem;
_editingExam = editingExam; _editingExam = editingExam;
_duplicateSource = duplicateSource;
_subjectName = defaultSubjectName; _subjectName = defaultSubjectName;
IsDifferentiated = isDifferentiated; IsDifferentiated = isDifferentiated;
@@ -106,6 +109,7 @@ public partial class ExamDialogViewModel : ObservableObject
} }
foreach (var t in Tasks) t.ShowWeight = UseWeighting; foreach (var t in Tasks) t.ShowWeight = UseWeighting;
RecomputeTotals(); RecomputeTotals();
UpdateWeekLoadHint();
} }
partial void OnUseWeightingChanged(bool value) partial void OnUseWeightingChanged(bool value)
@@ -113,6 +117,26 @@ public partial class ExamDialogViewModel : ObservableObject
foreach (var t in Tasks) t.ShowWeight = value; 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] [RelayCommand]
private void AddTask() private void AddTask()
{ {
@@ -304,6 +328,11 @@ public partial class ExamDialogViewModel : ObservableObject
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName); Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList(); Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
Result.GradingKey = gradingKey; Result.GradingKey = gradingKey;
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
if (_duplicateSource is not null)
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
_exams.Save(Result); _exams.Save(Result);
} }
} }
@@ -54,6 +54,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; } public Func<GradeOverviewRow, Task>? OnManageStudentGrades { get; set; }
public Func<Task>? OnCollectiveGrade { get; set; } public Func<Task>? OnCollectiveGrade { get; set; }
public Func<Task>? OnReportGrades { get; set; } public Func<Task>? OnReportGrades { get; set; }
public Func<GradeOverviewRow, Task>? OnShowPerformanceOverview { get; set; }
public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams, public GradeOverviewTabViewModel(IGradeRepository grades, IExamRepository exams,
IExamResultRepository results, IStudentRepository students, IExamResultRepository results, IStudentRepository students,
@@ -107,6 +108,14 @@ public partial class GradeOverviewTabViewModel : ObservableObject
Recompute(); Recompute();
} }
[RelayCommand]
private async Task ShowPerformanceOverview()
{
if (SelectedRow is null || OnShowPerformanceOverview is null) return;
await OnShowPerformanceOverview(SelectedRow);
Recompute();
}
[RelayCommand] [RelayCommand]
private async Task CollectiveGrade() private async Task CollectiveGrade()
{ {
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private List<StudentOption> _groupStudents = []; private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler"); public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
public ObservableCollection<DocumentationItem> Entries { get; } = []; public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption]; public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private void Load() private void Load()
{ {
Entries.Clear(); Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name); var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>(); var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id) string GroupLabel(Guid id)
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
var all = relevantStudentIds var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id)) .SelectMany(id => _docs.GetByStudent(id))
.Concat(SelectedStudentFilter.Id == Guid.Empty
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
: [])
.DistinctBy(d => d.Id)
.Where(d => !OnlyThisGroup || d.GroupId == _groupId) .Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.IsDraft) .OrderByDescending(d => d.IsDraft)
.ThenByDescending(d => d.Date) .ThenByDescending(d => d.Date)
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
{ {
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId; var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value); var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""), var studentName = d.StudentId == Guid.Empty
? WholeGroupOption.Name
: studentNameById.GetValueOrDefault(d.StudentId, "");
Entries.Add(new DocumentationItem(d, studentName,
isOwnGroup, otherGroupLabel)); isOwnGroup, otherGroupLabel));
} }
} }
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private async Task AddDocumentation() private async Task AddDocumentation()
{ {
if (OnEditDocumentation is null || _groupStudents.Count == 0) return; if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null); var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return; if (result is null) return;
_docs.Save(result); _docs.Save(result);

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