managarten/apps
Till JS ed8ab44832 feat(sync): conflict visualization with restore-my-version toast
Closes backlog C from the Phase 9 audit. The data layer has had
real field-level LWW since Sprint 1, but when the server's value
beat a local edit, the user had no way to know. This commit adds
the missing UI piece: a toast that appears whenever applyServerChanges
overwrites a non-empty local field with a strictly newer server
value, with a one-click "restore my version" path.

sync.ts — detection
-------------------
Two new exports:

  - SyncConflictPayload: per-field overwrite event shape
    (tableName, recordId, field, wasLocal, nowServer, localTime,
    serverTime).
  - subscribeSyncConflicts(listener): in-module pub/sub. Returns
    an unsubscribe function.

Both LWW branches in applyServerChanges (insert-as-update and the
canonical update-with-fields path) now call notifyConflict() when:

  1. The server time is STRICTLY greater (not equal) than the local
     field time → there's actually an edit window to lose
  2. The local field value is non-null/undefined → user actually
     typed something to overwrite
  3. The values are not equal (cheap JSON-string compare for objects,
     === for primitives) → there's a real change, not an idempotent
     server replay

Why a custom registry instead of CustomEvent + window.dispatchEvent?
The existing sync-telemetry + quota-detect helpers use
window.dispatchEvent which doesn't work in node-based vitest envs
(no DOM EventTarget). The conflict bus is small enough that a plain
Set<listener> is simpler than polyfilling EventTarget — and the
node test path matters because we need automated coverage of the
detection logic.

conflict-store.svelte.ts — UI state
-----------------------------------
Svelte 5 $state-backed store with three responsibilities:

  1. Coalescing: a SyncConflict is keyed by `${tableName}|${recordId}`,
     so a burst of N field-overwrites on the same record collapses
     into ONE toast with all affected fields underneath. The original
     wasLocal value is preserved across coalescing (we don't clobber
     the user's first typed value if a later field event arrives).

  2. Auto-dismiss: each conflict has a 30s TTL after which it
     evicts itself. Manual dismiss trumps the timer.

  3. Restore: writes wasLocal back to Dexie with a fresh updatedAt
     that beats the server's serverTime, plus a __fieldTimestamps
     patch so the field-LWW pass on the next sync round will let
     our value win. Deferred via setTimeout(0) so it lands AFTER
     applyServerChanges releases its per-table apply lock — running
     before the lock release would silently drop the restore (the
     hook suppression is per-table-set, not per-record).

FIFO eviction at MAX_VISIBLE=8 keeps a bursty server from growing
the visible array unbounded.

SyncConflictToast.svelte — the UI
---------------------------------
Mounts globally in +layout.svelte. Stacks bottom-right above the
OfflineIndicator. Each toast shows:

  - Module label ("Aufgabe", "Notiz", "Termin", …) derived from a
    table-name → German label map. Unknown tables fall through to
    the bare table name.
  - Field count summary ("Feld »title«" / "3 Felder") — we
    deliberately do NOT render the actual values because some are
    encrypted blobs and decrypting them in the toast would be
    significant complexity for marginal UX gain. The user knows
    what they were just editing.
  - Two buttons: "Wiederherstellen" (calls conflictStore.restore)
    and "Behalten" (calls dismiss).

Slide-in animation, dark-mode-aware styling, role="alertdialog"
for accessibility.

Wiring
------
data-layer-listeners.ts:
  - Imports installConflictListener from conflict-store
  - Calls it from installDataLayerListeners() right after the
    quota + telemetry handlers
  - Adds the disposeConflict() call to the cleanup return

+layout.svelte:
  - Imports SyncConflictToast and mounts it next to SuggestionToast
    so it inherits the same global-overlay positioning context

Tests
-----
Five new integration tests in sync.test.ts cover:

  - Fires when server overwrites a non-empty local field with a
    strictly newer value
  - Does NOT fire when local field is null/undefined (no edit to lose)
  - Does NOT fire when values are equal (idempotent replay)
  - Fires once per overwritten field on a multi-field update
  - Does NOT fire on a timestamp tie (LWW lets server win silently
    when there's no real edit window)

All 25 sync tests + 138 total data-layer tests pass. The new
captureConflicts() helper subscribes via subscribeSyncConflicts()
which works in the node-vitest env without needing a DOM polyfill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:01:17 +02:00
..
api feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
calc/packages/shared chore: delete 25 web-archived directories, remove stale stubs, clean workspace config 2026-04-03 13:03:49 +02:00
calendar chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
cards chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
chat chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
citycorners chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
contacts chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
context chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
docs docs: Phase 9 documentation roundup — close encryption-shaped doc gaps 2026-04-08 11:47:59 +02:00
guides chore: delete 25 web-archived directories, remove stale stubs, clean workspace config 2026-04-03 13:03:49 +02:00
inventar chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
mana feat(sync): conflict visualization with restore-my-version toast 2026-04-08 12:01:17 +02:00
manacore/apps/web/src/lib feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
manavoxel chore(workspace): unify vitest to ^4.1.2 across all packages 2026-04-07 13:58:29 +02:00
matrix chore(workspace): unify vitest to ^4.1.2 across all packages 2026-04-07 13:58:29 +02:00
memoro chore(workspace): unify vitest to ^4.1.2 across all packages 2026-04-07 13:58:29 +02:00
moodlit feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
mukke feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
news chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
nutriphi chore(workspace): unify vitest to ^4.1.2 across all packages 2026-04-07 13:58:29 +02:00
photos chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
picture chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
planta chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
presi chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
questions feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
skilltree chore: delete 25 web-archived directories, remove stale stubs, clean workspace config 2026-04-03 13:03:49 +02:00
storage chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
times chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
todo chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
traces feat: rename ManaCore to Mana across entire codebase 2026-04-05 20:00:13 +02:00
uload chore: complete ManaCore → Mana rename (docs, go modules, plists, images) 2026-04-07 12:26:10 +02:00
zitare/packages/content chore: delete 25 web-archived directories, remove stale stubs, clean workspace config 2026-04-03 13:03:49 +02:00