7.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
- 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.
Central Package Management
Directory.Packages.props pins all NuGet versions; individual .csproj files must reference
packages without a Version attribute (<PackageReference Include="Foo" />).