feat(fase-2b): Realtime sync + offline queue + Modo Compra

Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.

2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
  reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
  that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
  `pendingTempIds` set to deduplicate own-mutation echoes against optimistic
  rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
  server response is lost and we retry, the second POST hits PK-conflict
  which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
  follow the item across sections

2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
  MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
  automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
  tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
  and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
  keeps its optimistic row and syncs on reconnect

2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
  z-50) that covers the sidebar while keeping the normal auth routing.
  56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
  Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
  new `list_start_session` message

Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
  new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
  → unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
  replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module

Totals: 103 tests green
  16 pgTAP
  59 Vitest integration (in packages/test-utils)
   6 Vitest unit (in apps/web — the SyncQueue)
  22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
   2 skipped (realtime-presence — upstream bug, unchanged)

Documented in plan/fase-2b and CLAUDE.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 03:32:15 +02:00
parent cb07f67a69
commit e7a961a66d
23 changed files with 1712 additions and 160 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { flip } from 'svelte/animate';
@@ -15,6 +15,13 @@
reorderItems,
fetchSuggestions
} from '$lib/stores/lists';
import {
subscribeToList,
applyItemEvent,
type RealtimeSubscription
} from '$lib/stores/realtimeSync';
import { enqueueOp, hydrateSyncState } from '$lib/sync';
import SyncBanner from '$lib/components/SyncBanner.svelte';
import { getSupabase } from '$lib/supabase';
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
@@ -71,7 +78,13 @@
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
const checkedItems = $derived(items.filter((i) => i.is_checked));
// ── Load ───────────────────────────────────────────────────────────────────
// ── Load + Realtime ────────────────────────────────────────────────────────
let realtimeSub: RealtimeSubscription | null = null;
// Optimistic rows use client-side UUIDs ("tempId") until the server
// confirms the real id. We track them so a Realtime INSERT echo can
// atomically replace the optimistic row instead of appending a duplicate.
const pendingTempIds = new Set<string>();
onMount(async () => {
const [listRes, itemsRes] = await Promise.all([
@@ -88,10 +101,33 @@
items = itemsRes;
loading = false;
// Load initial suggestions (empty prefix = top 5)
if ($currentCollective) {
suggestions = await fetchSuggestions($currentCollective.id, '');
}
await hydrateSyncState();
realtimeSub = await subscribeToList(listId, (evt) => {
if (evt.type === 'INSERT') {
const optimistic = items.find(
(i) =>
pendingTempIds.has(i.id) &&
i.name === evt.row.name &&
i.created_by === evt.row.created_by &&
i.sort_order === evt.row.sort_order
);
if (optimistic) {
items = items.map((i) => (i.id === optimistic.id ? evt.row : i));
pendingTempIds.delete(optimistic.id);
return;
}
}
items = applyItemEvent(items, evt);
});
});
onDestroy(async () => {
await realtimeSub?.unsubscribe();
});
// ── Suggestions ────────────────────────────────────────────────────────────
@@ -135,17 +171,54 @@
created_at: new Date().toISOString()
};
// Use tempId as the REAL server id. The server accepts a client-provided
// UUID for `id` (see supabase/migrations/005 — `id uuid PRIMARY KEY
// DEFAULT gen_random_uuid()` lets us override it). This makes the
// insert idempotent: if the response is lost and we retry via the
// offline queue, the second POST gets a 409 PK conflict (still desired
// end-state), and the Realtime echo only fires once.
pendingTempIds.add(tempId);
items = [...items, optimistic];
newName = '';
newQty = null;
newUnit = '';
nameInput?.focus();
const real = await addItem(listId, name, newQty, optimistic.unit, $currentUser.id, sortOrder);
const real = await addItem(
listId,
name,
newQty,
optimistic.unit,
$currentUser.id,
sortOrder,
tempId
);
if (real) {
items = items.map((i) => (i.id === tempId ? real : i));
// tempId === real.id, so the map is a no-op; still dedupe in case
// a Realtime echo beat us.
const seen = new Set<string>();
items = items.filter((i) => {
if (seen.has(i.id)) return false;
seen.add(i.id);
return true;
});
pendingTempIds.delete(tempId);
} else {
items = items.filter((i) => i.id !== tempId);
// Network failure — keep optimistic row, enqueue the insert with
// the same id so the retry is idempotent.
await enqueueOp({
kind: 'insert',
table: 'shopping_items',
payload: {
id: tempId,
list_id: listId,
name,
quantity: newQty,
unit: optimistic.unit,
sort_order: sortOrder,
created_by: $currentUser.id
}
});
}
}
@@ -318,6 +391,15 @@
{list?.name}
</h1>
</div>
<!-- Start shopping session -->
<a
href="/lists/{listId}/session"
data-testid="start-session"
class="shrink-0 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-slate-50
hover:bg-slate-800 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.list_start_session()}
</a>
<!-- Actions menu -->
<div class="relative shrink-0">
<button
@@ -345,6 +427,8 @@
</div>
</header>
<SyncBanner />
<!-- Items -->
<div class="flex-1 overflow-y-auto pb-32">
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
@@ -492,7 +576,7 @@
{m.list_checked()} ({checkedItems.length})
</p>
{#each checkedItems as item (item.id)}
<div class="flex items-center gap-3 py-3 opacity-50">
<div role="listitem" class="flex items-center gap-3 py-3 opacity-50">
<!-- Drag handle placeholder -->
<div class="hidden sm:flex shrink-0 w-4"></div>

View File

@@ -0,0 +1,230 @@
<!--
Modo Compra — full-screen shopping session view.
Overlays the app sidebar (position: fixed on the wrapper). Large tap targets
optimised for thumbs and checkout flow: tap an item to flip it to CHECKED,
"Finish shopping" marks the list completed and returns to /lists.
Uses the same Realtime + offline machinery as the regular list detail page —
imports from $lib/stores/realtimeSync and $lib/sync so mutations stay
consistent with Fase 2b.1/2b.2.
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { flip } from 'svelte/animate';
import { currentUser } from '$lib/stores/auth';
import { loadItems, checkItem, completeList } from '$lib/stores/lists';
import {
subscribeToList,
applyItemEvent,
type RealtimeSubscription
} from '$lib/stores/realtimeSync';
import { getSupabase } from '$lib/supabase';
import SyncBanner from '$lib/components/SyncBanner.svelte';
import type { ShoppingList, ShoppingItem } from '@colectivo/types';
import { ArrowLeft, Check } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
const listId = $page.params.id!;
let list = $state<ShoppingList | null>(null);
let items = $state<ShoppingItem[]>([]);
let loading = $state(true);
let finishing = $state(false);
let showFinishModal = $state(false);
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
const checkedItems = $derived(items.filter((i) => i.is_checked));
let sub: RealtimeSubscription | null = null;
onMount(async () => {
const [listRes, itemsRes] = await Promise.all([
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
loadItems(listId)
]);
if (listRes.error || !listRes.data) {
goto('/lists');
return;
}
list = listRes.data as ShoppingList;
items = itemsRes;
loading = false;
sub = await subscribeToList(listId, (evt) => {
items = applyItemEvent(items, evt);
});
});
onDestroy(async () => {
await sub?.unsubscribe();
});
async function toggleCheck(item: ShoppingItem) {
if (!$currentUser) return;
const checked = !item.is_checked;
items = items.map((i) =>
i.id === item.id
? {
...i,
is_checked: checked,
checked_by: checked ? $currentUser!.id : null,
checked_at: checked ? new Date().toISOString() : null
}
: i
);
await checkItem(item.id, $currentUser.id, checked);
}
async function confirmFinish() {
if (finishing) return;
finishing = true;
await completeList(listId);
goto('/lists');
}
const flipMs = 250;
</script>
<div
data-testid="shopping-session"
class="fixed inset-0 z-50 flex flex-col bg-background text-text-primary"
>
<!-- Header: back + list name -->
<header class="flex items-center gap-3 border-b border-surface-raised px-4 py-3">
<a
href="/lists/{listId}"
aria-label="Back"
class="rounded-md p-2 hover:bg-black/5 dark:hover:bg-white/5"
>
<ArrowLeft size={20} strokeWidth={1.75} />
</a>
<h1 class="flex-1 truncate text-lg font-semibold tracking-[-0.01em]">
{list?.name ?? ''}
</h1>
</header>
<SyncBanner />
<main class="flex-1 overflow-y-auto px-4 pb-36 pt-2">
{#if loading}
<p class="text-center text-sm text-text-muted py-8">{m.loading()}</p>
{:else if items.length === 0}
<p class="py-16 text-center text-sm text-text-muted">{m.list_items_empty()}</p>
{:else}
<section>
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_to_buy()} ({uncheckedItems.length})
</p>
<ul class="divide-y divide-surface-raised">
{#each uncheckedItems as item (item.id)}
<li
role="listitem"
animate:flip={{ duration: flipMs }}
class="flex items-center gap-4 py-4"
>
<button
onclick={() => toggleCheck(item)}
aria-label="Toggle item"
class="shrink-0 h-14 w-14 rounded-full border-2 border-slate-300 dark:border-slate-600
flex items-center justify-center hover:border-slate-400 dark:hover:border-slate-500
active:scale-95 transition"
></button>
<div class="flex-1 min-w-0">
<p class="truncate text-base font-medium">{item.name}</p>
{#if item.quantity || item.unit}
<p class="text-sm text-text-muted">
{item.quantity ?? ''}{item.unit ? ' ' + item.unit : ''}
</p>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{#if checkedItems.length > 0}
<section class="mt-8 opacity-60">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_checked()} ({checkedItems.length})
</p>
<ul class="divide-y divide-surface-raised">
{#each checkedItems as item (item.id)}
<li
role="listitem"
animate:flip={{ duration: flipMs }}
class="flex items-center gap-4 py-4"
>
<button
onclick={() => toggleCheck(item)}
aria-label="Uncheck item"
class="shrink-0 h-14 w-14 rounded-full border-2 border-slate-400 bg-slate-400
dark:border-slate-500 dark:bg-slate-500
flex items-center justify-center active:scale-95 transition"
>
<Check size={28} strokeWidth={2.5} class="text-white dark:text-slate-900" />
</button>
<div class="flex-1 min-w-0">
<p class="truncate text-base line-through text-text-muted">{item.name}</p>
</div>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
</main>
<!-- Finish shopping CTA -->
<div
class="absolute bottom-0 left-0 right-0 border-t border-surface-raised bg-background/90
px-4 py-3 backdrop-blur-[8px]"
>
<button
onclick={() => (showFinishModal = true)}
class="h-14 w-full rounded-lg bg-slate-900 text-base font-semibold text-slate-50
hover:bg-slate-800 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200
active:scale-[0.98] transition"
>
{m.list_finish_shopping()}
</button>
</div>
{#if showFinishModal}
<div
role="dialog"
aria-modal="true"
class="absolute inset-0 z-10 flex items-center justify-center bg-black/40"
onclick={(e) => {
if (e.target === e.currentTarget) showFinishModal = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') showFinishModal = false;
}}
tabindex="-1"
>
<div class="mx-4 max-w-sm w-full rounded-lg bg-surface p-5 shadow-lg">
<p class="mb-4 text-base font-medium">{m.list_finish_confirm()}</p>
<div class="flex justify-end gap-2">
<button
onclick={() => (showFinishModal = false)}
class="rounded-md px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5"
>
{m.list_finish_confirm_no()}
</button>
<button
onclick={confirmFinish}
disabled={finishing}
class="rounded-md bg-slate-900 px-3 py-2 text-sm font-semibold text-slate-50
hover:bg-slate-800 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200
disabled:opacity-60"
>
{m.list_finish_confirm_yes()}
</button>
</div>
</div>
</div>
{/if}
</div>