Files
LehrerApp/CLAUDE.md
T
2026-08-24 22:00:26 +02:00

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. 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" />).