Zurückstellen von Dashboard-Warnungen synchronisieren
CI / build-and-test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 09:03:44 +02:00
1 parent 9a84bbaa38
commit 7d8deaf79c
10 files changed
+140 -11

No files matched your search

+2 -2
View File
@@ -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);
@@ -1,17 +1,30 @@
using System.Security.Cryptography;
using System.Text;
namespace LehrerApp.Core.Models;
/// <summary>
/// "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.
/// </summary>
public class DashboardWarningSnooze
{
public Guid Id { get; set; } = Guid.NewGuid();
/// Aus (StudentId, WarningTag) abgeleitet (siehe <see cref="IdFor"/>; 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));
}
}
+22
View File
@@ -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);
}
}
+25 -2
View File
@@ -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<BsonDocument>("meta").Upsert(new BsonDocument { ["_id"] = 1, ["SchemaVersion"] = 6 });
legacy.GetCollection<DashboardWarningSnooze>("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]
+22
View File
@@ -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<BsonDocument>("meta").FindById(1);
@@ -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<DashboardWarningSnooze> 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
+6 -1
View File
@@ -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<DashboardWarningSnooze> 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
+25
View File
@@ -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()
{
+1
View File
@@ -147,6 +147,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<SubstitutionEntry>(context => context.SubstitutionEntries);
Simple<Vorgang>(context => context.Vorgaenge);
Simple<UntisHubJobState>(context => context.UntisHubJobStates);
Simple<DashboardWarningSnooze>(context => context.DashboardWarningSnoozes);
handlers[nameof(CompetencyCatalogVersion)] = (context, operation, entityId, json) =>
{
+13
View File
@@ -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<DashboardWarningSnooze>`-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/