Files
2026-08-24 22:00:26 +02:00

131 lines
7.7 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
LehrerApp is a German-language desktop application for teachers (Notenverwaltung, Anwesenheit,
Hausaufgaben, Klausuren, Unterrichtsplanung, Mitarbeitsbewertung). The primary product is the
Avalonia desktop client backed by a local LiteDB file; an optional ASP.NET Core API enables
multi-device sync via an encrypted event queue.
The living roadmap and feature spec is `TODO.md` — check it before starting new work and update
it (with an implementation note, following the existing style) when a checklist item is finished.
`docs/Datenmodell.md` documents the meaning of domain fields where naming alone is ambiguous
(e.g. why `GroupMembership` has no `SchoolYear` of its own).
## Commands
```bash
# Build everything
dotnet build LehrerApp.sln
# Run all tests
dotnet test LehrerApp.sln
# Run one test project
dotnet test LehrerApp.Tests/LehrerApp.Tests.csproj
# Run a single test (xUnit fully-qualified name filter)
dotnet test --filter "FullyQualifiedName~GradingServiceTests.RoundToGrade_Kaufmaennisch"
# Run the desktop app
dotnet run --project LehrerApp.Desktop
# Run the sync API locally (needs JWT_SECRET — copy .env.example to .env first)
dotnet run --project LehrerApp.Api
```
The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from the environment).
## Architecture
### Project layout
- **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`),
and framework-free business services (`Services/`): `GradingService` (rounding, grading-key
validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`,
`AppLockService`. No dependency on LiteDB or Avalonia — this is what makes these services
testable without a UI or a real database.
- **LehrerApp.Data** — LiteDB implementation. `LiteDbContext` owns the single `LiteDatabase`
connection and all collection accessors; `Repositories/AllRepositories.cs` implements every
`I*Repository` interface from Core. `DatabaseEncryptionService` handles password-protecting the
DB file.
- **LehrerApp.Desktop** — the Avalonia MVVM client. `AppBootstrapper.BuildServices()` is the
single DI composition root (repositories and `LiteDbContext` are singletons — one LiteDB file
per user/process). `ViewModels/` and `Views/` are split into `Groups/`, `Students/`, `Settings/`
(mirrors the domain, not a strict 1:1 with models).
- **LehrerApp.Sync** — sync client library used by Desktop: `EventQueue` (local outbox),
`ConflictResolver` (last-write-wins with timestamp tie-breaking), `SyncEngine` (push/pull
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
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
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
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)
- ViewModels use CommunityToolkit.Mvvm source generators: `[ObservableProperty] private T _foo;`
and `[RelayCommand]`. A generated `partial void On{Foo}Changed(...)` is emitted for every
`[ObservableProperty]` — do not name a property/callback so it collides with that generated
name (e.g. a field named `OnFooChanged` next to `[ObservableProperty] Foo` fails with `CS0102`).
- Field-level validation: each invalid input gets its own `{Field}Error` string property
(not one collective `ValidationMessage`), rendered directly under that field in XAML via
`IsVisible="{Binding {Field}Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"`.
`Save()` clears all `*Error` properties up front, accumulates failures into a local `valid`
flag instead of returning on the first one, then does `if (!valid) return;` — so multiple
errors can show at once. A message that only makes sense as one combined statement about
several fields (e.g. a weighting scheme's three percentages summing to 100) legitimately stays
a single message near those fields; that's a judgement call, not a bug.
- Enum-backed UI: don't bind a `ComboBox` directly to a raw enum (Avalonia falls back to
`ToString()`, i.e. English member names). Expose a `string[] Options` of German labels plus a
`{Enum}Name` wrapper string property that the ComboBox binds to, converting to/from the actual
enum property — see `NiveauDisplay`/`GradeCategoryDisplay` for the established pattern.
- Dialogs are `Window` subclasses shown via `await dialog.ShowDialog<bool>(owner)`, with
`Close(true)`/`Close(false)` on confirm/cancel and the result read from a `Result` property on
the dialog's ViewModel.
### Avalonia XAML gotchas
- Local attribute values always win over `Style` selectors for the same property. Conditional
`Classes`-based styling only works if the base value also comes from a `Style` selector, not a
local attribute on the element.
- `[SomeAttached.Property] = value` is not valid inside a C# object initializer for an attached
property — use `ToolTip.SetTip(control, value)` as a separate statement.
- `x:DataType` on a `Window`/`UserControl` enables compiled bindings, which validate binding paths
at build time — a clean build is meaningful evidence that new bindings are wired correctly, not
just that the XML parses.
### LiteDB details
- Only a property literally named `Id` is auto-recognized as the primary key. A model with a
differently-named identity property (e.g. `EventId`) needs an explicit `[BsonId]`, or LiteDB
silently assigns an unrelated `_id` and lookups/deletes by that property quietly do nothing.
- `DateTime` round-trips through LiteDB BSON with `Kind` converted `Utc → Local` and the ticks
shifted to preserve the same instant. Since `DateTime` comparison operators compare raw ticks
and ignore `Kind`, comparing a round-tripped value against a freshly-created one is unsafe
outside UTC+0 — always `.ToUniversalTime()` both sides first (see `ConflictResolver`).
LiteDB's `LiteDatabase.Rebuild(new RebuildOptions { Password = ... })` for in-place re-encryption
is broken in the pinned LiteDB version (5.0.21) — it throws even against an unencrypted source
file. `DatabaseEncryptionService` instead copies every collection into a fresh database opened
with the target password.
- `LiteDbContext` tracks a schema version in a `meta` collection (`RunVersionedMigrations`) so
migration steps run once, not on every startup. Add new migrations as another
`if (version < N) { ...; version = N; }` block rather than making old steps re-check their own
idempotency forever.
- `new LiteDbContext(stream)` opens an in-memory database from a `MemoryStream` — used pervasively
in tests for a fast, disk-free `LiteDbContext`.
### Central Package Management
`Directory.Packages.props` pins all NuGet versions; individual `.csproj` files must reference
packages without a `Version` attribute (`<PackageReference Include="Foo" />`).