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,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 });
});
});