From 45cca5072b5b0dcedac5c7583b55e272d98a99ad Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 07:06:24 +0200 Subject: [PATCH] feat(fase-14): sync_conflicts review route + chrome banner (14.2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/lib/stores/syncStatus.ts | 7 + apps/web/src/routes/(app)/+layout.svelte | 47 ++++++ .../settings/sync-conflicts/+page.svelte | 155 ++++++++++++++++++ apps/web/tests/e2e/offline.test.ts | 62 +++++++ 4 files changed, 271 insertions(+) create mode 100644 apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte diff --git a/apps/web/src/lib/stores/syncStatus.ts b/apps/web/src/lib/stores/syncStatus.ts index 9330de4..84a43de 100644 --- a/apps/web/src/lib/stores/syncStatus.ts +++ b/apps/web/src/lib/stores/syncStatus.ts @@ -30,6 +30,13 @@ export const isOnline = readable(browser ? navigator.onLine : true, (se /** Number of pending ops in the offline queue (set by sync/queue integration). */ export const pendingOpsCount = writable(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(0); + /** High-level status used by the UI banner. */ export const syncStatus: Readable = derived( [isOnline, pendingOpsCount], diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index 169d805..1cfce8a 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -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(); + 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 @@ + {#if $unresolvedConflictsCount > 0 && $page.url.pathname !== '/settings/sync-conflicts'} + + {/if} {#if sectionToast}
+ /** + * 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([]); + let loaded = $state(false); + let dismissed = $state>(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(); + } + + +
+
+ +

+ {m.sync_conflicts_title()} +

+
+ +
+
+ {#if loaded && visible.length === 0} +

+ {m.sync_conflicts_empty()} +

+ {:else} +
    + {#each visible as row (row.id)} +
  • +
    + {row.entity_type} + {new Date(row.created_at).toLocaleString()} +
    +
    +
    +

    local

    +
    {JSON.stringify(row.local_version, null, 0)}
    +
    +
    +

    remote ({row.resolution})

    +
    {JSON.stringify(row.remote_version, null, 0)}
    +
    +
    +
    + + +
    +
  • + {/each} +
+ {/if} +
+
+
diff --git a/apps/web/tests/e2e/offline.test.ts b/apps/web/tests/e2e/offline.test.ts index f94aeae..d12ca85 100644 --- a/apps/web/tests/e2e/offline.test.ts +++ b/apps/web/tests/e2e/offline.test.ts @@ -14,6 +14,68 @@ import { loginAs } from '../fixtures/login.js'; const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i; +test.describe('Sync conflicts review', () => { + test('OF-02: a planted sync_conflicts row surfaces the banner + lists on /settings/sync-conflicts, and Discard removes the row', async ({ + page + }) => { + await loginAs(page, USERS.borja); + await page.goto('/lists'); + // Wait for the layout's conflict probe to run + complete (it's + // deferred via setTimeout(0)). + await page.waitForFunction( + () => Boolean((window as unknown as { __sb?: unknown }).__sb), + null, + { timeout: 10_000 } + ); + + // sync_conflicts is append-only at the RLS layer (migration 016 has + // no DELETE policy). Instead of deleting, we pre-dismiss every + // existing row in localStorage so the route + banner reflect only + // the row we plant in this test. + await page.evaluate(async () => { + const sb = (window as unknown as { + __sb: { + auth: { getUser: () => Promise<{ data: { user: { id: string } | null } }> }; + from: (t: string) => { + select: (c: string) => { eq: (k: string, v: string) => Promise<{ data: { id: string }[] | null }> }; + insert: (row: unknown) => Promise; + }; + }; + }).__sb; + const u = (await sb.auth.getUser()).data.user; + if (!u) throw new Error('no user'); + const existing = (await sb.from('sync_conflicts').select('id').eq('user_id', u.id)).data ?? []; + localStorage.setItem('syncConflictsDismissed', JSON.stringify(existing.map((r) => r.id))); + + await sb.from('sync_conflicts').insert({ + user_id: u.id, + collective_id: null, + entity_type: 'shopping_item', + entity_id: '00000000-0000-0000-0000-000000000000', + local_version: { name: 'OF-02-local' }, + remote_version: { name: 'OF-02-remote' }, + resolution: 'remote_won' + }); + }); + + // Reload to re-trigger the probe with the planted row in place. + await page.reload(); + await expect(page.getByTestId('sync-conflicts-banner')).toBeVisible({ timeout: 15_000 }); + + await page.getByTestId('sync-conflicts-banner').click(); + await expect(page).toHaveURL(/\/settings\/sync-conflicts$/); + + const row = page.getByTestId('sync-conflicts-route-row').first(); + await expect(row).toBeVisible({ timeout: 5_000 }); + await row.getByTestId('sync-conflicts-discard-local').click(); + await expect(page.getByTestId('sync-conflicts-route-empty')).toBeVisible({ timeout: 3_000 }); + + // Tests can't DELETE from sync_conflicts (RLS append-only) — the + // row stays in the table. Dismissal lives in localStorage so the + // next run's pre-dismissal pass keeps it out of view. + }); +}); + test.describe('Offline queue + reconnect flush', () => { test('O-01: going offline shows the banner and optimistic adds stay visible', async ({ page,