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/