8.7 KiB
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
# 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
The PHP backend has lived in the independent
LehrerBackend repository since
2026-10-19 (local sibling ../LehrerBackend/). ai-backend/README.md is only a pointer;
change PHP endpoints, providers, authentication and billing in LehrerBackend. Older
ai-backend/... paths in comments/TODO refer to the new repository root. Desktop clients and
DTOs stay here; no submodule or local backend checkout is needed to build the app.
- 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.
LiteDbContextowns the singleLiteDatabaseconnection and all collection accessors;Repositories/AllRepositories.csimplements everyI*Repositoryinterface from Core.DatabaseEncryptionServicehandles password-protecting the DB file. - LehrerApp.Desktop — the Avalonia MVVM client.
AppBootstrapper.BuildServices()is the single DI composition root (repositories andLiteDbContextare singletons — one LiteDB file per user/process).ViewModels/andViews/are split intoGroups/,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, seePlainSyncEventvsSyncEvent). - 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.Apigeleitet werden. - LehrerApp.Api — minimal ASP.NET Core server: JWT auth, an append-only
EventStoreplusSnapshotStore/ReadableSnapshotStoreper device, mapped inEndpoints/Endpoints.cs. Sync is optional — Desktop only registersSyncEngine/SnapshotServicein DI when a server URL is configured (AppBootstrapper.LoadServerUrl). - Each library has a matching
*.Testsproject (LehrerApp.Tests→ Core,LehrerApp.Data.Tests→ Data,LehrerApp.Desktop.Tests→ Desktop,LehrerApp.Sync.Tests→ Sync), all xUnit.LehrerApp.WebUntis.Testscovers the direct WebUntis client and report parser.
MVVM conventions (Desktop)
- ViewModels use CommunityToolkit.Mvvm source generators:
[ObservableProperty] private T _foo;and[RelayCommand]. A generatedpartial 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 namedOnFooChangednext to[ObservableProperty] Foofails withCS0102). - Field-level validation: each invalid input gets its own
{Field}Errorstring property (not one collectiveValidationMessage), rendered directly under that field in XAML viaIsVisible="{Binding {Field}Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}".Save()clears all*Errorproperties up front, accumulates failures into a localvalidflag instead of returning on the first one, then doesif (!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
ComboBoxdirectly to a raw enum (Avalonia falls back toToString(), i.e. English member names). Expose astring[] Optionsof German labels plus a{Enum}Namewrapper string property that the ComboBox binds to, converting to/from the actual enum property — seeNiveauDisplay/GradeCategoryDisplayfor the established pattern. - Dialogs are
Windowsubclasses shown viaawait dialog.ShowDialog<bool>(owner), withClose(true)/Close(false)on confirm/cancel and the result read from aResultproperty on the dialog's ViewModel.
Avalonia XAML gotchas
- Local attribute values always win over
Styleselectors for the same property. ConditionalClasses-based styling only works if the base value also comes from aStyleselector, not a local attribute on the element. [SomeAttached.Property] = valueis not valid inside a C# object initializer for an attached property — useToolTip.SetTip(control, value)as a separate statement.x:DataTypeon aWindow/UserControlenables 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
Idis 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_idand lookups/deletes by that property quietly do nothing. DateTimeround-trips through LiteDB BSON withKindconvertedUtc → Localand the ticks shifted to preserve the same instant. SinceDateTimecomparison operators compare raw ticks and ignoreKind, comparing a round-tripped value against a freshly-created one is unsafe outside UTC+0 — always.ToUniversalTime()both sides first (seeConflictResolver). LiteDB'sLiteDatabase.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.DatabaseEncryptionServiceinstead copies every collection into a fresh database opened with the target password.LiteDbContexttracks a schema version in ametacollection (RunVersionedMigrations) so migration steps run once, not on every startup. Add new migrations as anotherif (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 aMemoryStream— used pervasively in tests for a fast, disk-freeLiteDbContext.LehrerApp.Api'sCli.cs(create-user/set-password) opensusers.dbas a second, separateLiteDatabaseinstance in a short-lived process next to the already-running server's own instance. The write lands on disk correctly, but the running server process keeps serving logins against its own in-memory state and never picks up the external change —docker restarton the API container after running the CLI is required for the new password to actually take effect.
Central Package Management
Directory.Packages.props pins all NuGet versions; individual .csproj files must reference
packages without a Version attribute (<PackageReference Include="Foo" />).