+ Eine andere Sitzung hat dein Edit auf {fieldList(conflict.fields)} überschrieben. Soll deine
+ Version wiederhergestellt werden?
+
+
+
+
+
+
+ {/each}
+
+{/if}
+
+
diff --git a/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts
new file mode 100644
index 000000000..bd11a34e9
--- /dev/null
+++ b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts
@@ -0,0 +1,227 @@
+/**
+ * Conflict Store — surfaces sync field-LWW overwrites to the user.
+ *
+ * Subscribes to the `mana-sync-conflict` CustomEvent that
+ * `applyServerChanges` fires whenever it overwrites a non-empty local
+ * field with a strictly-newer server value. Each event becomes a row
+ * in the visible-conflicts list, which a toast component renders so
+ * the user can either:
+ *
+ * - Restore (writes the original local value back with a fresh
+ * updatedAt that beats the server's, queues a sync push)
+ * - Dismiss (just removes the toast — server value stays)
+ *
+ * Coalescing
+ * ----------
+ * Multiple conflicts on the same record collapse into ONE entry, with
+ * a per-field map underneath. A burst of 5 server changes hitting the
+ * same task only shows one toast (not five), and Restore reverts ALL
+ * affected fields in one transaction. The coalesce key is
+ * `${tableName}|${recordId}` and the dedup window is the time the
+ * record stays in `visible`.
+ *
+ * Auto-dismiss
+ * ------------
+ * Each conflict expires after CONFLICT_TTL_MS (default 30s) so a long-
+ * running session doesn't accumulate stale toasts. Manual dismiss
+ * trumps the timer.
+ *
+ * Restore semantics
+ * -----------------
+ * Restoration runs OUTSIDE the apply lock — applyServerChanges holds
+ * a per-table lock that suppresses pending-change tracking, and we
+ * want our restore write to be tracked. We defer via setTimeout(0)
+ * which lands after the current microtask drain, by which time the
+ * apply lock has been released in the finally block.
+ */
+
+import { db } from './database';
+import { subscribeSyncConflicts, type SyncConflictPayload } from './sync';
+import { FIELD_TIMESTAMPS_KEY } from './database';
+
+/** How long a conflict stays visible before auto-dismissing. */
+const CONFLICT_TTL_MS = 30_000;
+
+/** Cap on the visible list — older entries get evicted FIFO so a
+ * bursty server can't grow the array unbounded. */
+const MAX_VISIBLE = 8;
+
+/** A coalesced conflict — one per (tableName, recordId), with the
+ * per-field overwrite details merged. */
+export interface SyncConflict {
+ /** Stable id for the toast key — `${tableName}|${recordId}`. */
+ id: string;
+ tableName: string;
+ recordId: string;
+ /** Per-field overwrite details. New fields from later events get
+ * merged in; if the same field fires twice, the latest server
+ * state wins (we don't try to be smarter — restore would still
+ * bring back the original local value from the first event). */
+ fields: Record<
+ string,
+ {
+ wasLocal: unknown;
+ nowServer: unknown;
+ localTime: string;
+ serverTime: string;
+ }
+ >;
+ detectedAt: string; // ISO
+}
+
+let visible = $state([]);
+let timers = new Map>();
+let installed = false;
+
+function scheduleAutoDismiss(id: string): void {
+ const existing = timers.get(id);
+ if (existing) clearTimeout(existing);
+ const t = setTimeout(() => dismiss(id), CONFLICT_TTL_MS);
+ timers.set(id, t);
+}
+
+function clearTimer(id: string): void {
+ const t = timers.get(id);
+ if (t) {
+ clearTimeout(t);
+ timers.delete(id);
+ }
+}
+
+function ingest(payload: SyncConflictPayload): void {
+ const id = `${payload.tableName}|${payload.recordId}`;
+ const now = new Date().toISOString();
+
+ const existing = visible.find((c) => c.id === id);
+ if (existing) {
+ // Coalesce a new field-overwrite into the existing entry.
+ // Don't clobber the original wasLocal — that's the value the
+ // user actually typed. Only update nowServer + serverTime.
+ const prior = existing.fields[payload.field];
+ existing.fields[payload.field] = {
+ wasLocal: prior ? prior.wasLocal : payload.wasLocal,
+ nowServer: payload.nowServer,
+ localTime: prior ? prior.localTime : payload.localTime,
+ serverTime: payload.serverTime,
+ };
+ // Touch the timer so the toast stays visible while bursts arrive.
+ scheduleAutoDismiss(id);
+ // Force reactivity — Svelte 5 needs a new array reference for
+ // $derived consumers to re-render. Mutating in place leaves
+ // $effect blind.
+ visible = [...visible];
+ return;
+ }
+
+ const fresh: SyncConflict = {
+ id,
+ tableName: payload.tableName,
+ recordId: payload.recordId,
+ fields: {
+ [payload.field]: {
+ wasLocal: payload.wasLocal,
+ nowServer: payload.nowServer,
+ localTime: payload.localTime,
+ serverTime: payload.serverTime,
+ },
+ },
+ detectedAt: now,
+ };
+
+ // FIFO eviction if we're at the cap.
+ const next = [...visible, fresh];
+ if (next.length > MAX_VISIBLE) {
+ const evicted = next.shift();
+ if (evicted) clearTimer(evicted.id);
+ }
+ visible = next;
+ scheduleAutoDismiss(id);
+}
+
+function dismiss(id: string): void {
+ clearTimer(id);
+ visible = visible.filter((c) => c.id !== id);
+}
+
+async function restore(id: string): Promise {
+ const conflict = visible.find((c) => c.id === id);
+ if (!conflict) return;
+
+ // Defer the actual write so we land after applyServerChanges has
+ // released its apply-lock for this table. setTimeout(0) is enough
+ // — the lock is released synchronously in the finally block, and
+ // the next macrotask runs after that.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ const now = new Date().toISOString();
+ const updates: Record = { updatedAt: now };
+ const ftPatch: Record = {};
+
+ for (const [field, info] of Object.entries(conflict.fields)) {
+ updates[field] = info.wasLocal;
+ ftPatch[field] = now;
+ }
+
+ // Read the current row's __fieldTimestamps and merge our patch in
+ // so we don't blow away unrelated server-side timestamps.
+ const row = await db.table(conflict.tableName).get(conflict.recordId);
+ if (row) {
+ const existingFT =
+ ((row as Record)[FIELD_TIMESTAMPS_KEY] as Record) ?? {};
+ updates[FIELD_TIMESTAMPS_KEY] = { ...existingFT, ...ftPatch };
+ } else {
+ updates[FIELD_TIMESTAMPS_KEY] = ftPatch;
+ }
+
+ try {
+ await db.table(conflict.tableName).update(conflict.recordId, updates);
+ } catch (err) {
+ console.error(
+ `[mana-conflict] restore failed for ${conflict.tableName}/${conflict.recordId}:`,
+ err
+ );
+ // Leave the toast in place so the user knows the restore didn't
+ // land — they can retry or dismiss manually.
+ return;
+ }
+
+ dismiss(id);
+}
+
+/** Boot-time installer. Wires a single sync-conflict subscriber via
+ * the in-module pub/sub helper from sync.ts. The data-layer-listeners
+ * module calls this in the same place that installs the quota +
+ * telemetry listeners, so it's symmetrical with every other sync-side
+ * observer. */
+export function installConflictListener(): () => void {
+ if (installed) return () => {};
+ installed = true;
+
+ const unsubscribe = subscribeSyncConflicts((payload) => {
+ ingest(payload);
+ });
+
+ return () => {
+ unsubscribe();
+ for (const t of timers.values()) clearTimeout(t);
+ timers.clear();
+ visible = [];
+ installed = false;
+ };
+}
+
+/** Read-only view of the visible conflicts for the toast component. */
+export const conflictStore = {
+ get visible() {
+ return visible;
+ },
+ dismiss,
+ restore,
+ /** Test-only: clear everything without going through the listener. */
+ _resetForTesting() {
+ for (const t of timers.values()) clearTimeout(t);
+ timers.clear();
+ visible = [];
+ installed = false;
+ },
+};
diff --git a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts
index 294421403..e38a6c090 100644
--- a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts
+++ b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts
@@ -22,6 +22,7 @@ import { QUOTA_EVENT, type QuotaExceededDetail } from './quota-detect';
import { cleanupTombstones } from './quota';
import { pruneActivityLog } from './activity';
import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry';
+import { installConflictListener } from './conflict-store.svelte';
/** How often to run the tombstone cleanup. 24h is a comfortable cadence
* given that the cutoff is 30 days — runs roughly once per app session. */
@@ -99,6 +100,12 @@ export function installDataLayerListeners(): () => void {
window.addEventListener(QUOTA_EVENT, handleQuota);
window.addEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry);
+ // ─── Sync conflict listener (Backlog C) ────────────────────
+ // Wires the field-LWW overwrite events into the conflict store
+ // that the SyncConflictToast component reads. The store handles
+ // coalescing, auto-dismiss, and the restore-write path.
+ const disposeConflict = installConflictListener();
+
// ─── Periodic cleanup loop ─────────────────────────────────
// Runs once on boot, then daily. Two independent jobs share the
// schedule so we never have a third interval competing for the same
@@ -143,5 +150,6 @@ export function installDataLayerListeners(): () => void {
window.removeEventListener(QUOTA_EVENT, handleQuota);
window.removeEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry);
window.clearInterval(cleanupTimer);
+ disposeConflict();
};
}
diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts
index bb17d9f16..2ff232530 100644
--- a/apps/mana/apps/web/src/lib/data/sync.test.ts
+++ b/apps/mana/apps/web/src/lib/data/sync.test.ts
@@ -37,7 +37,9 @@ import {
isValidSyncChange,
readFieldTimestamps,
applyServerChanges,
+ subscribeSyncConflicts,
type SyncChange,
+ type SyncConflictPayload,
} from './sync';
import { db, FIELD_TIMESTAMPS_KEY } from './database';
@@ -341,4 +343,171 @@ describe('applyServerChanges (Dexie integration)', () => {
.toArray();
expect(pendingForTaskF).toEqual([]);
});
+
+ // ─── Sync conflict event detection (Backlog C) ─────────────
+
+ describe('sync-conflict events', () => {
+ // Helper: collect every conflict event fired during the body
+ // of `fn`, then resolve to the captured payloads. Cleans up the
+ // listener even on test failure.
+ async function captureConflicts(fn: () => Promise): Promise {
+ const captured: SyncConflictPayload[] = [];
+ // In-module pub/sub from sync.ts — works in node-vitest +
+ // browser without DOM EventTarget polyfills.
+ const unsubscribe = subscribeSyncConflicts((payload) => {
+ captured.push(payload);
+ });
+ try {
+ await fn();
+ } finally {
+ unsubscribe();
+ }
+ return captured;
+ }
+
+ it('fires when the server overwrites a non-empty local field with a strictly newer value', async () => {
+ await db.table('tasks').add({
+ id: 'task-conflict-1',
+ title: 'my version',
+ priority: 'low',
+ isCompleted: false,
+ order: 0,
+ });
+
+ const conflicts = await captureConflicts(async () => {
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-1',
+ op: 'update',
+ fields: {
+ title: { value: 'their version', updatedAt: '2099-01-01T00:00:00Z' },
+ },
+ },
+ ]);
+ });
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0]).toMatchObject({
+ tableName: 'tasks',
+ recordId: 'task-conflict-1',
+ field: 'title',
+ wasLocal: 'my version',
+ nowServer: 'their version',
+ });
+ });
+
+ it('does NOT fire when the local field is null/undefined (no edit to lose)', async () => {
+ await db.table('tasks').add({
+ id: 'task-conflict-2',
+ title: null,
+ priority: 'low',
+ isCompleted: false,
+ order: 0,
+ });
+
+ const conflicts = await captureConflicts(async () => {
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-2',
+ op: 'update',
+ fields: {
+ title: { value: 'first server title', updatedAt: '2099-01-01T00:00:00Z' },
+ },
+ },
+ ]);
+ });
+
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it('does NOT fire when the values are equal (idempotent server replay)', async () => {
+ await db.table('tasks').add({
+ id: 'task-conflict-3',
+ title: 'same value',
+ priority: 'low',
+ isCompleted: false,
+ order: 0,
+ });
+
+ const conflicts = await captureConflicts(async () => {
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-3',
+ op: 'update',
+ fields: {
+ title: { value: 'same value', updatedAt: '2099-01-01T00:00:00Z' },
+ },
+ },
+ ]);
+ });
+
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it('fires once per overwritten field (multi-field conflicts)', async () => {
+ await db.table('tasks').add({
+ id: 'task-conflict-4',
+ title: 'local title',
+ priority: 'low',
+ isCompleted: false,
+ order: 0,
+ });
+
+ const conflicts = await captureConflicts(async () => {
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-4',
+ op: 'update',
+ fields: {
+ title: { value: 'server title', updatedAt: '2099-01-01T00:00:00Z' },
+ priority: { value: 'high', updatedAt: '2099-01-01T00:00:00Z' },
+ },
+ },
+ ]);
+ });
+
+ expect(conflicts).toHaveLength(2);
+ expect(conflicts.map((c) => c.field).sort()).toEqual(['priority', 'title']);
+ });
+
+ it('does NOT fire when the server timestamp equals the local one (LWW tie)', async () => {
+ // Seed with a known timestamp via the insert path so we can
+ // match the server time exactly.
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-5',
+ op: 'insert',
+ data: {
+ id: 'task-conflict-5',
+ title: 'tied title',
+ priority: 'low',
+ isCompleted: false,
+ order: 0,
+ updatedAt: '2026-04-01T00:00:00Z',
+ },
+ },
+ ]);
+
+ const conflicts = await captureConflicts(async () => {
+ await applyServerChanges('todo', [
+ {
+ table: 'tasks',
+ id: 'task-conflict-5',
+ op: 'update',
+ fields: {
+ title: { value: 'changed', updatedAt: '2026-04-01T00:00:00Z' }, // exact tie
+ },
+ },
+ ]);
+ });
+
+ // Tied — LWW lets server win silently (no edit-loss to surface)
+ expect(conflicts).toHaveLength(0);
+ });
+ });
});
diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts
index 2e5fbb5f6..98df318a8 100644
--- a/apps/mana/apps/web/src/lib/data/sync.ts
+++ b/apps/mana/apps/web/src/lib/data/sync.ts
@@ -142,6 +142,88 @@ export function readFieldTimestamps(record: unknown): Record {
* concurrent user writes to OTHER tables continue tracking normally.
* Malformed entries are dropped before any DB work happens.
*/
+/**
+ * Per-conflict event payload — emitted when applyServerChanges field-LWW
+ * overwrites a local field value with a strictly newer server value.
+ *
+ * `wasLocal` is the value the user had typed before the sync overwrite.
+ * `nowServer` is what the row holds now. The conflict store reads both
+ * to give the user a "restore my version" option, which writes wasLocal
+ * back with a fresh updatedAt so it wins on the next sync round.
+ *
+ * Conflicts are NOT raised when the local field was empty (no edit to
+ * lose), when the values are identical, or when the timestamps are
+ * exactly equal (LWW lets the server win on ties but there's nothing
+ * meaningful to surface — both clients agreed at the same moment).
+ */
+export interface SyncConflictPayload {
+ tableName: string;
+ recordId: string;
+ field: string;
+ wasLocal: unknown;
+ nowServer: unknown;
+ localTime: string;
+ serverTime: string;
+}
+
+/** Identifier kept around for legacy callers + readable telemetry —
+ * the conflict bus is a plain in-module pub/sub (see below) so it
+ * works the same way in browser and node test envs. */
+export const SYNC_CONFLICT_EVENT = 'mana-sync-conflict';
+
+/** Subscriber callback shape. */
+export type SyncConflictListener = (payload: SyncConflictPayload) => void;
+
+/** Active subscribers. Set so dedup is automatic and unsubscribe is O(1). */
+const conflictListeners = new Set();
+
+/**
+ * Subscribe to sync-conflict events. Returns an unsubscribe function.
+ *
+ * Why a custom registry instead of CustomEvent + window.dispatchEvent?
+ * - Works in node-based vitest envs where `window` doesn't exist
+ * - No accidental coupling to the DOM EventTarget surface
+ * - Lighter than spinning up an EventTarget polyfill in tests
+ *
+ * The conflict-store module installs exactly one subscriber per app
+ * lifecycle. The test file installs a temporary one per test case
+ * via this same API.
+ */
+export function subscribeSyncConflicts(listener: SyncConflictListener): () => void {
+ conflictListeners.add(listener);
+ return () => {
+ conflictListeners.delete(listener);
+ };
+}
+
+function notifyConflict(payload: SyncConflictPayload): void {
+ // Fan out to every subscriber. Errors in one listener don't break
+ // the rest — sync detection is best-effort and we don't want a
+ // broken UI handler to corrupt the apply path.
+ for (const listener of conflictListeners) {
+ try {
+ listener(payload);
+ } catch (err) {
+ console.error('[mana-sync] conflict listener threw:', err);
+ }
+ }
+}
+
+/** Cheap structural equality for sync-conflict comparison. We don't
+ * need a deep diff here — `===` for primitives, JSON-string compare
+ * for objects (including encrypted-blob strings, which compare as
+ * raw strings). Hot path is rare so the JSON serialise cost is fine. */
+function valuesEqual(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (a == null || b == null) return false;
+ if (typeof a !== typeof b) return false;
+ try {
+ return JSON.stringify(a) === JSON.stringify(b);
+ } catch {
+ return false;
+ }
+}
+
export async function applyServerChanges(appId: string, changes: unknown[]): Promise {
// Reject malformed entries up-front so a single bad row from the server
// can never write garbage into IndexedDB. Drops are logged once and the
@@ -244,6 +326,26 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (recordTime >= localFieldTime) {
+ // Conflict signal: server STRICTLY wins (>) and the local
+ // field had a non-empty value that differs from the new
+ // one. Equal-time ties don't fire because there's no
+ // edit to lose.
+ const localValue = (existing as Record)[key];
+ if (
+ recordTime > localFieldTime &&
+ localValue != null &&
+ !valuesEqual(localValue, val)
+ ) {
+ notifyConflict({
+ tableName,
+ recordId,
+ field: key,
+ wasLocal: localValue,
+ nowServer: val,
+ localTime: localFieldTime,
+ serverTime: recordTime,
+ });
+ }
updates[key] = val;
newFT[key] = recordTime;
}
@@ -284,6 +386,24 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro
const serverTime = fc.updatedAt ?? '';
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (serverTime >= localFieldTime) {
+ // Same conflict criteria as the insert-as-update path:
+ // strictly newer + non-empty local + actually different.
+ const localValue = (existing as Record)[key];
+ if (
+ serverTime > localFieldTime &&
+ localValue != null &&
+ !valuesEqual(localValue, fc.value)
+ ) {
+ notifyConflict({
+ tableName,
+ recordId,
+ field: key,
+ wasLocal: localValue,
+ nowServer: fc.value,
+ localTime: localFieldTime,
+ serverTime,
+ });
+ }
updates[key] = fc.value;
newFT[key] = serverTime;
}
diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte
index 92d4a6a3c..5d1083f32 100644
--- a/apps/mana/apps/web/src/routes/+layout.svelte
+++ b/apps/mana/apps/web/src/routes/+layout.svelte
@@ -12,6 +12,7 @@
import SuggestionToast from '$lib/components/SuggestionToast.svelte';
import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte';
import RecoveryCodeUnlockModal from '$lib/components/RecoveryCodeUnlockModal.svelte';
+ import SyncConflictToast from '$lib/components/SyncConflictToast.svelte';
import OfflineIndicator from '$lib/components/OfflineIndicator.svelte';
import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte';
@@ -106,6 +107,7 @@
{@render children()}
+