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,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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user