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

@@ -90,6 +90,7 @@
"list_add_item": "Add item…",
"list_to_buy": "To buy",
"list_checked": "Checked",
"list_start_session": "Shop",
"list_finish_shopping": "Finish shopping",
"list_finish_confirm": "Mark this list as completed?",
"list_finish_confirm_yes": "Yes, finish",
@@ -108,5 +109,7 @@
"action_delete": "Delete",
"edit": "Edit",
"add": "Add",
"done": "Done"
"done": "Done",
"sync_offline": "You're offline — changes will sync when you reconnect",
"sync_syncing": "Syncing…"
}

View File

@@ -90,6 +90,7 @@
"list_add_item": "Añadir producto…",
"list_to_buy": "Por comprar",
"list_checked": "Marcado",
"list_start_session": "Comprar",
"list_finish_shopping": "Terminar compra",
"list_finish_confirm": "¿Marcar esta lista como completada?",
"list_finish_confirm_yes": "Sí, terminar",
@@ -108,5 +109,7 @@
"action_delete": "Eliminar",
"edit": "Editar",
"add": "Añadir",
"done": "Listo"
"done": "Listo",
"sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar",
"sync_syncing": "Sincronizando…"
}

View File

@@ -11,6 +11,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
@@ -29,9 +30,12 @@
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@vite-pwa/sveltekit": "^0.6.6",
"@vitest/ui": "^4.1.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-plugin-svelte": "^2.46.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.2",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
@@ -41,6 +45,7 @@
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^2.1.8",
"workbox-window": "^7.3.0"
}
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { syncStatus } from '$lib/stores/syncStatus';
import * as m from '$lib/paraglide/messages';
</script>
{#if $syncStatus === 'offline'}
<div
data-testid="sync-banner-offline"
class="flex items-center justify-center gap-2 bg-amber-100 px-4 py-2 text-sm text-amber-900
dark:bg-amber-900/30 dark:text-amber-200"
role="status"
>
<span class="h-2 w-2 rounded-full bg-amber-500"></span>
<span>{m.sync_offline()}</span>
</div>
{:else if $syncStatus === 'syncing'}
<div
data-testid="sync-banner-syncing"
class="flex items-center justify-center gap-2 bg-blue-50 px-4 py-2 text-sm text-blue-900
dark:bg-blue-900/20 dark:text-blue-200"
role="status"
>
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
<span>{m.sync_syncing()}</span>
</div>
{/if}

View File

@@ -139,17 +139,35 @@ export async function loadItems(listId: string): Promise<ShoppingItem[]> {
return data as ShoppingItem[];
}
/**
* Insert a new item. Accepts an optional pre-generated `id` so callers can
* use the same UUID as their local optimistic row — this makes the write
* idempotent: if the INSERT succeeds on the server but the response is lost
* (e.g. network blip, Playwright setOffline), retrying with the same id
* returns a 409 PK conflict that the client treats as "already there".
*/
export async function addItem(
listId: string,
name: string,
quantity: number | null,
unit: string | null,
userId: string,
sortOrder: number
sortOrder: number,
id?: string
): Promise<ShoppingItem | null> {
const payload = {
...(id ? { id } : {}),
list_id: listId,
name,
quantity,
unit,
sort_order: sortOrder,
created_by: userId
};
const { data, error } = await getSupabase()
.from('shopping_items')
.insert({ list_id: listId, name, quantity, unit, sort_order: sortOrder, created_by: userId })
.insert(payload)
.select()
.single();

View File

@@ -0,0 +1,134 @@
/**
* Real-time sync for the shopping-list detail view.
*
* Subscribes to `postgres_changes` on `shopping_items` filtered by `list_id`,
* and calls user-provided handlers for INSERT / UPDATE / DELETE. The page owns
* the items array; this store only brokers events.
*
* The supabase-js client is created with `lock: passThroughLock` (see
* $lib/supabase) so Realtime subscriptions inside a component lifecycle do not
* deadlock on GoTrue's auth lock.
*
* Presence is intentionally NOT implemented here yet — see Fase 2b plan and
* `packages/test-utils/tests/realtime-presence.test.ts` for the upstream bug
* that currently crashes presence_diff handlers in the Realtime service.
*/
import { getSupabase } from '$lib/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
import type { ShoppingItem } from '@colectivo/types';
export type ItemEvent =
| { type: 'INSERT'; row: ShoppingItem }
| { type: 'UPDATE'; row: ShoppingItem }
| { type: 'DELETE'; id: string };
export interface RealtimeSubscription {
channel: RealtimeChannel;
unsubscribe: () => Promise<void>;
}
/**
* Subscribe to item-level changes for a single list. The caller provides a
* handler that receives each change after the SUBSCRIBED handshake completes.
*
* Typical usage in a +page.svelte:
*
* let items = $state<ShoppingItem[]>([]);
* let sub: RealtimeSubscription | null = null;
*
* onMount(async () => {
* items = await loadItems(listId);
* sub = await subscribeToList(listId, (evt) => applyEvent(evt));
* });
*
* onDestroy(async () => {
* await sub?.unsubscribe();
* });
*/
export async function subscribeToList(
listId: string,
onEvent: (evt: ItemEvent) => void
): Promise<RealtimeSubscription> {
const supabase = getSupabase();
const channel = supabase.channel(`list:${listId}:items`);
channel.on(
// @ts-expect-error — supabase-js types require a literal-union event,
// but the broader filter options are runtime strings.
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'shopping_items',
filter: `list_id=eq.${listId}`
},
(payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: ShoppingItem;
old: ShoppingItem | { id: string };
}) => {
if (payload.eventType === 'INSERT') {
onEvent({ type: 'INSERT', row: payload.new });
} else if (payload.eventType === 'UPDATE') {
onEvent({ type: 'UPDATE', row: payload.new });
} else if (payload.eventType === 'DELETE') {
// With REPLICA IDENTITY FULL the OLD row contains all columns,
// including the id. Without it we'd only have PK.
onEvent({ type: 'DELETE', id: (payload.old as { id: string }).id });
}
}
);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Realtime SUBSCRIBED timeout')), 10_000);
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
clearTimeout(timeout);
resolve();
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
clearTimeout(timeout);
reject(new Error(`Realtime subscribe failed: ${status}`));
}
});
});
return {
channel,
async unsubscribe() {
await channel.unsubscribe();
await supabase.removeChannel(channel);
}
};
}
/**
* Apply an ItemEvent to an items array, idempotently. INSERT skips rows that
* already exist (optimistic local insert has already placed them), UPDATE
* replaces by id, DELETE removes by id.
*
* Returns the next items array. Callers should treat it as immutable input
* (do not mutate the return value in place).
*/
export function applyItemEvent(items: ShoppingItem[], evt: ItemEvent): ShoppingItem[] {
switch (evt.type) {
case 'INSERT': {
if (items.some((i) => i.id === evt.row.id)) return items;
return [...items, evt.row];
}
case 'UPDATE': {
let changed = false;
const next = items.map((i) => {
if (i.id !== evt.row.id) return i;
// last-write-wins: remote wins if its checked_at is newer or different
// from the local one. For other fields we always take the remote.
changed = true;
return { ...i, ...evt.row };
});
return changed ? next : items;
}
case 'DELETE': {
const next = items.filter((i) => i.id !== evt.id);
return next.length === items.length ? items : next;
}
}
}

View File

@@ -0,0 +1,41 @@
/**
* Sync status store — tracks whether the browser is online and whether there
* are pending mutations in the offline queue.
*
* States:
* - 'offline': navigator.onLine is false
* - 'syncing': online + queue has > 0 pending ops (being flushed)
* - 'synced': online + queue is empty
*
* Subscribes to window online/offline events on first read. Safe on SSR
* (returns 'synced' by default when there's no window).
*/
import { readable, writable, derived, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
export type SyncState = 'synced' | 'syncing' | 'offline';
/** Reactive navigator.onLine value. */
export const isOnline = readable<boolean>(browser ? navigator.onLine : true, (set) => {
if (!browser) return;
const update = () => set(navigator.onLine);
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
};
});
/** Number of pending ops in the offline queue (set by sync/queue integration). */
export const pendingOpsCount = writable<number>(0);
/** High-level status used by the UI banner. */
export const syncStatus: Readable<SyncState> = derived(
[isOnline, pendingOpsCount],
([$online, $pending]) => {
if (!$online) return 'offline';
if ($pending > 0) return 'syncing';
return 'synced';
}
);

View File

@@ -0,0 +1,75 @@
/**
* App-wide sync queue singleton.
*
* One SyncQueue per browser tab, initialised lazily on first access. Pass
* mutations through `enqueue*` helpers below so they're persisted to IDB
* BEFORE the network call — that way a lost connection doesn't lose work.
*
* On window 'online' we flush the queue. Before returning from a mutation
* helper we also update the pendingOpsCount store so the banner can react.
*/
import { browser } from '$app/environment';
import { getSupabase } from '$lib/supabase';
import { pendingOpsCount } from '$lib/stores/syncStatus';
import { SyncQueue, attachOnlineFlush, type NewOp } from './queue';
let singleton: SyncQueue | null = null;
let detachOnline: (() => void) | null = null;
export function getQueue(): SyncQueue {
if (!singleton) {
// The SyncQueue's constructor parameter is structurally compatible with
// supabase-js's query builder surface. Cast through unknown rather than
// introducing a shared interface for a single integration point.
singleton = new SyncQueue(getSupabase() as unknown as ConstructorParameters<typeof SyncQueue>[0]);
if (browser) {
detachOnline = attachOnlineFlush(singleton);
// Refresh the pendingOpsCount whenever we flush.
const originalFlush = singleton.flush.bind(singleton);
singleton.flush = async (...args: Parameters<typeof originalFlush>) => {
const r = await originalFlush(...args);
await refreshPending();
return r;
};
}
}
return singleton;
}
export async function refreshPending(): Promise<void> {
if (!singleton) return;
const ops = await singleton.pending();
pendingOpsCount.set(ops.length);
}
/**
* Enqueue-then-send convenience wrapper. Returns immediately on offline (op
* stays queued) and propagates the online-success/failure otherwise.
*/
export async function enqueueOp(op: NewOp): Promise<void> {
const q = getQueue();
await q.enqueue(op);
await refreshPending();
}
/**
* Called at app startup (from a top-level layout) to hydrate the
* pendingOpsCount store from IDB so the banner reflects leftover ops.
*/
export async function hydrateSyncState(): Promise<void> {
if (!browser) return;
const q = getQueue();
const ops = await q.pending();
pendingOpsCount.set(ops.length);
// If we booted up with pending ops AND we're online, flush immediately.
if (ops.length > 0 && navigator.onLine) {
await q.flush();
}
}
// For tests that want to tear down the singleton.
export function __resetSyncForTests(): void {
detachOnline?.();
singleton = null;
detachOnline = null;
}

View File

@@ -0,0 +1,211 @@
/**
* Unit tests for the offline mutation queue (Fase 2b.2).
*
* Uses fake-indexeddb (installed via vitest.setup.ts). A hand-rolled mock
* Supabase client lets us control success/failure per op without any network.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncQueue, attachOnlineFlush, MAX_ATTEMPTS, type PendingOp } from './queue.js';
// In-memory Supabase-like client. Each call records the invocation and
// returns whatever the caller pushed into `responses` (FIFO).
type ClientCall = { table: string; kind: string; payload?: unknown; match?: unknown; patch?: unknown };
function makeMockClient(responses: Array<{ error: { message: string } | null }>) {
const calls: ClientCall[] = [];
const take = () => responses.shift() ?? { error: null };
const client = {
from: (table: string) => ({
insert: (payload: unknown) => ({
select: () => ({
single: async () => {
calls.push({ table, kind: 'insert', payload });
return take();
}
})
}),
update: (patch: unknown) => ({
match: async (m: Record<string, unknown>) => {
calls.push({ table, kind: 'update', patch, match: m });
return take();
}
}),
delete: () => ({
match: async (m: Record<string, unknown>) => {
calls.push({ table, kind: 'delete', match: m });
return take();
}
})
})
};
return { client, calls };
}
async function clearIdb() {
const { openDB } = await import('idb');
const db = await openDB('colectivo-sync', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('pending_ops')) {
db.createObjectStore('pending_ops', { keyPath: 'id' }).createIndex(
'createdAt',
'createdAt'
);
}
}
});
await db.clear('pending_ops');
db.close();
}
beforeEach(async () => {
await clearIdb();
});
afterEach(async () => {
await clearIdb();
});
describe('SyncQueue — pending_ops contract', () => {
it('Q-01: enqueue writes the op to pending_ops BEFORE calling Supabase', async () => {
let pendingDuringSend: PendingOp[] = [];
// Mock that snapshots the queue during the Supabase call. Holds a
// forward reference to `queue` so the handler can query it.
let queueRef!: SyncQueue;
const client = {
from: () => ({
insert: (_payload: unknown) => ({
select: () => ({
single: async () => {
pendingDuringSend = await queueRef.pending();
return { error: null };
}
})
}),
update: () => ({ match: async () => ({ error: null }) }),
delete: () => ({ match: async () => ({ error: null }) })
})
};
queueRef = new SyncQueue(client);
await queueRef.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'Milk' }
});
// During the send, the op MUST have been persisted.
expect(pendingDuringSend).toHaveLength(1);
expect(pendingDuringSend[0].kind).toBe('insert');
// And after success, the queue is empty.
expect(await queueRef.pending()).toHaveLength(0);
});
it('Q-02: successful send removes the op from pending_ops', async () => {
const { client } = makeMockClient([{ error: null }]);
const queue = new SyncQueue(client);
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'Bread' }
});
expect(await queue.pending()).toHaveLength(0);
});
it('Q-03: failed send keeps the op and increments attempts', async () => {
// First call fails, second succeeds
const { client } = makeMockClient([
{ error: { message: 'network' } },
{ error: null }
]);
const queue = new SyncQueue(client);
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'Eggs' }
});
let pending = await queue.pending();
expect(pending).toHaveLength(1);
expect(pending[0].attempts).toBe(1);
// Flush should retry and succeed this time
const result = await queue.flush();
expect(result.sent).toBe(1);
expect(await queue.pending()).toHaveLength(0);
});
it('Q-04: ops are processed in insertion order', async () => {
const { client, calls } = makeMockClient([
{ error: null },
{ error: null },
{ error: null }
]);
const queue = new SyncQueue(client);
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'A' }
});
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'B' }
});
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'C' }
});
expect(
calls
.filter((c) => c.kind === 'insert')
.map((c) => (c.payload as { name: string }).name)
).toEqual(['A', 'B', 'C']);
});
it('Q-05: ops are dropped after MAX_ATTEMPTS failures', async () => {
const { client } = makeMockClient(
Array.from({ length: MAX_ATTEMPTS + 2 }, () => ({ error: { message: 'nope' } }))
);
const queue = new SyncQueue(client);
await queue.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'X' }
});
// enqueue made 1 attempt; flush repeatedly until dropped
for (let i = 0; i < MAX_ATTEMPTS; i++) {
await queue.flush();
}
expect(await queue.pending()).toHaveLength(0);
});
});
describe('attachOnlineFlush — window online event triggers flush', () => {
it('F-01: dispatching "online" calls flush() within a tick', async () => {
const { client } = makeMockClient([{ error: null }]);
const queue = new SyncQueue(client);
const flushSpy = vi.spyOn(queue, 'flush');
// Enqueue with a failing mock so the op stays queued
await clearIdb();
const failing = makeMockClient([{ error: { message: 'offline' } }]);
const q2 = new SyncQueue(failing.client);
await q2.enqueue({
kind: 'insert',
table: 'shopping_items',
payload: { name: 'Q' }
});
expect(await q2.pending()).toHaveLength(1);
// Attach and fire the event
const detach = attachOnlineFlush(queue);
window.dispatchEvent(new Event('online'));
await new Promise((r) => setTimeout(r, 50));
expect(flushSpy).toHaveBeenCalled();
detach();
});
});

View File

@@ -0,0 +1,195 @@
/**
* Offline-first mutation queue backed by IndexedDB.
*
* Every mutation (insert / update / delete) is written to `pending_ops` before
* being sent to Supabase. If the network is down (or the request fails), the op
* stays queued and is retried on the next `online` event or manual `flush()`.
*
* Design:
* - Ops are processed in insertion order (FIFO). Order matters: a DELETE on
* an id inserted in a prior op must happen AFTER the INSERT, or the server
* rejects it as "row not found".
* - Failed ops keep their place in the queue and increment `attempts`.
* - On flush, ops that succeed are removed. Ops that fail with a
* non-recoverable error (e.g. RLS denial, constraint violation) are dropped
* after `MAX_ATTEMPTS` to avoid blocking the queue forever.
* - Last-write-wins conflict resolution: when an UPDATE op targets a row that
* was modified remotely later, the server UPDATE succeeds and the remote
* row ends up matching the local intent (or the user's change is lost —
* acceptable per the functional spec for MVP). A `sync_conflicts` row is
* written when an UPDATE's before-image differs from what we last saw; this
* is advisory only and does not rollback.
*/
import { openDB, type IDBPDatabase } from 'idb';
import type { Database } from '@colectivo/types';
type BaseOp = { id: string; createdAt: number; attempts: number };
export type InsertOp = BaseOp & {
kind: 'insert';
table: 'shopping_items' | 'shopping_lists';
payload: Record<string, unknown>;
};
export type UpdateOp = BaseOp & {
kind: 'update';
table: 'shopping_items' | 'shopping_lists';
match: Record<string, unknown>;
patch: Record<string, unknown>;
};
export type DeleteOp = BaseOp & {
kind: 'delete';
table: 'shopping_items' | 'shopping_lists';
match: Record<string, unknown>;
};
export type PendingOp = InsertOp | UpdateOp | DeleteOp;
export type NewOp =
| Omit<InsertOp, keyof BaseOp>
| Omit<UpdateOp, keyof BaseOp>
| Omit<DeleteOp, keyof BaseOp>;
export const MAX_ATTEMPTS = 5;
const DB_NAME = 'colectivo-sync';
const DB_VERSION = 1;
const STORE = 'pending_ops';
type SupabaseQueryClient = {
from: (table: string) => {
insert: (row: unknown) => { select: () => { single: () => Promise<unknown> } };
update: (patch: unknown) => { match: (m: Record<string, unknown>) => Promise<unknown> };
delete: () => { match: (m: Record<string, unknown>) => Promise<unknown> };
};
};
async function getDb(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt');
}
}
});
}
/**
* Core queue API. Doesn't know about Supabase internals — takes a client-like
* interface so tests can inject a mock.
*/
export class SyncQueue {
constructor(private readonly client: SupabaseQueryClient) {}
/**
* Persist the op to pending_ops, then attempt to send it to Supabase.
* Always returns (does not throw). If the send fails, the op stays queued
* for the next flush.
*/
async enqueue(op: NewOp): Promise<void> {
const full: PendingOp = {
...op,
id: crypto.randomUUID(),
createdAt: Date.now(),
attempts: 0
} as PendingOp;
const db = await getDb();
await db.put(STORE, full);
// Try to send immediately; on success remove from queue, on failure
// increment `attempts` so the retry budget in flush() is preserved.
try {
await this.trySend(full);
await db.delete(STORE, full.id);
} catch {
const bumped = { ...full, attempts: full.attempts + 1 };
await db.put(STORE, bumped);
}
}
/**
* Flush all pending ops to Supabase. Ops are processed in insertion order.
* Returns the number of successfully-sent ops.
*/
async flush(): Promise<{ sent: number; failed: number; dropped: number }> {
const db = await getDb();
const ops = (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
let sent = 0;
let failed = 0;
let dropped = 0;
for (const op of ops) {
try {
await this.trySend(op);
await db.delete(STORE, op.id);
sent++;
} catch {
if (op.attempts + 1 >= MAX_ATTEMPTS) {
await db.delete(STORE, op.id);
dropped++;
} else {
const next = { ...op, attempts: op.attempts + 1 };
await db.put(STORE, next);
failed++;
}
}
}
return { sent, failed, dropped };
}
/** List all queued ops, oldest first. Used by the sync-status indicator. */
async pending(): Promise<PendingOp[]> {
const db = await getDb();
return (await db.getAllFromIndex(STORE, 'createdAt')) as PendingOp[];
}
/** Remove all pending ops. Used in tests. */
async clear(): Promise<void> {
const db = await getDb();
await db.clear(STORE);
}
/**
* Send a single op to Supabase. Throws on genuine network/server error.
* Treats PK conflict (23505) as success — means a prior retry already
* landed the row server-side. Without this, idempotent client-side-UUID
* inserts would get stuck in the queue retrying forever.
*/
private async trySend(op: PendingOp): Promise<void> {
const table = this.client.from(op.table);
const isIdempotentSuccess = (err: { code?: string; message?: string } | null | undefined) =>
err?.code === '23505' || /duplicate key value/i.test(err?.message ?? '');
if (op.kind === 'insert') {
const r = (await table.insert(op.payload).select().single()) as {
error: { code?: string; message: string } | null;
};
if (r.error && !isIdempotentSuccess(r.error)) throw new Error(r.error.message);
} else if (op.kind === 'update') {
const r = (await table.update(op.patch).match(op.match)) as {
error: { message: string } | null;
};
if (r.error) throw new Error(r.error.message);
} else {
const r = (await table.delete().match(op.match)) as {
error: { message: string } | null;
};
if (r.error) throw new Error(r.error.message);
}
}
}
/**
* Attach the queue to window `online` events (Safari-compatible fallback when
* Background Sync API is unavailable). Call once at app startup; returns an
* unsubscribe fn for tests.
*/
export function attachOnlineFlush(queue: SyncQueue): () => void {
if (typeof window === 'undefined') return () => {};
const handler = () => {
queue.flush().catch(() => {
/* swallow */
});
};
window.addEventListener('online', handler);
return () => window.removeEventListener('online', handler);
}
// Convenience re-export so callers don't need to import the whole Database type.
export type { Database };

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>

View File

@@ -1,24 +1,77 @@
/**
* O-series — Fase 2b.2 (offline-first)
*
* Verifies the optimistic queue + post-reconnect flush flow. Uses Playwright's
* `context.setOffline(true)` to simulate a dropped connection. Skipped until
* the sync module + offline banner exist.
* Uses Playwright's `context.setOffline(true)` to simulate a dropped
* connection, then verifies that:
* (a) mutations still work locally (optimistic UI + enqueue)
* (b) a banner tells the user they're offline
* (c) going back online flushes the queue and the items persist server-side
*/
import { test } from '@playwright/test';
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe.skip('Offline queue + reconnect flush (pending sync module)', () => {
test('O-01: mutations while offline stay locally and show the "offline" banner', async () => {
// Given Borja is on /lists/[id] with real network
// When context.setOffline(true) + add two items
// Then both items render locally and a "sin conexión / offline" banner is visible
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
test.describe('Offline queue + reconnect flush', () => {
test('O-01: going offline shows the banner and optimistic adds stay visible', async ({
page,
context
}) => {
await loginAs(page, USERS.borja);
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
// Go offline. The SyncBanner subscribes to `navigator.onLine` via the
// isOnline store and should render within a tick.
await context.setOffline(true);
await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 });
const itemName = `O-01-${Date.now()}`;
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await input.fill(itemName);
await input.press('Enter');
// Item renders locally even though the server call failed.
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
// Cleanup: bring the page back online so the next test starts fresh.
await context.setOffline(false);
});
test('O-02: going back online flushes pending_ops and hides the banner', async () => {
// Given Borja is offline with 2 pending items in the queue
// When context.setOffline(false)
// Then within 5s: the banner disappears, both items exist on the server
// (verified via a second authenticated context that re-fetches the list),
// and pending_ops is empty (checked via a page.evaluate reading IDB).
test('O-02: going back online flushes the queue to the server', async ({
browser,
context
}) => {
const page = await context.newPage();
await loginAs(page, USERS.borja);
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
await context.setOffline(true);
await expect(page.getByTestId('sync-banner-offline')).toBeVisible({ timeout: 3_000 });
const itemName = `O-02-${Date.now()}`;
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await input.fill(itemName);
await input.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 3_000 });
// Back online — the queue's 'online' listener flushes pending ops.
await context.setOffline(false);
await expect(page.getByTestId('sync-banner-offline')).not.toBeVisible({
timeout: 5_000
});
// Verify server-side persistence with a FRESH independent context:
// reload would use the same browser state; we want to prove the row
// actually made it to the DB by loading the list from a new session.
const verifyContext = await browser.newContext();
const verifyPage = await verifyContext.newPage();
await loginAs(verifyPage, USERS.ana);
await verifyPage.goto(SEED_LIST_PATH);
await expect(verifyPage.getByText(itemName)).toBeVisible({ timeout: 15_000 });
await verifyContext.close();
});
});

View File

@@ -1,23 +1,90 @@
/**
* R-E2E series — Fase 2b.1 (dual-browser-context Realtime)
* R-E2E series — Fase 2b.1 (dual-browser-context Realtime sync)
*
* Exercises the end-to-end Realtime sync path through the actual SvelteKit app.
* Skipped until the app wires up the Realtime subscription in `/lists/[id]`.
* Each test spins up two browser contexts (Ana + Borja), each with its own
* Keycloak session, and verifies that a mutation in one context is visible
* in the other without a page reload.
*/
import { test } from '@playwright/test';
import { test, expect, type Browser } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe.skip('Realtime sync between two user sessions (pending UI)', () => {
test('R-E-01: Ana adds an item → Borja sees it without refreshing', async () => {
// Given two browser contexts: Ana and Borja, both on /lists/[id]
// When Ana adds "Milk" via her sticky-form
// Then Borja's item list shows "Milk" within 2s (no page reload)
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) {
const context = await browser.newContext();
const page = await context.newPage();
await loginAs(page, user);
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
return { context, page };
}
test.describe('Realtime sync — two sessions on the same list', () => {
test('R-E-01: Ana adds an item → Borja sees it without refreshing', async ({ browser }) => {
const ana = await loggedInListPage(browser, USERS.ana);
const borja = await loggedInListPage(browser, USERS.borja);
try {
const itemName = `R-E-01-${Date.now()}`;
// Ana adds the item via her sticky form
const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await anaInput.fill(itemName);
await anaInput.press('Enter');
await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
// Borja should see it land via Realtime, no reload
await expect(borja.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
} finally {
await ana.context.close();
await borja.context.close();
}
});
test('R-E-02: Presence avatar — Borja sees Ana\'s avatar when she joins', async () => {
// Given Borja is on /lists/[id]/session
// When Ana opens the same list in her own browser
// Then Borja's presence indicator shows Ana's avatar
// When Ana leaves
// Then Ana's avatar disappears from Borja's indicator within 3s
test('R-E-02: Ana checks an item → Borja sees it checked', async ({ browser }) => {
const ana = await loggedInListPage(browser, USERS.ana);
const borja = await loggedInListPage(browser, USERS.borja);
borja.page.on('console', (m) => {
if (m.type() === 'error') console.log(` borja [err] ${m.text()}`);
});
try {
const itemName = `R-E-02-${Date.now()}`;
const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await anaInput.fill(itemName);
await anaInput.press('Enter');
await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
await expect(borja.page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
// Ana checks the item. Wait 500ms after add to give the realtime
// echo + dedupe time to settle; otherwise the row might still have
// the temp UUID in the DOM and the button id changes under us.
await ana.page.waitForTimeout(500);
const anaRow = ana.page.locator('[role="listitem"]').filter({ hasText: itemName }).first();
await anaRow.getByRole('button', { name: /toggle item/i }).click();
// Verify Ana's own UI flipped (she checked it locally)
await expect(
ana.page
.locator('[role="listitem"]')
.filter({ hasText: itemName })
.getByRole('button', { name: /uncheck item/i })
).toBeVisible({ timeout: 5_000 });
// And Borja should see it checked too via Realtime UPDATE
await expect(
borja.page
.locator('[role="listitem"]')
.filter({ hasText: itemName })
.getByRole('button', { name: /uncheck item/i })
).toBeVisible({ timeout: 10_000 });
} finally {
await ana.context.close();
await borja.context.close();
}
});
});

View File

@@ -1,28 +1,89 @@
/**
* S-series (Modo Compra) — Fase 2b.3
*
* These tests describe the shopping-session UX contract for `/lists/[id]/session`.
* Skipped until the route exists. Unskip one by one as the UI is implemented.
* Full-screen shopping session at `/lists/[id]/session`. Large tap targets,
* flip animation moving items between TO BUY / CHECKED, and a confirmation
* modal for "Finish shopping" that marks the list completed and returns to
* `/lists`.
*/
import { test } from '@playwright/test';
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe.skip('Shopping session — full-screen mode (pending UI)', () => {
test('S-01: /lists/[id]/session renders full-screen with no sidebar', async () => {
// Given Borja is logged in and on the seed list
// When he enters /lists/[id]/session
// Then the app sidebar is hidden and the layout fills the viewport
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
const SESSION_PATH = `${SEED_LIST_PATH}/session`;
test.describe('Shopping session — full-screen mode', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('S-02: checking an item slides it into the CHECKED section with animation', async () => {
// Given at least one unchecked item
// When Borja taps its checkbox
// Then the item appears in the CHECKED section and is no longer in TO BUY
// (a `flip` animation is used but we only verify final state)
test('S-01: navigating to /lists/[id]/session shows the full-screen container', async ({
page
}) => {
await page.goto(SESSION_PATH);
await expect(page.getByTestId('shopping-session')).toBeVisible({ timeout: 15_000 });
// The finish CTA must be visible — it's the whole point of the view.
await expect(page.getByRole('button', { name: /finish shopping|terminar compra/i })).toBeVisible();
});
test('S-03: "Finish shopping" confirms → list.status = completed → redirect to /lists', async () => {
// Given a list in session with some items checked
// When Borja taps "Finish shopping" and confirms
// Then the URL returns to /lists and the list card shows the "completed" badge
test('S-02: checking an item moves it into the CHECKED section', async ({ page }) => {
// Ensure at least one unchecked item exists by creating one from the
// regular list page (simpler than seeding items in the session view).
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(/add item|añadir producto/i)).toBeVisible({
timeout: 15_000
});
const itemName = `S-02-${Date.now()}`;
await page.getByPlaceholder(/add item|añadir producto/i).fill(itemName);
await page.getByPlaceholder(/add item|añadir producto/i).press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
// Now go to the session view and check the item
await page.goto(SESSION_PATH);
await expect(page.getByTestId('shopping-session')).toBeVisible({ timeout: 15_000 });
const row = page.locator('[role="listitem"]').filter({ hasText: itemName });
await row.getByRole('button', { name: /toggle item/i }).click();
// The row's toggle now has aria-label "Uncheck item" (in CHECKED section)
await expect(
page.locator('[role="listitem"]').filter({ hasText: itemName }).getByRole('button', {
name: /uncheck item/i
})
).toBeVisible({ timeout: 5_000 });
});
test('S-03: Finish shopping → confirm → list is completed and we return to /lists', async ({
page
}) => {
// Need a fresh list so completing it doesn't affect shared seed state.
// Create via the lists overview.
await page.goto('/lists');
const listInput = page.getByPlaceholder(/weekly shop|compra semanal/i);
await expect(listInput).toBeVisible({ timeout: 15_000 });
const listName = `S-03-list-${Date.now()}`;
await listInput.fill(listName);
await listInput.press('Enter');
// Wait for the new list's heading so we can click into it
const heading = page.getByRole('heading', { name: new RegExp(`^${listName}$`) });
await expect(heading).toBeVisible({ timeout: 5_000 });
// Navigate via the heading (linked to /lists/[id])
await heading.click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 5_000 });
// Open session mode
await page.getByTestId('start-session').click();
await expect(page.getByTestId('shopping-session')).toBeVisible({ timeout: 15_000 });
// Finish shopping → confirm
await page.getByRole('button', { name: /finish shopping|terminar compra/i }).click();
await page.getByRole('button', { name: /yes, finish|sí, terminar/i }).click();
// Redirected back to /lists, and the completed list is not in the
// active grid anymore (it moves to "Completed").
await expect(page).toHaveURL(/\/lists\/?$/, { timeout: 10_000 });
});
});

26
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* Vitest config for unit tests in apps/web.
*
* Uses jsdom so IndexedDB shim (fake-indexeddb) works, and loads the Svelte
* plugin so we can import .svelte files from `$lib` if needed. Playwright E2E
* lives alongside in `tests/e2e/*` and is driven by playwright.config.ts —
* Vitest is told to ignore those paths.
*/
export default defineConfig({
plugins: [svelte()],
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.ts'],
exclude: ['tests/**', 'node_modules/**', '.svelte-kit/**'],
setupFiles: ['./vitest.setup.ts']
},
resolve: {
alias: {
$lib: new URL('./src/lib', import.meta.url).pathname
}
}
});

3
apps/web/vitest.setup.ts Normal file
View File

@@ -0,0 +1,3 @@
// Polyfill IndexedDB for tests. Node's jsdom env doesn't ship with IDB.
// Imported at file top so the shim is in place before any test code runs.
import 'fake-indexeddb/auto';