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:
@@ -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). */
|
/** Number of pending ops in the offline queue (set by sync/queue integration). */
|
||||||
export const pendingOpsCount = writable<number>(0);
|
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. */
|
/** High-level status used by the UI banner. */
|
||||||
export const syncStatus: Readable<SyncState> = derived(
|
export const syncStatus: Readable<SyncState> = derived(
|
||||||
[isOnline, pendingOpsCount],
|
[isOnline, pendingOpsCount],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
||||||
import { currentCollective } from '$lib/stores/collective';
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
import { enabledSections } from '$lib/stores/features';
|
import { enabledSections } from '$lib/stores/features';
|
||||||
|
import { unresolvedConflictsCount } from '$lib/stores/syncStatus';
|
||||||
import type { SectionKey } from '@colectivo/types';
|
import type { SectionKey } from '@colectivo/types';
|
||||||
import { setSyncContext } from '$lib/sync';
|
import { setSyncContext } from '$lib/sync';
|
||||||
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
||||||
@@ -89,6 +90,35 @@
|
|||||||
void goto('/lists');
|
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
|
// 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
|
// public.users.theme and adopt it if it differs from the local
|
||||||
// preference. The anti-FOUC inline script in app.html and initTheme()
|
// preference. The anti-FOUC inline script in app.html and initTheme()
|
||||||
@@ -149,6 +179,23 @@
|
|||||||
|
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
<UndoToast />
|
<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}
|
{#if sectionToast}
|
||||||
<div
|
<div
|
||||||
data-testid="section-disabled-toast"
|
data-testid="section-disabled-toast"
|
||||||
|
|||||||
155
apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte
Normal file
155
apps/web/src/routes/(app)/settings/sync-conflicts/+page.svelte
Normal 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>
|
||||||
@@ -14,6 +14,68 @@ import { loginAs } from '../fixtures/login.js';
|
|||||||
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||||
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
|
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<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}).__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.describe('Offline queue + reconnect flush', () => {
|
||||||
test('O-01: going offline shows the banner and optimistic adds stay visible', async ({
|
test('O-01: going offline shows the banner and optimistic adds stay visible', async ({
|
||||||
page,
|
page,
|
||||||
|
|||||||
Reference in New Issue
Block a user