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:
@@ -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>
|
||||
|
||||
|
||||
230
apps/web/src/routes/(app)/lists/[id]/session/+page.svelte
Normal file
230
apps/web/src/routes/(app)/lists/[id]/session/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user