Adds a new "+ New collective" entry at the bottom of the sidebar
collective-switcher dropdown (and the mobile drawer for parity).
Clicking it opens a CreateCollectiveModal that reuses the same
onboarding form — name + emoji grid — and calls the existing
create_collective() RPC (migration 012). On success, the new
collective is appended to $userCollectives, set active in
$currentCollective, persisted to localStorage, and the user is sent
to /lists.
Side effects:
- Switcher now opens even with a single collective (previously gated
on >1) so the entry is always reachable; the chevron is always
shown.
- DesktopSidebar gets a data-testid on the switcher button so the
Playwright suite can target it without text matching.
O-04 (rescued from Fase 7): Eva creates her first collective via
onboarding, then opens the switcher and creates a second one without
leaving /lists; both collectives end up in the switcher and the DB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.
- Migration 016 adds public.sync_conflicts (append-only audit log of
last-write-wins conflicts surfaced by the offline mutation queue).
RLS allows owners to SELECT + INSERT their own rows only; UPDATE
and DELETE have no policy and are silently denied. collective_id
is intentionally nullable so user-level conflicts (theme, language
in future) also fit. Indexed on (user_id, created_at desc) for the
"show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
an optional `preImage` field on UpdateOp. Before sending an UPDATE
the queue fetches the remote row (limited to the patched fields),
proceeds with the UPDATE (last-write-wins), then fires a best-effort
INSERT into sync_conflicts when the remote diverged. Failure to log
never blocks the UPDATE or pollutes the pending_ops queue. Three
unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
$effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
for the current user with an "Export JSON" button. Gated on
import.meta.env.DEV (or a forceShow prop for a future secret
unlock) — never ships in the production bundle. Slotted into
/settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
with the existing seed users (Ana, Borja). Requires fake-indexeddb in
the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the dark-mode plumbing:
- New $lib/theme module: themePreference + resolvedTheme stores plus
initTheme() / setThemePreference(). Owns localStorage persistence,
<html data-theme=...> mirroring, and the matchMedia listener that
re-resolves "system" when the OS preference flips. Storage-agnostic
(no Supabase coupling) so the caller persists to public.users.theme
separately.
- Inline anti-FOUC script in app.html reads localStorage + matchMedia
synchronously and writes data-theme before the first paint — refresh
in dark mode no longer flashes white.
- Tailwind switched to darkMode: ['selector', '[data-theme="dark"]']
so all existing `dark:` utilities resolve from the same attribute.
- :root and :root[data-theme=light] share the light token set; dark
tokens move into :root[data-theme=dark]. Tokens stay as RGB triplets
(rgb(var(--token) / <alpha>)) per the repo gotcha — no hsl().
- ThemeToggle.svelte: three-way radiogroup (Light / Dark / System) with
paraglide messages in EN + ES.
Tests (TDD): 8 new unit specs in src/lib/theme.test.ts cover defaults,
localStorage round-trip, data-theme reflection, system resolution
against matchMedia, invalid-value fallback, and OS preference change
propagation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>