Review der Codebase

This commit is contained in:
2026-08-13 19:57:48 +02:00
parent d987b93315
commit 27359794f7
9 changed files with 269 additions and 80 deletions
+24
View File
@@ -102,6 +102,24 @@ public sealed class RepositoryTests
var aspectId = Guid.NewGuid();
db.ParticipationAspects.Insert(new ParticipationAspect { Id = aspectId, GroupId = groupId, Key = "quality" });
var reportGradeId = Guid.NewGuid();
db.ReportGrades.Insert(new ReportGrade { Id = reportGradeId, GroupId = groupId, StudentId = studentId });
var gradingSchemeId = Guid.NewGuid();
db.GradingSchemes.Insert(new GradingScheme { Id = gradingSchemeId, GroupId = groupId });
var sectionId = Guid.NewGuid();
db.ParticipationSections.Insert(new ParticipationSection { Id = sectionId, GroupId = groupId });
var documentationId = Guid.NewGuid();
db.Documentation.Insert(new Documentation { Id = documentationId, GroupId = groupId, StudentId = studentId });
var taskId = Guid.NewGuid();
db.Tasks.Insert(new WorkTask { Id = taskId, GroupId = groupId });
var timeEntryId = Guid.NewGuid();
db.TimeEntries.Insert(new TimeEntry { Id = timeEntryId, GroupId = groupId, TaskId = taskId });
groupRepo.Delete(groupId);
Assert.Null(db.Groups.FindById(groupId));
@@ -115,6 +133,12 @@ public sealed class RepositoryTests
Assert.Null(db.ParticipationSessions.FindById(sessionId));
Assert.Null(db.ParticipationEntries.FindById(entryId));
Assert.Null(db.ParticipationAspects.FindById(aspectId));
Assert.Null(db.ReportGrades.FindById(reportGradeId));
Assert.Null(db.GradingSchemes.FindById(gradingSchemeId));
Assert.Null(db.ParticipationSections.FindById(sectionId));
Assert.Null(db.Documentation.FindById(documentationId)?.GroupId);
Assert.Null(db.Tasks.FindById(taskId)?.GroupId);
Assert.Null(db.TimeEntries.FindById(timeEntryId)?.GroupId);
}
[Fact]
+15
View File
@@ -60,6 +60,21 @@ public class LiteDbContext : IDisposable
public int SchemaVersion => ReadSchemaVersion();
internal void ExecuteInTransaction(Action action)
{
_db.BeginTrans();
try
{
action();
_db.Commit();
}
catch
{
_db.Rollback();
throw;
}
}
private void RunVersionedMigrations()
{
var version = ReadSchemaVersion();
+60 -26
View File
@@ -40,40 +40,74 @@ public class GroupRepository(LiteDbContext db) : IGroupRepository
}
public void Delete(Guid id)
{
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
db.Memberships.Delete(membership.Id);
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList())
db.ExecuteInTransaction(() =>
{
foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
db.ExamResults.Delete(result.Id);
db.Exams.Delete(exam.Id);
}
foreach (var membership in db.Memberships.Find(e => e.GroupId == id).ToList())
db.Memberships.Delete(membership.Id);
foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList())
db.Grades.Delete(grade.Id);
foreach (var exam in db.Exams.Find(e => e.GroupId == id).ToList())
{
foreach (var result in db.ExamResults.Find(r => r.ExamId == exam.Id).ToList())
db.ExamResults.Delete(result.Id);
db.Exams.Delete(exam.Id);
}
foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
{
foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList())
foreach (var grade in db.Grades.Find(g => g.GroupId == id).ToList())
db.Grades.Delete(grade.Id);
foreach (var reportGrade in db.ReportGrades.Find(g => g.GroupId == id).ToList())
db.ReportGrades.Delete(reportGrade.Id);
foreach (var scheme in db.GradingSchemes.Find(s => s.GroupId == id).ToList())
db.GradingSchemes.Delete(scheme.Id);
foreach (var unit in db.Units.Find(u => u.GroupId == id).ToList())
{
foreach (var lesson in db.Lessons.Find(l => l.UnitId == unit.Id).ToList())
db.Lessons.Delete(lesson.Id);
db.Units.Delete(unit.Id);
}
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
db.Lessons.Delete(lesson.Id);
db.Units.Delete(unit.Id);
}
foreach (var lesson in db.Lessons.Find(l => l.GroupId == id).ToList())
db.Lessons.Delete(lesson.Id);
foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
{
foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
db.ParticipationEntries.Delete(entry.Id);
db.ParticipationSessions.Delete(session.Id);
}
foreach (var session in db.ParticipationSessions.Find(s => s.GroupId == id).ToList())
{
foreach (var entry in db.ParticipationEntries.Find(e => e.SessionId == session.Id).ToList())
db.ParticipationEntries.Delete(entry.Id);
db.ParticipationSessions.Delete(session.Id);
}
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
db.ParticipationAspects.Delete(aspect.Id);
foreach (var aspect in db.ParticipationAspects.Find(a => a.GroupId == id).ToList())
db.ParticipationAspects.Delete(aspect.Id);
foreach (var section in db.ParticipationSections.Find(s => s.GroupId == id).ToList())
db.ParticipationSections.Delete(section.Id);
db.Groups.Delete(id);
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
foreach (var documentation in db.Documentation.Find(d => d.GroupId == id).ToList())
{
documentation.GroupId = null;
documentation.UpdatedAt = DateTime.UtcNow;
db.Documentation.Update(documentation);
}
foreach (var task in db.Tasks.Find(t => t.GroupId == id).ToList())
{
task.GroupId = null;
task.UpdatedAt = DateTime.UtcNow;
db.Tasks.Update(task);
}
foreach (var timeEntry in db.TimeEntries.Find(t => t.GroupId == id).ToList())
{
timeEntry.GroupId = null;
db.TimeEntries.Update(timeEntry);
}
db.Groups.Delete(id);
});
}
}
+34 -5
View File
@@ -15,6 +15,8 @@ namespace LehrerApp.Desktop;
public class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
private static ServiceProvider? _serviceProvider;
private static bool _exitHandlerAttached;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -32,31 +34,58 @@ public class App : Application
promptVm.OnUnlocked = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop);
StartMainApp(desktop, showImmediately: true);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
}
else
{
StartMainApp(desktop);
StartMainApp(desktop, showImmediately: false);
}
}
base.OnFrameworkInitializationCompleted();
}
private static void StartMainApp(IClassicDesktopStyleApplicationLifetime desktop)
private static void StartMainApp(
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
{
Services = AppBootstrapper.BuildServices();
_serviceProvider = AppBootstrapper.BuildServices();
Services = _serviceProvider;
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
if (!_exitHandlerAttached)
{
desktop.Exit += (_, _) => DisposeServices();
_exitHandlerAttached = true;
}
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
var main = new MainWindow { DataContext = mainVm };
desktop.MainWindow = main;
main.Show();
if (showImmediately) main.Show();
}
private static void DisposeServices()
{
if (_serviceProvider is null) return;
try
{
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
{
AppBootstrapper.Logger.Error("Datenbank-Checkpoint beim Beenden fehlgeschlagen.", ex);
}
finally
{
_serviceProvider.Dispose();
_serviceProvider = null;
}
}
private static void WireCallbacks(MainWindowViewModel main)
+1 -1
View File
@@ -76,7 +76,7 @@ public static class AppBootstrapper
Environment.Exit(0);
}
public static IServiceProvider BuildServices()
public static ServiceProvider BuildServices()
{
var services = new ServiceCollection();
@@ -7,10 +7,10 @@ namespace LehrerApp.Desktop.Services;
/// Zentrale Exception-Behandlung (13.2.1): protokolliert jeden unerwarteten Fehler und zeigt
/// eine verständliche Meldung statt eines rohen Absturzes.
///
/// Reichweite ehrlich betrachtet: <see cref="Dispatcher"/>.UIThread.UnhandledException kann
/// Ausnahmen aus Befehlen/Ereignis-Handlern auf dem UI-Thread abfangen und die App am Leben
/// erhalten (Handled = true) — das deckt den weit überwiegenden Teil realer Abstürze in einer
/// Desktop-App ab. AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind
/// <see cref="Dispatcher"/>.UIThread.UnhandledException fängt Fehler aus Befehlen und
/// Ereignis-Handlern ab. Nur erwartbar wiederherstellbare I/O-, Netzwerk-, Timeout- und
/// Abbruchfehler werden behandelt; unbekannte Zustandsfehler dürfen die App beenden.
/// AppDomain.UnhandledException und TaskScheduler.UnobservedTaskException sind
/// Sicherheitsnetze für Fehler außerhalb des UI-Threads; die App kann eine "IsTerminating"-
/// Ausnahme dort nicht mehr verhindern, aber wenigstens vollständig protokollieren, bevor sie endet.
/// </summary>
@@ -36,10 +36,18 @@ public static class GlobalExceptionHandler
private static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
{
_logger?.Error("Unbehandelter Fehler auf dem UI-Thread.", e.Exception);
_notifications?.ShowError("Es ist ein unerwarteter Fehler aufgetreten. Details wurden protokolliert.");
e.Handled = true; // App am Leben halten statt abzustürzen.
e.Handled = IsRecoverable(e.Exception);
if (e.Handled)
_notifications?.ShowError("Der Vorgang ist fehlgeschlagen. Details wurden protokolliert.");
}
private static bool IsRecoverable(Exception exception) => exception is
IOException or
UnauthorizedAccessException or
HttpRequestException or
TimeoutException or
OperationCanceledException;
private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
_logger?.Error(
@@ -22,6 +22,14 @@ public partial class MainWindowViewModel : ObservableObject
public ObservableCollection<ToastItem> Toasts { get; }
public AppLockViewModel AppLock { get; }
public bool IsDashboardActive => ActiveNavItem == NavItem.Dashboard;
public bool IsGroupsActive => ActiveNavItem == NavItem.Groups;
public bool IsStudentsActive => ActiveNavItem == NavItem.Students;
public bool IsExamsActive => ActiveNavItem == NavItem.Exams;
public bool IsPlannerActive => ActiveNavItem == NavItem.Planner;
public bool IsWorkloadActive => ActiveNavItem == NavItem.Workload;
public bool IsSettingsActive => ActiveNavItem == NavItem.Settings;
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy,
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
@@ -35,6 +43,17 @@ public partial class MainWindowViewModel : ObservableObject
AppLock.ApplyConfig();
}
partial void OnActiveNavItemChanged(NavItem value)
{
OnPropertyChanged(nameof(IsDashboardActive));
OnPropertyChanged(nameof(IsGroupsActive));
OnPropertyChanged(nameof(IsStudentsActive));
OnPropertyChanged(nameof(IsExamsActive));
OnPropertyChanged(nameof(IsPlannerActive));
OnPropertyChanged(nameof(IsWorkloadActive));
OnPropertyChanged(nameof(IsSettingsActive));
}
[RelayCommand]
private void NavigateTo(NavItem item)
{
+57 -37
View File
@@ -60,6 +60,15 @@
überschreiben kann lokale Werte hätten immer Vorrang vor Style-Settern. -->
<Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="15"/>
<Setter Property="Width" Value="24"/>
</Style>
<Style Selector="Button.navitem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/>
</Style>
<Style Selector="Button.navitem.active">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
</Style>
<!-- Minimierter Zustand: Icons größer & zentriert, Beschriftungen ausgeblendet -->
<Style Selector="DockPanel.compact TextBlock.navlabel">
@@ -67,9 +76,20 @@
</Style>
<Style Selector="DockPanel.compact TextBlock.navicon">
<Setter Property="FontSize" Value="20"/>
<Setter Property="Width" Value="28"/>
</Style>
<Style Selector="DockPanel.compact Button.navitem">
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Padding" Value="2,8"/>
</Style>
<Style Selector="DockPanel.compact StackPanel.navitems">
<Setter Property="Margin" Value="2,12,2,0"/>
</Style>
<Style Selector="DockPanel.compact StackPanel.navcontent">
<Setter Property="Spacing" Value="0"/>
</Style>
<Style Selector="DockPanel.compact Border.drawerheader">
<Setter Property="Padding" Value="8,20,8,14"/>
</Style>
<Style Selector="DockPanel.compact TextBlock.navsection">
<Setter Property="IsVisible" Value="False"/>
@@ -82,7 +102,7 @@
</Style>
</DockPanel.Styles>
<Border DockPanel.Dock="Top" Padding="16,20,16,14"
<Border Classes="drawerheader" DockPanel.Dock="Top" Padding="16,20,16,14"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="10">
@@ -106,14 +126,14 @@
</Border>
<ScrollViewer>
<StackPanel Margin="8,12,8,0" Spacing="2">
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<StackPanel Classes="navitems" Margin="8,12,8,0" Spacing="2">
<Button Classes="navitem" Classes.active="{Binding IsDashboardActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Dashboard}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📊" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📊" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Dashboard"/>
</StackPanel>
</Button>
@@ -121,43 +141,43 @@
<TextBlock Classes="navsection" Text="UNTERRICHT" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="10,14,0,4"/>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsGroupsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Groups}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🏫" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="🏫" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Lerngruppen"/>
</StackPanel>
</Button>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsStudentsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Students}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="👤" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="👤" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Schüler"/>
</StackPanel>
</Button>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsExamsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Exams}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📝" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📝" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Klausuren"/>
</StackPanel>
</Button>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsPlannerActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Planner}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📅" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="📅" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Planung"/>
</StackPanel>
</Button>
@@ -165,23 +185,23 @@
<TextBlock Classes="navsection" Text="VERWALTUNG" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="10,14,0,4"/>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsWorkloadActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Workload}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⏱" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⏱" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Arbeitszeit"/>
</StackPanel>
</Button>
<Button Classes="navitem" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
<Button Classes="navitem" Classes.active="{Binding IsSettingsActive}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Settings}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⚙️" Width="20" TextAlignment="Center"/>
<StackPanel Classes="navcontent" Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="⚙️" TextAlignment="Center"/>
<TextBlock Classes="navlabel" Text="Einstellungen"/>
</StackPanel>
</Button>
+45 -5
View File
@@ -415,7 +415,10 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,
beim Review aber bestätigt.
beim Review aber bestätigt. Die Löschung läuft transaktional und entfernt alle fachlich
abhängigen Datensätze (inkl. Zeugnisnoten, gruppenspezifischem Notenschema und
Mitarbeitsabschnitten). Dokumentation, Aufgaben und Zeiteinträge bleiben als historische
Nachweise erhalten; ihr `GroupId` wird auf `null` gesetzt.
- [ ] **7.2.2** Gruppe ins neue Schuljahr übernehmen: Kopie mit gleicher Schülerschaft,
neues `SchoolYear`, neue `GroupMembership`-Einträge.
- [ ] **7.2.3** Schüler aus einer Gruppe entfernen (`GroupMembership.LeftAt` setzen statt löschen).
@@ -487,6 +490,8 @@ ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
- [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
- [ ] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
- [ ] **10.1.5** Statusanzeige erweitern: letzter Sync, Anzahl wartender Events, Fehlerzustand.
- [ ] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
### 10.2 Server
- [ ] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
@@ -579,10 +584,11 @@ Fächer- und Kompetenzverwaltung existiert bereits in
### 13.2 Fehlerbehandlung & Logging
- [x] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz —
[GlobalExceptionHandler.cs](LehrerApp.Desktop/Services/GlobalExceptionHandler.cs).
`Dispatcher.UIThread.UnhandledException` fängt Fehler aus Befehlen/Ereignis-Handlern auf
dem UI-Thread ab, protokolliert sie und setzt `Handled = true` — die App stürzt dabei
nachweislich nicht ab (per Headless-Test verifiziert: Fehler in einem Dispatcher-Callback
wird geloggt + als Toast angezeigt, App läuft weiter). `AppDomain.UnhandledException` und
`Dispatcher.UIThread.UnhandledException` protokolliert Fehler aus Befehlen/Ereignis-Handlern.
Nur als wiederherstellbar eingestufte I/O-, Netzwerk-, Timeout- und Abbruchfehler werden mit
`Handled = true` behandelt und als Toast angezeigt. Unbekannte Programmier-/Zustandsfehler
dürfen die Anwendung kontrolliert beenden, statt mit möglicherweise beschädigtem Zustand
weiterzulaufen. `AppDomain.UnhandledException` und
`TaskScheduler.UnobservedTaskException` sind Sicherheitsnetze für Fehler außerhalb des
UI-Threads — dort kann ein bereits "IsTerminating"-Fehler nicht mehr verhindert werden,
wird aber vollständig protokolliert.
@@ -664,6 +670,10 @@ Fächer- und Kompetenzverwaltung existiert bereits in
zeigt `ProcessPath` auf den `dotnet`-Host statt auf die App, ein Neustart über die
Einstellungen ist dort also nur in einer veröffentlichten Build (`dotnet publish`)
sinnvoll zu testen.
- [x] **13.3.6** Sauberer Desktop-Lifecycle — beim Beenden wird ein LiteDB-Checkpoint ausgeführt
und anschließend der DI-Container samt Singleton-Datenbankverbindung entsorgt. Das initiale
Hauptfenster wird vom Avalonia-Desktop-Lifetime angezeigt; ein explizites `Show()` erfolgt
nur beim späteren Wechsel vom Passwortfenster zur Hauptansicht.
### 13.4 Codepflege
- [x] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`) — [CLAUDE.md](CLAUDE.md).
@@ -706,6 +716,16 @@ Fächer- und Kompetenzverwaltung existiert bereits in
- [ ] **14.5** Leere Zustände mit Handlungsaufforderung statt leerer Tabellen.
- [ ] **14.6** Fenstergröße und Spaltenbreiten über Sitzungen hinweg merken.
- [ ] **14.7** Bedienung auf Touch-Geräten prüfen (Tablet im Unterricht).
- [ ] **14.8** Responsive Layout und Windows-DPI prüfen (kleine Notebook-Auflösungen sowie
125/150/200 % Skalierung; starre Master-Detail-Spalten bei Bedarf stapeln). Der kompakte
Drawer berücksichtigt bereits die schmalere verfügbare Breite mit reduziertem Außen-/
Innenabstand und einer eigenen Iconfläche, damit Windows-Emoji nicht abgeschnitten werden.
- [ ] **14.9** Barrierefreiheit prüfen: Automation-Namen für Icon-Buttons, sichtbare Fokusrahmen,
Kontraste und Status nicht ausschließlich über Farbe/Emoji vermitteln.
- [ ] **14.10** Plattformübergreifend konsistentes SVG-/`PathIcon`-Set statt systemabhängiger
Emoji-Darstellung einführen.
- [x] **14.11** Aktiven Navigationspunkt in der Seitenleiste sichtbar hervorheben; Zustand wird
über `MainWindowViewModel.ActiveNavItem` gesteuert.
---
@@ -719,6 +739,26 @@ Fächer- und Kompetenzverwaltung existiert bereits in
---
## 16. Datenmodell- und Architekturqualität
- [ ] **16.1** Referenzielle Integrität für alle Modellbeziehungen dokumentieren und je Beziehung
explizit `Cascade`, `Restrict`, `SetNull` oder Archivierung festlegen; Löschpfade mit
Transaktions- und Vollständigkeitstests absichern.
- [ ] **16.2** Domain-Validierung aus den Dialog-ViewModels in gemeinsam nutzbare Regeln/Services
überführen, damit Import, Sync und API dieselben Regeln durchsetzen.
- [ ] **16.3** Prüfen, ob Punkte, Gewichtungen und Prozentgrenzen von `double` auf `decimal` oder
skalierte Ganzzahlen migriert werden sollen; Rundungs- und Migrationsstrategie festlegen.
- [ ] **16.4** Redundant gespeicherte Bewertungswerte (`ExamResult.TotalPoints`, berechnete Note)
entweder ableiten oder zusammen mit einer unveränderlichen Version des verwendeten
Notenschlüssels als historischen Snapshot speichern.
- [ ] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei
Bedarf zusätzlich als historischen Snapshot speichern, damit Umbenennungen keine alten
Klausur- oder Sitzungsreferenzen brechen.
- [ ] **16.6** Navigation und manuelle Callback-Verdrahtung in `App.axaml.cs` langfristig durch
einen testbaren Navigationsdienst oder Messenger ersetzen; statischen Servicezugriff abbauen.
---
## Empfohlene Reihenfolge
Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbeitungsreihenfolge: