feat(fase-14): sync_conflicts review route + chrome banner (14.2.3)

New page `/settings/sync-conflicts` that lists the user's last 100
sync_conflicts rows side-by-side (local vs remote payloads) with two
dismiss actions: "Discard local" and "Discard remote". Both actions
write the row id into a `syncConflictsDismissed` localStorage set —
migration 016 made sync_conflicts append-only at the RLS layer, and
Fase 14 ships no DB migration, so dismissal is per-device and the
underlying log is preserved for operator audits.

`(app)/+layout.svelte` now probes sync_conflicts once per session
(deferred out of the auth callback for the same lock-deadlock reason
the collectives query is) and feeds the count into a new
`unresolvedConflictsCount` store. When non-zero AND the user isn't
already on the review route, an amber pill banner offers a one-tap
"Review" link to the page.

OF-02 e2e plants a sync_conflicts row through the dev-only `__sb`
window client, reloads, asserts the banner, clicks through to the
route, discards the row, and asserts the empty state. Existing rows
are pre-dismissed via localStorage so the test is deterministic
regardless of accumulated history (we can't DELETE from the table).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 07:06:24 +02:00
parent c8c7f9f8c9
commit 45cca5072b
4 changed files with 271 additions and 0 deletions

View File

@@ -30,6 +30,13 @@ export const isOnline = readable<boolean>(browser ? navigator.onLine : true, (se
/** Number of pending ops in the offline queue (set by sync/queue integration). */
export const pendingOpsCount = writable<number>(0);
/**
* Fase 14.2.3 — number of `sync_conflicts` rows currently awaiting the
* user's attention (i.e. on the server AND not dismissed locally). Set
* by the `(app)/+layout.svelte` conflict probe; the banner reads it.
*/
export const unresolvedConflictsCount = writable<number>(0);
/** High-level status used by the UI banner. */
export const syncStatus: Readable<SyncState> = derived(
[isOnline, pendingOpsCount],

View File

@@ -9,6 +9,7 @@
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
import { currentCollective } from '$lib/stores/collective';
import { enabledSections } from '$lib/stores/features';
import { unresolvedConflictsCount } from '$lib/stores/syncStatus';
import type { SectionKey } from '@colectivo/types';
import { setSyncContext } from '$lib/sync';
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
@@ -89,6 +90,35 @@
void goto('/lists');
});
// Fase 14.2.3 — probe the server for unresolved sync_conflicts so the
// chrome can show a "review" banner. We only ask when the user id
// changes (login + collective switch don't change user_id; this fires
// once per session). Dismissals are stored in localStorage under
// `syncConflictsDismissed` by `/settings/sync-conflicts`; we subtract
// them here so a dismissed log doesn't keep re-surfacing the banner.
const DISMISSED_KEY = 'syncConflictsDismissed';
let lastConflictProbeUserId: string | null = $state(null);
$effect(() => {
const userId = $currentUser?.id ?? null;
if (!userId || userId === lastConflictProbeUserId) return;
lastConflictProbeUserId = userId;
setTimeout(async () => {
const { data } = await getSupabase()
.from('sync_conflicts')
.select('id')
.eq('user_id', userId);
let dismissed = new Set<string>();
try {
const raw = localStorage.getItem(DISMISSED_KEY);
if (raw) dismissed = new Set(JSON.parse(raw) as string[]);
} catch {
/* corrupt — treat as no dismissals */
}
const open = (data ?? []).filter((r: { id: string }) => !dismissed.has(r.id));
unresolvedConflictsCount.set(open.length);
}, 0);
});
// Fase 9.1: cross-device theme sync. When the user logs in, read
// public.users.theme and adopt it if it differs from the local
// preference. The anti-FOUC inline script in app.html and initTheme()
@@ -149,6 +179,23 @@
<BottomTabBar />
<UndoToast />
{#if $unresolvedConflictsCount > 0 && $page.url.pathname !== '/settings/sync-conflicts'}
<div
data-testid="sync-conflicts-banner"
role="status"
class="pointer-events-none fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-6"
>
<a
href="/settings/sync-conflicts"
class="pointer-events-auto flex items-center gap-3 rounded-full bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-[0px_20px_40px_rgba(245,158,11,0.3)] hover:bg-amber-600"
>
<span>{m.pwa_sync_conflicts_banner()}</span>
<span class="rounded-full bg-white/20 px-2 py-0.5 text-xs">
{m.pwa_sync_conflicts_review()}
</span>
</a>
</div>
{/if}
{#if sectionToast}
<div
data-testid="section-disabled-toast"

View File

@@ -0,0 +1,155 @@
<script lang="ts">
/**
* Fase 14.2.3 — sync conflicts review page.
*
* Tabular surface for the `sync_conflicts` log scoped to the current
* user. Each row pairs the local + remote JSON payloads side-by-side
* and exposes two dismiss actions:
*
* - "Discard local" → accept the remote_won outcome (no-op
* server-side: the remote version already won).
* Hides the row from view via a localStorage
* dismissed-set so the user sees a clean log.
* - "Discard remote" → same hide-from-view semantics but recorded
* separately for analytics. The MVP does NOT
* re-apply the local payload to the entity —
* last-write-wins is permanent at the data
* layer (see plan §14.scope: "no CRDT").
*
* Why localStorage and not a DB UPDATE: migration 016 marks
* sync_conflicts as append-only at the RLS layer (no UPDATE/DELETE
* policies). Fase 14 carries no DB migration. Dismissal is per-device,
* which is acceptable for an informational log.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getSupabase } from '$lib/supabase';
import { currentUser } from '$lib/stores/auth';
import * as m from '$lib/paraglide/messages';
type ConflictRow = {
id: string;
entity_type: string;
entity_id: string;
local_version: unknown;
remote_version: unknown;
resolution: 'remote_won' | 'local_won';
created_at: string;
};
const DISMISSED_KEY = 'syncConflictsDismissed';
let rows = $state<ConflictRow[]>([]);
let loaded = $state(false);
let dismissed = $state<Set<string>>(new Set());
onMount(async () => {
// Hydrate dismissed-set from localStorage. Safe on SSR — onMount
// only runs in the browser.
try {
const raw = localStorage.getItem(DISMISSED_KEY);
if (raw) dismissed = new Set(JSON.parse(raw) as string[]);
} catch {
/* corrupt localStorage — start fresh */
}
if (!$currentUser) {
loaded = true;
return;
}
const { data } = await getSupabase()
.from('sync_conflicts')
.select('id, entity_type, entity_id, local_version, remote_version, resolution, created_at')
.eq('user_id', $currentUser.id)
.order('created_at', { ascending: false })
.limit(100);
rows = (data ?? []) as ConflictRow[];
loaded = true;
});
const visible = $derived(rows.filter((r) => !dismissed.has(r.id)));
function persistDismissed() {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify(Array.from(dismissed)));
} catch {
/* quota / private mode — best effort */
}
}
function discard(id: string) {
const next = new Set(dismissed);
next.add(id);
dismissed = next;
persistDismissed();
}
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="px-8 pb-4 pt-8">
<button
type="button"
data-testid="sync-conflicts-back"
onclick={() => goto('/settings')}
class="mb-2 text-xs text-text-secondary hover:underline"
>
{m.sync_conflicts_back()}
</button>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.sync_conflicts_title()}
</h1>
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<div class="mx-auto max-w-2xl">
{#if loaded && visible.length === 0}
<p class="text-sm text-text-secondary" data-testid="sync-conflicts-route-empty">
{m.sync_conflicts_empty()}
</p>
{:else}
<ul class="space-y-3">
{#each visible as row (row.id)}
<li
data-testid="sync-conflicts-route-row"
data-id={row.id}
class="rounded-md border border-slate-200 bg-surface p-3 text-xs dark:border-slate-700"
>
<div class="mb-2 flex items-center justify-between text-text-secondary">
<span class="font-mono">{row.entity_type}</span>
<span>{new Date(row.created_at).toLocaleString()}</span>
</div>
<div class="mb-3 grid grid-cols-2 gap-2">
<div>
<p class="text-text-muted">local</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.local_version, null, 0)}</pre>
</div>
<div>
<p class="text-text-muted">remote ({row.resolution})</p>
<pre class="overflow-x-auto whitespace-pre-wrap break-all">{JSON.stringify(row.remote_version, null, 0)}</pre>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
data-testid="sync-conflicts-discard-local"
onclick={() => discard(row.id)}
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.sync_conflicts_discard_local()}
</button>
<button
type="button"
data-testid="sync-conflicts-discard-remote"
onclick={() => discard(row.id)}
class="rounded-md border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.sync_conflicts_discard_remote()}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>