From 7d8deaf79ced64ae37d706c813b6fa79e4e8dafa Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 28 Sep 2026 09:03:44 +0200 Subject: [PATCH] =?UTF-8?q?Zur=C3=BCckstellen=20von=20Dashboard-Warnungen?= =?UTF-8?q?=20synchronisieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine auf einem Gerät zurückgestellte Warnung stand auf den anderen weiter rot oben, weil DashboardWarningSnooze bewusst lokal gehalten war. Save löst jetzt db.OnChange aus, EventApplier übernimmt den Typ. Die Id wird aus (StudentId, WarningTag) abgeleitet, damit zwei Geräte denselben Datensatz beschreiben statt am Unique-Index ux_student_tag zu kollidieren; Schema-Migration 7 schlüsselt bestehende lokale Einträge um. Co-Authored-By: Claude Opus 5.5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 4 +-- .../Models/DashboardWarningSnooze.cs | 23 ++++++++++++---- LehrerApp.Data.Tests/ChangeHookTests.cs | 22 +++++++++++++++ LehrerApp.Data.Tests/LiteDbContextTests.cs | 27 +++++++++++++++++-- LehrerApp.Data/LiteDbContext.cs | 22 +++++++++++++++ .../Repositories/AllRepositories.cs | 7 ++++- LehrerApp.Desktop.Tests/Fakes.cs | 7 ++++- LehrerApp.Sync.Tests/EventApplierTests.cs | 25 +++++++++++++++++ LehrerApp.Sync/EventApplier.cs | 1 + TODO.md | 13 +++++++++ 10 files changed, 140 insertions(+), 11 deletions(-) diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 92eb79a..dccf460 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -218,8 +218,8 @@ public interface IVorgangRepository /// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation). void Delete(Guid id); } -/// Rein lokaler "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — -/// kein Sync, siehe DashboardWarningSnooze. +/// "Zurückstellen"-Zeitstempel für Dashboard-Warnungen je (StudentId, WarningTag) — wird +/// synchronisiert, siehe DashboardWarningSnooze. public interface IDashboardWarningSnoozeRepository { DashboardWarningSnooze? Get(Guid studentId, string warningTag); diff --git a/LehrerApp.Core/Models/DashboardWarningSnooze.cs b/LehrerApp.Core/Models/DashboardWarningSnooze.cs index 995ca78..549fdfd 100644 --- a/LehrerApp.Core/Models/DashboardWarningSnooze.cs +++ b/LehrerApp.Core/Models/DashboardWarningSnooze.cs @@ -1,17 +1,30 @@ +using System.Security.Cryptography; +using System.Text; + namespace LehrerApp.Core.Models; /// /// "Zurückstellen" einer klassenübergreifenden Dashboard-Warnung (Fehlzeiten/Hausaufgaben/ -/// Verspätungen, siehe DashboardViewModel) für eine Person — ein rein lokaler Erinnerungs- -/// Zeitstempel je (StudentId, WarningTag), bewusst nicht über den Sync repliziert (siehe -/// IDashboardWarningSnoozeRepository): eher ein Job-/Fetch-Zustand als echte Fallakten-Daten wie -/// Vorgang/Documentation. +/// Verspätungen, siehe DashboardViewModel) für eine Person — ein Erinnerungs-Zeitstempel je +/// (StudentId, WarningTag), der über den Sync repliziert wird, damit eine auf Gerät A +/// zurückgestellte Warnung auf Gerät B nicht weiter rot oben steht. /// public class DashboardWarningSnooze { - public Guid Id { get; set; } = Guid.NewGuid(); + /// Aus (StudentId, WarningTag) abgeleitet (siehe ; setzt + /// IDashboardWarningSnoozeRepository.Save) statt zufällig: stellen + /// zwei Geräte dieselbe Warnung unabhängig voneinander zurück, beschreiben sie so denselben + /// Datensatz (Last-Write-Wins im Sync), statt zwei Datensätze anzulegen, die beim Anwenden am + /// Unique-Index ux_student_tag scheitern würden. + public Guid Id { get; set; } public Guid StudentId { get; set; } /// Dieselben Strings wie VorgangTagDisplay.Suggestions ("Absentismus"/"Hausaufgaben"/"Verspätungen"). public string WarningTag { get; set; } = ""; public DateOnly SnoozedUntil { get; set; } + + public static Guid IdFor(Guid studentId, string warningTag) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{studentId:N}:{warningTag.ToLowerInvariant()}")); + return new Guid(hash.AsSpan(0, 16)); + } } diff --git a/LehrerApp.Data.Tests/ChangeHookTests.cs b/LehrerApp.Data.Tests/ChangeHookTests.cs index 60a41f4..fc6d2da 100644 --- a/LehrerApp.Data.Tests/ChangeHookTests.cs +++ b/LehrerApp.Data.Tests/ChangeHookTests.cs @@ -77,4 +77,26 @@ public sealed class ChangeHookTests Assert.Equal("Save", call.Operation); Assert.Same(state, call.Payload); } + + [Fact] + public void DashboardWarningSnoozeRepository_Save_LoestOnChangeAus() + { + using var db = NewInMemoryContext(); + var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>(); + db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload)); + var repo = new DashboardWarningSnoozeRepository(db); + var studentId = Guid.NewGuid(); + var snooze = new DashboardWarningSnooze + { + StudentId = studentId, WarningTag = "Absentismus", SnoozedUntil = new DateOnly(2026, 10, 12), + }; + + repo.Save(snooze); + + var call = Assert.Single(calls); + Assert.Equal(nameof(DashboardWarningSnooze), call.EntityType); + Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Absentismus").ToString(), call.EntityId); + Assert.Equal("Save", call.Operation); + Assert.Same(snooze, call.Payload); + } } diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index 9dd2b12..ac9e26e 100644 --- a/LehrerApp.Data.Tests/LiteDbContextTests.cs +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -121,7 +121,7 @@ public sealed class LiteDbContextTests using var temp = new TempDatabase(); using var context = new LiteDbContext(temp.Path); - Assert.Equal(6, context.SchemaVersion); + Assert.Equal(7, context.SchemaVersion); } [Fact] @@ -132,7 +132,30 @@ public sealed class LiteDbContextTests using var second = new LiteDbContext(temp.Path); - Assert.Equal(6, second.SchemaVersion); + Assert.Equal(7, second.SchemaVersion); + } + + [Fact] + public void MigrateDashboardWarningSnoozeIds_SchluesseltLokaleEintraegeAufAbgeleiteteIdUm() + { + using var temp = new TempDatabase(); + var studentId = Guid.NewGuid(); + + using (var legacy = new LiteDatabase(temp.Path)) + { + legacy.GetCollection("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = 6 }); + legacy.GetCollection("dashboard_warning_snoozes").Insert(new DashboardWarningSnooze + { + Id = Guid.NewGuid(), StudentId = studentId, WarningTag = "Verspätungen", + SnoozedUntil = new DateOnly(2026, 10, 12), + }); + } + + using var context = new LiteDbContext(temp.Path); + + var snooze = Assert.Single(context.DashboardWarningSnoozes.FindAll()); + Assert.Equal(DashboardWarningSnooze.IdFor(studentId, "Verspätungen"), snooze.Id); + Assert.Equal(new DateOnly(2026, 10, 12), snooze.SnoozedUntil); } [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index c6782f4..9d12173 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -274,9 +274,31 @@ public class LiteDbContext : IDisposable MigrateCompetencyReferences(); version = 6; } + if (version < 7) + { + MigrateDashboardWarningSnoozeIds(); + version = 7; + } WriteSchemaVersion(version); } + /// DashboardWarningSnooze wird seit Schema 7 synchronisiert und braucht dafür eine aus + /// (StudentId, WarningTag) abgeleitete Id (siehe DashboardWarningSnooze.IdFor). Vorher lokal + /// angelegte Einträge mit zufälliger Id werden umgeschlüsselt, sonst kollidierte ein + /// eingehender Sync-Datensatz für dasselbe Paar am Unique-Index ux_student_tag. + private void MigrateDashboardWarningSnoozeIds() + { + var snoozes = DashboardWarningSnoozes; + foreach (var snooze in snoozes.FindAll().ToList()) + { + var id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + if (snooze.Id == id) continue; + snoozes.Delete(snooze.Id); + snooze.Id = id; + snoozes.Upsert(snooze); + } + } + private int ReadSchemaVersion() { var meta = _db.GetCollection("meta").FindById(1); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index fc644f4..9e56096 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -909,7 +909,12 @@ public class DashboardWarningSnoozeRepository(LiteDbContext db) : IDashboardWarn public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => db.DashboardWarningSnoozes.FindOne(s => s.StudentId == studentId && s.WarningTag == warningTag); public List GetAll() => db.DashboardWarningSnoozes.FindAll().ToList(); - public void Save(DashboardWarningSnooze snooze) => db.DashboardWarningSnoozes.Upsert(snooze); + public void Save(DashboardWarningSnooze snooze) + { + snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + db.DashboardWarningSnoozes.Upsert(snooze); + db.OnChange?.Invoke(nameof(DashboardWarningSnooze), snooze.Id.ToString(), "Save", snooze); + } } public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 555c365..4e2b846 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -347,7 +347,12 @@ public class FakeDashboardWarningSnoozeRepository : IDashboardWarningSnoozeRepos public DashboardWarningSnooze? Get(Guid studentId, string warningTag) => _all.FirstOrDefault(s => s.StudentId == studentId && s.WarningTag == warningTag); public List GetAll() => _all.ToList(); - public void Save(DashboardWarningSnooze snooze) { _all.RemoveAll(s => s.Id == snooze.Id); _all.Add(snooze); } + public void Save(DashboardWarningSnooze snooze) + { + snooze.Id = DashboardWarningSnooze.IdFor(snooze.StudentId, snooze.WarningTag); + _all.RemoveAll(s => s.Id == snooze.Id); + _all.Add(snooze); + } } public class FakeAttachmentStorage : IAttachmentStorage diff --git a/LehrerApp.Sync.Tests/EventApplierTests.cs b/LehrerApp.Sync.Tests/EventApplierTests.cs index c807fe4..149cb6d 100644 --- a/LehrerApp.Sync.Tests/EventApplierTests.cs +++ b/LehrerApp.Sync.Tests/EventApplierTests.cs @@ -52,6 +52,31 @@ public sealed class EventApplierTests Assert.Equal(UntisHubJobKind.OffenePeriods, saved!.Kind); } + [Fact] + public async Task ApplyAsync_DashboardWarningSnoozeSave_UeberschreibtLokalenEintragFuerDasselbePaar() + { + using var db = NewInMemoryContext(); + var applier = new EventApplier(db, Key); + var studentId = Guid.NewGuid(); + // Beide Geräte stellen dieselbe Warnung unabhängig zurück — dank IdFor dieselbe Id, also + // kein Konflikt am Unique-Index ux_student_tag, sondern ein schlichtes Überschreiben. + db.DashboardWarningSnoozes.Insert(new DashboardWarningSnooze + { + Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"), + StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 5), + }); + var remote = new DashboardWarningSnooze + { + Id = DashboardWarningSnooze.IdFor(studentId, "Hausaufgaben"), + StudentId = studentId, WarningTag = "Hausaufgaben", SnoozedUntil = new DateOnly(2026, 10, 26), + }; + + await applier.ApplyAsync(MakeEvent(nameof(DashboardWarningSnooze), remote.Id.ToString(), "Save", remote)); + + var saved = Assert.Single(db.DashboardWarningSnoozes.FindAll()); + Assert.Equal(new DateOnly(2026, 10, 26), saved.SnoozedUntil); + } + [Fact] public async Task ApplyAsync_CompetencyDomainSave_UebernimmtVerschachtelteKompetenzen() { diff --git a/LehrerApp.Sync/EventApplier.cs b/LehrerApp.Sync/EventApplier.cs index 28cdab9..5f2859e 100644 --- a/LehrerApp.Sync/EventApplier.cs +++ b/LehrerApp.Sync/EventApplier.cs @@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n Simple(context => context.SubstitutionEntries); Simple(context => context.Vorgaenge); Simple(context => context.UntisHubJobStates); + Simple(context => context.DashboardWarningSnoozes); handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) => { diff --git a/TODO.md b/TODO.md index a892f02..7811e58 100644 --- a/TODO.md +++ b/TODO.md @@ -2005,6 +2005,19 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk `DashboardViewModel.Load()`/`RebuildAttention()`. `DashboardView.axaml` bekam dafür einen neuen `.defused`-Stil (nach `.attendance` deklariert, damit er bei gleicher Klassenanzahl gewinnt) für Severity-Balken und Fehlzeiten-Prozent-Text. + **Nachtrag, Zurückstellen synchronisiert (2026-09-28):** Nutzer-Feedback: eine auf Gerät A + zurückgestellte Warnung stand auf Gerät B weiter rot oben — die ursprüngliche Einordnung als + "rein lokaler Erinnerungs-Zeitstempel" passte nicht, weil das Zurückstellen eine bewusste + Entscheidung über die Person ist, nicht Geräte-Buchhaltung (gleiche Begründung wie beim + Sync von `UntisHubJobState`). `DashboardWarningSnoozeRepository.Save` ruft jetzt `db.OnChange`, + `EventApplier` hat einen `Simple`-Eintrag. Anders als bei + `UntisHubJobState` liegt auf (StudentId, WarningTag) ein Unique-Index — zwei Geräte mit + zufälligen Ids hätten beim Anwenden einen `LiteException` ausgelöst und das Ereignis + übersprungen. Die Id wird deshalb jetzt deterministisch aus dem Paar abgeleitet + (`DashboardWarningSnooze.IdFor`, gesetzt im Repository-`Save`), sodass beide Geräte denselben + Datensatz beschreiben (Last-Write-Wins). Schema-Migration 7 schlüsselt bereits lokal + gespeicherte Einträge auf die abgeleitete Id um. Bestehende lokale Zurückstellungen werden + erst beim nächsten erneuten Zurückstellen übertragen (kein automatischer Nach-Push). - **Nachtrag, Detail-Popup und Schnellbausteine (September 2026):** Nutzer-Feedback: (a) im Dashboard-Eintrag mehr erfahren, (b) beim Anlegen von Vorgang/Dokumentation Unterstützung statt alles von Hand. Gemeinsame Grundlage ist der neue `StudentWarningEvidenceService` (Desktop/