diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b071941 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# 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.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. + +### 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(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 (``). diff --git a/LehrerApp.Desktop/App.axaml b/LehrerApp.Desktop/App.axaml index 03585de..1ff4bad 100644 --- a/LehrerApp.Desktop/App.axaml +++ b/LehrerApp.Desktop/App.axaml @@ -5,5 +5,16 @@ + + + + diff --git a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs index 818ff39..7dcfd67 100644 --- a/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/StudentViewModels.cs @@ -18,6 +18,7 @@ public partial class StudentListViewModel : ObservableObject [ObservableProperty] private StudentListItem? _selectedStudent; public ObservableCollection Students { get; } = []; + public string CountSummary => $"{Students.Count} Schüler gesamt"; public StudentListViewModel(IStudentRepository students) { @@ -40,6 +41,7 @@ public partial class StudentListViewModel : ObservableObject : all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) || s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); foreach (var s in f) Students.Add(new StudentListItem(s)); + OnPropertyChanged(nameof(CountSummary)); } public Func? OnAddStudent { get; set; } diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index 06a806a..b6a649f 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -38,7 +38,7 @@ - @@ -62,7 +62,7 @@ - @@ -195,7 +195,7 @@ - @@ -228,7 +228,7 @@ - diff --git a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml index ac11dfe..83a7110 100644 --- a/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddGroupDialog.axaml @@ -9,7 +9,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/AddSessionDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddSessionDialog.axaml index b7aac11..9475110 100644 --- a/LehrerApp.Desktop/Views/Groups/AddSessionDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddSessionDialog.axaml @@ -9,7 +9,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/AddStudentToGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/AddStudentToGroupDialog.axaml index 2a96c7a..f3fde18 100644 --- a/LehrerApp.Desktop/Views/Groups/AddStudentToGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AddStudentToGroupDialog.axaml @@ -11,7 +11,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/CollectiveGradeDialog.axaml b/LehrerApp.Desktop/Views/Groups/CollectiveGradeDialog.axaml index fd460c6..9a2d1c0 100644 --- a/LehrerApp.Desktop/Views/Groups/CollectiveGradeDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/CollectiveGradeDialog.axaml @@ -9,7 +9,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml index e668de7..774a477 100644 --- a/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/DeleteExamDialog.axaml @@ -7,7 +7,7 @@ CanResize="False" WindowStartupLocation="CenterOwner"> - + diff --git a/LehrerApp.Desktop/Views/Groups/DeleteGroupDialog.axaml b/LehrerApp.Desktop/Views/Groups/DeleteGroupDialog.axaml index 70217ad..3353839 100644 --- a/LehrerApp.Desktop/Views/Groups/DeleteGroupDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/DeleteGroupDialog.axaml @@ -7,7 +7,7 @@ CanResize="False" WindowStartupLocation="CenterOwner"> - + diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml index fadd580..c7bf538 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml @@ -12,7 +12,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml index 27b2fdd..c2ad1c3 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamEvaluationDialog.axaml @@ -13,7 +13,7 @@ - + @@ -70,7 +70,7 @@ - diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml index 5a29149..3e5e053 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml @@ -3,6 +3,7 @@ xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups" xmlns:views="clr-namespace:LehrerApp.Desktop.Views.Groups" xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core" + xmlns:shared="clr-namespace:LehrerApp.Desktop.Views.Shared" x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView" x:DataType="vm:GroupDetailViewModel"> @@ -13,10 +14,7 @@ BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1"> - - - - + diff --git a/LehrerApp.Desktop/Views/Groups/GroupListView.axaml b/LehrerApp.Desktop/Views/Groups/GroupListView.axaml index 679cbdd..0013da4 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupListView.axaml +++ b/LehrerApp.Desktop/Views/Groups/GroupListView.axaml @@ -1,6 +1,7 @@ @@ -11,10 +12,7 @@ BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}" BorderThickness="0,0,0,1"> - - - - + diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml index 2390a87..c6df6e2 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ParticipationGradeDialog.axaml @@ -9,7 +9,7 @@ - + diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml b/LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml index e0cd0fb..efa2346 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ParticipationWizardDialog.axaml @@ -13,7 +13,7 @@ - +