Files
collective-lists/CLAUDE.md
Oier Bravo Urtasun 5803806295 docs(fase-5.15): title chrome cleanup on detail views
Recorded in plan/fase-5-mobile-ux.md (new §5.15 right next to the
existing 5.13 / 5.14 sections) and CLAUDE.md "Fase 5 — what has been
built" with the list.status → visual signal table.

Code change landed in 73bc51e.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 03:25:28 +02:00

35 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Code Language

All code must be written in English. This applies to variable names, function names, type names, database column names, SQL identifiers, comments, and any other code artifact. The UI may display text in any language, but the code layer is always English.

Project Status

Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). Fase 3 complete (Tareas + Notas). Fase 4 partially complete (global search + RLS audit). Fase 5 complete for the automatable scope (mobile UX: responsive shell + masthead + row redesign + selection mode + insecure-context resilience; see plan/fase-5-mobile-ux.md). 233 tests green: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 44 Playwright. 2 skipped (Realtime presence — upstream handle_out/3 bug). PWA install/push/rate-limit/JWT rotation deferred to a prod-deploy sprint.

  • README.md — full development plan, confirmed tech stack, Justfile reference, and technical warnings
  • analysis/analisis-funcional.md — complete functional specification (domain model, use cases, data models, business rules)
  • plan/fase-*.md — phase-by-phase implementation plans (Fase 0 through Fase 5)
  • design/slate_collective/DESIGN.md + design/*/screen.png — "Monolith Editorial" direction + per-screen Stitch mockups (desktop + mobile variants). Consult before changing UI.

Fase 1 — what has been built

  • supabase/migrations/001_users.sqlusers table, handle_new_user trigger
  • supabase/migrations/002_collectives.sqlcollectives, collective_members, collective_invitations; auto-promote-oldest-admin trigger
  • supabase/migrations/003_rls.sql — full RLS policies, is_active_member/is_member/is_admin helpers, accept_invitation() function
  • supabase/migrations/004_storage.sqlavatars bucket (private, signed URLs)
  • apps/web/src/lib/supabase.ts — Supabase singleton with PKCE flow
  • apps/web/src/lib/auth.tslogin() / logout() via supabase.auth.signInWithOAuth
  • apps/web/src/lib/stores/auth.tscurrentUser, authLoading, isAuthenticated, displayName
  • apps/web/src/lib/stores/collective.tscurrentCollective, userCollectives, collectiveMembers
  • apps/web/src/lib/components/Avatar.svelte — initials / emoji / upload with deterministic colour hash
  • apps/web/src/lib/components/ImageCropper.svelte — lazy-loaded cropperjs, 1:1, 256×256 WebP output
  • apps/web/src/routes/+layout.svelte — auth state listener, collective bootstrap
  • apps/web/src/routes/(app)/+layout.ts — SSR disabled (ssr: false); no auth check here (see gotcha below)
  • apps/web/src/routes/(app)/+layout.svelte — sidebar with collective switcher; auth redirect via $effect
  • apps/web/src/routes/auth/callback/+page.svelte — PKCE code exchange
  • apps/web/src/routes/onboarding/+page.svelte — create collective or join via link
  • apps/web/src/routes/(app)/collective/manage/+page.svelte — members, roles, invite link generator
  • apps/web/src/routes/invitation/[token]/+page.svelte — accept invitation (auth-aware)
  • apps/web/src/routes/(app)/settings/+page.svelte — display name, avatar, language, sign out
  • apps/web/messages/en.json + es.json — all Fase 1 + 2a strings
  • packages/types/src/database.ts — manually seeded from migrations (update with just db-types once CLI is wired)
  • setup.sh — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
  • infra/kong-start.sh — Kong container entrypoint; substitutes ${VAR} placeholders in kong.yml before Kong starts (Kong 2.8 does not do this natively)

Test suite — what has been built

  • packages/test-utils/ — shared test infrastructure (Vitest integration tests)
    • package.json@colectivo/test-utils; depends on @supabase/supabase-js, jose, pg
    • vitest.config.ts — loads root .env via loadEnv; single-fork mode (no parallelism races)
    • src/seed-constants.ts — fixed UUIDs for all 5 seed users + collective + seed list
    • src/supabase-clients.tscreateClientAs(userId) signs HS256 JWT with SUPABASE_JWT_SECRET; createAdminClient() uses service role key
    • src/db-helpers.tssql() via raw pg Pool for direct DB manipulation (bypasses RLS); used to set deleted_at = 8 days ago etc.
    • tests/rls-isolation.test.ts — F-series: Eva (non-member) sees zero rows, all writes blocked
    • tests/rls-collective.test.ts — B-series: collective read/update/invite by role
    • tests/rls-lists.test.ts — C-series: list CRUD, soft-delete, trash window, cross-collective isolation
    • tests/rls-items.test.ts — D-series: item CRUD by role, cross-collective item isolation
    • tests/rls-frequency.test.ts — E-series: frequency read-only + trigger populates on INSERT
  • supabase/tests/*.sql each start with CREATE EXTENSION IF NOT EXISTS pgtap; so they're idempotent on a fresh DB
    • 001_accept_invitation.sqlaccept_invitation() happy path + not_found / expired / already_used; uses set_config('request.jwt.claim.sub', …) to set auth.uid() for the test session, and looks up by token (not id)
    • 002_item_frequency_trigger.sql — trigger normalizes name with lower(trim(...)) and increments use_count on repeat INSERTs. RLS write-blocks are covered by the Vitest suite instead (postgres superuser bypasses RLS here)
    • 003_promote_on_admin_leave.sql — deletes a synthetic admin from public.users to fire the BEFORE DELETE trigger and asserts the oldest remaining member is promoted
  • apps/web/playwright.config.ts — Playwright config; globalSetup is a Keycloak health check (not a session cache), reuseExistingServer: true
  • apps/web/tests/fixtures/users.ts — seed user constants (Ana, Borja, Carmen, David, Eva)
  • apps/web/tests/fixtures/login.tsloginAs(page, user) runs the real OAuth flow (Keycloak → GoTrue PKCE) and waits for the session token to land in localStorage. Each test that needs auth calls this in beforeEach; cached storageState is NOT used (see gotcha #12)
  • apps/web/tests/e2e/auth.test.ts — A-series: unauthenticated redirect, full login, collective visible in sidebar, sign-out, guest login
  • apps/web/tests/e2e/lists.test.ts — C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-only
  • apps/web/tests/e2e/items.test.ts — D-series: seed list renders, add item (with reload-persistence check), toggle check, desktop-hover delete, frequency suggestions, guest read-only

Test commands:

just test-all            # run every suite (pgTAP + Vitest + Playwright) — requires just dev running
just test-integration    # Vitest RLS tests (requires just dev stack running)
just test-db             # pgTAP SQL tests (requires just dev stack running)
just test-e2e            # Playwright E2E tests (requires just dev running)
just test-e2e-headed     # Playwright with visible browser (debugging)

Fase 5 — what has been built (complete in scope)

5.8 big-button create (match /notes)

  • /lists sticky bottom create bar removed; masthead gains a primary "New list" button next to the Trash ghost icon
  • handleCreate creates with empty name + goto(/lists/[id]); detail view shows a prominent editable title input (2632px) with 500 ms autosave (renameList)
  • Matches /notes pattern end-to-end

5.10 row redesign (button-row + gesture toggle + overlay + drag handles)

  • Shopping items have no checkbox in normal mode. Row is drag-handle | name-button | qty stepper. Checked items render strikethrough + muted.
  • Swipe right on unchecked → mark checked (green reveal), swipe left on checked → uncheck (neutral reveal). Opposite direction is a no-op snap-back. SWIPE_COMMIT_THRESHOLD=120, SWIPE_REVEAL_WIDTH=96. No undo toast — the gesture is symmetric and the inverse swipe reverses.
  • Double-tap opens an overlay (Dialog / bottom sheet) with: name input + Delete (red) + Confirm + X close. 300 ms tap-window. Delete still routes through scheduleUndoableUndoToast.
  • Drag handle on the left, always visible on mobile, md:opacity-0 md:group-hover:opacity-100 on desktop. Handle's onpointerdown flips a dragEnabled flag so dnd only kicks in from the handle, leaving row swipes untouched.
  • unit column dropped (migration 011_drop_shopping_items_unit.sql). The double-tap overlay only edits name, so an unreachable unit would be stranded data. Users encode units inside the name (e.g. "Milk 1L"); seed updated accordingly.

5.11 selection mode + bulk actions

  • Entry: long-press 500 ms on mobile or data-testid="selection-toggle" button in the desktop header. Preselects the row you pressed (mobile).
  • Inside selection mode: single-tap toggles selection, swipe + double-tap disabled, stepper + drag handle hidden. Row gains a checkbox on the left.
  • Chrome: header turns into data-testid="selection-bar" (dark slate-900 with "N selected" + Cancel + desktop-inline actions). Mobile bottom action bar (data-testid="selection-action-bar") above BottomTabBar with Delete / Move / Mark-checked.
  • Bulk actions (one Supabase call each): bulkDeleteItems + single UndoToast with batch snapshot, bulkMoveItems via a list picker sheet (data-testid="move-picker") listing the collective's other active lists + a "Create new list" row, bulkCheckItems filters is_checked=false so it only marks, never toggles.

5.12 detail-view chrome (hide MobileTopBar, sticky contextual bars)

  • On /lists/[id], /tasks/[id], /notes/[id], /lists/[id]/session the global MobileTopBar is hidden via an isDetailRoute regex check in (app)/+layout.svelte. The route's own contextual header (back + title + actions) becomes the only top chrome and is now sticky top-0 z-30 bg-surface/80 backdrop-blur-xl.
  • BottomTabBar stays visible so users can hop between sections.

5.15 detail-view title chrome cleanup

  • /lists/[id] and /tasks/[id] dropped the uppercase eyebrow label ("ACTIVE LIST" / "TASK MANAGER") that used to sit above the editable title. Title moved up into that space.
  • /lists/[id] status signal replaces the hardcoded "ACTIVE LIST": completed → title with text-text-secondary line-through; archived → muted pill next to title (data-testid="list-status-pill"); active → plain title.
  • /tasks/[id] has no status on task_lists; just the label drop.

5.14 inline add-form (last element of the list)

  • /lists/[id] + /tasks/[id] no longer have a bottom-sticky add form. The form renders inline as the last child of the unchecked / pending section (just above Checked / Completed). Empty-state branch shows message + form beneath it.
  • On /lists/[id] the frequency chips (ItemSuggestions) moved below the input (input row on top, chips on second line). Mobile: horizontal scroll; desktop: wrap.
  • Hides entirely while selectionMode is active.
  • Svelte 5 snippets addItemForm / addTaskForm so the form is rendered in both the empty-state branch and the between-sections slot without duplication.
  • data-testid="add-item-form" / "add-task-form" hooks added for future placement assertions.

Fase 5 — earlier sub-phases (still relevant)

  • apps/web/src/lib/components/layout/DesktopSidebar.svelte — desktop sidebar extracted from (app)/+layout.svelte, data-testid="desktop-sidebar", hidden md:flex
  • apps/web/src/lib/components/layout/MobileTopBar.svelte — hamburger + collective name + avatar link; md:hidden sticky top-0 z-30 backdrop-blur-xl
  • apps/web/src/lib/components/layout/BottomTabBar.svelte — 4 nav items icon-only, glassmorphism, pb-[env(safe-area-inset-bottom)], aria-current="page" on the active route, data-testid="bottom-tab-bar"
  • apps/web/src/lib/components/layout/MobileDrawer.svelte — backdrop + collective switcher + manage/settings/logout, data-testid="mobile-drawer"
  • apps/web/src/lib/components/ScreenMasthead.svelte — uppercase label (data-testid="masthead-label") + big title (data-testid="masthead-title") + optional meta / actions snippets. Applied on /lists, /tasks, /notes, /search.
  • apps/web/src/lib/sync/undoQueue.tsscheduleUndoable({ label, restore, commit, ttlMs? }) with 4-s TTL; auto-commits on timeout, undo(id) cancels + restores
  • apps/web/src/lib/components/UndoToast.svelte — rendered globally in (app)/+layout.svelte, shows latest undoable, offset above bottom tab bar on mobile
  • /lists/[id] swipe-delete: SWIPE_MAX=96, SWIPE_THRESHOLD=48 (latch-open), SWIPE_COMMIT_THRESHOLD=200 (full-swipe commit via scheduleUndoable)
  • apps/web/src/lib/components/ItemSuggestions.svelte — mobile: horizontal scroll with [scrollbar-width:none]; desktop: wraps
  • (app)/+layout.sveltecritical fix: outer container now flex flex-col md:flex-row (was flex only). Without this MobileTopBar and main rendered as horizontal siblings on mobile and the masthead was pushed off-screen.
  • Mobile tests: mobile-shell.test.ts (M-01..M-04), mobile-masthead.test.ts (M-05 × 4 routes), mobile-swipe-delete.test.ts (M-06/M-07 .skip — Chromium touch emulation too flaky), undoQueue.test.ts (5 unit tests)

Fase 4 — what has been built

  • supabase/migrations/010_search_vectors.sql — STORED GENERATED search tsvector columns on shopping_items/tasks/notes + GIN indexes + search_result_type enum + search_in_collective(p_collective_id, p_query, p_types, p_creator, p_from, p_to) SECURITY INVOKER function (RLS-aware, trash-excluded) + GRANT EXECUTE to anon/authenticated. Config is simple (no stemmer — bilingual en/es).
  • apps/web/src/lib/stores/search.ts — RPC wrapper with SearchRow + SearchFilters types
  • /search route — debounced input (300ms, ≥2 chars), filter chips (Lists/Tasks/Notes; all-active = no filter sent), results grouped by type with data-testid="search-group-<type>", deep-link to the right route per result type
  • apps/web/messages/{en,es}.json — search strings (search_filter_*, search_group_*, search_empty, search_hint)
  • pgTAP 007_search_tsvector.sql (9 tests: columns, GIN indexes, function signature, generated-vector invariant)
  • Vitest search.test.ts (10 tests across S-01..S-04) + rls-audit.test.ts (42 tests: 3 intruders × 14 cross-collective ops — proves zero leakage across every domain table)
  • Playwright search.test.ts (2 tests: SR-01 happy-path, SR-02 filter deactivation)

Fase 3 — what has been built

  • supabase/migrations/008_tasks.sqltask_lists, tasks with completion-consistency CHECK (is_completed ⇔ completed_by ⇔ completed_at), helper task_list_collective_id(), RLS by role
  • supabase/migrations/009_notes.sqlnotes with note_color enum (8 values), is_pinned, is_archived (mutually exclusive via CHECK), deleted_at (7d trash window in RLS), fn_notes_touch trigger refreshing updated_at/updated_by, pg_cron job purge-deleted-notes at 03:10
  • apps/web/src/lib/stores/tasks.ts — task lists CRUD (optimistic) + task ops (addTask, completeTask, renameTask, deleteTask, reorderTasks)
  • apps/web/src/lib/stores/notes.tsloadNotes/loadArchivedNotes/loadTrashedNotes, createNote, patchNote (always sets updated_by), pinNote/unpinNote, archiveNote/unarchiveNote, trashNote/restoreNote, permanentDeleteNote, duplicateNote, NOTE_COLORS
  • /tasks (overview) + /tasks/[id] (detail with dndzone reorder + flip animation; lazy-loads collective_members for "Completed by …" attribution; 5s polling per analysis §6)
  • /notes (board with pinned section testid notes-pinned) + /notes/[id] (editor: title input + textarea, 500ms debounced autosave, color picker, pin/archive/duplicate/trash) + /notes/archive + /notes/trash (30s polling on board)
  • apps/web/messages/{en,es}.json — full Tareas + Notas string set (tasks_*, notes_*)
  • pgTAP 005_tasks_rls.sql (5 tests on the completion CHECK), 006_notes_trash_purge.sql (4 tests: inline-runs the purge SQL + verifies pin/archive CHECK)
  • Vitest rls-tasks.test.ts (14 tests) + rls-notes.test.ts (15 tests)
  • Playwright tasks.test.ts (3 tests) + notes.test.ts (4 tests)

Fase 2b — what has been built

  • supabase/migrations/007_realtime_publication.sql — adds shopping_items + shopping_lists to supabase_realtime publication with REPLICA IDENTITY FULL
  • apps/web/src/lib/stores/realtimeSync.tssubscribeToList(listId, onEvent) postgres_changes subscription helper; applyItemEvent reducer for INSERT/UPDATE/DELETE
  • /lists/[id]/+page.svelte — wired to realtimeSync with pendingTempIds echo-deduplication. Mutations use client-generated UUIDs so offline retries are idempotent (PK-conflict = success)
  • apps/web/src/lib/sync/queue.tsSyncQueue class (IDB-backed pending_ops, FIFO flush, MAX_ATTEMPTS retry, PK-conflict-as-success)
  • apps/web/src/lib/sync/index.ts — app-wide SyncQueue singleton; enqueueOp, hydrateSyncState, auto-flush on window.online
  • apps/web/src/lib/stores/syncStatus.ts — derived store: offline | syncing | synced from navigator.onLine + pendingOpsCount
  • apps/web/src/lib/components/SyncBanner.svelte — renders the offline/syncing indicator; mounted in list detail + session views
  • apps/web/src/routes/(app)/lists/[id]/session/+page.svelte — Modo Compra full-screen view (fixed positioning overlays the sidebar), 56px toggles, flip animation, confirm modal, completeList → redirect
  • apps/web/messages/*.jsonsync_offline, sync_syncing, list_start_session
  • packages/test-utils/src/realtime-helpers.tssubscribePostgresChanges(client, opts) with waitFor(predicate, ms) — awaits SUBSCRIBED handshake, dedups events, leaks nothing between tests
  • Vitest unit harness in apps/web/vitest.config.ts (jsdom env) + vitest.setup.ts (fake-indexeddb/auto) + pnpm --filter @colectivo/web test:unit
  • Realtime config gotchas documented in project_realtime_config memory: tenant name, DB_USER=supabase_admin, SEED_SELF_HOST: "true", pre-created realtime schema, publication + REPLICA IDENTITY FULL
  • Realtime waitFor timeout is 10s, not 5s. Under full just test-all load the Phoenix socket occasionally takes several seconds to propagate a fresh subscription's filter before the test mutation fires. Set in packages/test-utils/src/realtime-helpers.ts. Don't drop below 10s — passing runs resolve on event arrival, only the flake window widened.
  • Never call crypto.randomUUID() directly — use generateId() from $lib/utils/id. crypto.randomUUID is gated behind secure contexts (HTTPS / localhost / 127.0.0.1 / file://). When the app is served over a bare LAN IP for on-device phone previews (http://192.168.1.167:5173), window.crypto.randomUUID is undefined and every optimistic-id site crashes handleAdd with TypeError: crypto.randomUUID is not a function. generateId() uses crypto.randomUUID() when available and falls back to an RFC 4122 v4 assembly via crypto.getRandomValues() (which IS exposed on insecure contexts). Covered by src/lib/utils/id.test.ts (U-01..U-04) + tests/e2e/insecure-context.test.ts (INS-01, stubs randomUUID = undefined via addInitScript and drives the add-item flow).
  • Playwright baseURL is hardcoded to http://localhost:5173, ignoring PUBLIC_APP_URL. The LAN-IP variant in root .env is for on-device phone previews and introduces timing races in the OAuth round-trip that produce flaky failures in the suite. Keep the loopback for deterministic E2E; phone previews are documented below.

Fase 2a — what has been built

  • supabase/migrations/005_shopping.sqlshopping_lists, shopping_items, RLS, list_collective_id() helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
  • supabase/migrations/006_item_frequency.sqlitem_frequency table, fn_record_item_frequency trigger (SECURITY DEFINER, fires on every shopping_items INSERT)
  • apps/web/src/lib/stores/lists.ts — shopping lists store (optimistic CRUD) + item operations + suggestion fetching
  • apps/web/src/lib/components/ItemSuggestions.svelte — frequency chips with active-prefix highlighting
  • apps/web/src/routes/(app)/lists/+page.svelte — overview: featured card + 2-col grid + completed section + trash view (sticky input replaced by masthead "New list" button in Fase 5.8)
  • apps/web/src/routes/(app)/lists/[id]/+page.svelte — list detail: items with checkbox, qty stepper (/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via svelte-dnd-action, sticky add form with ItemSuggestions

Local Dev: Required /etc/hosts Entry

GoTrue (Supabase Auth) and the browser both must resolve keycloak to reach the Keycloak container. Add this line to /etc/hosts on the development machine:

127.0.0.1 keycloak

Without this, the OAuth redirect URL (http://keycloak:8080/...) built by Keycloak's discovery document is unreachable from the browser.

Planned Stack

Layer Technology
Frontend / PWA SvelteKit 2 + @vite-pwa/sveltekit + Workbox
Auth / IdP Keycloak self-hosted (OIDC/OAuth2)
Backend / BaaS Supabase self-hosted (GoTrue + Realtime + Storage + Kong)
Database PostgreSQL 15 (via Supabase)
Offline storage IndexedDB via idb
Monorepo Turborepo + pnpm workspaces
Infra Docker Compose (dev + prod) + nginx (prod, managed externally)
CI/CD GitHub Actions

Planned Repository Layout

apps/web/src/lib/
  supabase.ts          # Supabase singleton client (PKCE, GoTrue auth)
  auth.ts              # login() / logout() via supabase.auth.signInWithOAuth (Keycloak provider)
  stores/              # Svelte global stores
  sync/                # offline queue + conflict resolution
  components/          # reusable UI components
apps/web/src/routes/   # SvelteKit file-based routes
packages/types/src/
  database.ts          # types generated from Supabase schema (never edit manually)
  domain.ts            # domain types (Collective, ShoppingList, Item, etc.)
supabase/
  migrations/          # versioned SQL migrations
  seed.sql             # dev test data (5 Keycloak users + sample collective)
  functions/           # Edge Functions (invitations, etc.)
  config.toml          # Supabase CLI config + Keycloak OIDC
keycloak/
  realm-export.json    # "colectivo" realm — imported on dev startup
infra/
  docker-compose.dev.yml
  docker-compose.prod.yml
  scripts/backup.sh / backup-cron.sh / restore.sh / deploy.sh

Development Commands (Justfile)

just setup            # first-time (and idempotent) local environment setup
just dev              # start docker-compose.dev.yml + SvelteKit dev server
just serve            # build + start prod Node server at :3000 against dev Docker stack
just stop             # stop all services (Docker stack + Vite dev server)
just dev-stop         # stop Docker stack only
just dev-clean        # stop Docker stack and remove all volumes (full reset)
just db-reset         # drop + migrate + seed (dev)
just db-migrate       # apply pending migrations (dev)
just db-seed          # apply supabase/seed.sql to the running dev db
just db-types         # regenerate TS types from Supabase schema → packages/types
just kc-export        # export Keycloak realm → keycloak/realm-export.json
just kc-open          # open Keycloak Admin Console (localhost:8080)
just deploy           # run infra/scripts/deploy.sh (prod)
just backup supabase           # immediate manual backup
just restore supabase <file>   # restore a specific dump
just logs             # docker compose logs -f (prod)

Local Dev Endpoints and Credentials

Service URL Credentials (dev only)
SvelteKit app http://localhost:5173
Supabase Studio http://localhost:54323
Supabase API (Kong) http://localhost:8001 apikey: PUBLIC_SUPABASE_ANON_KEY from .env
Keycloak Admin http://localhost:8080/admin admin / admin
PostgreSQL localhost:5432 postgres / postgres

Dev secrets (set in .env, never commit to prod):

Variable Dev value
POSTGRES_PASSWORD postgres
SUPABASE_JWT_SECRET super-secret-jwt-token-with-at-least-32-characters-long
KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD admin / admin
KEYCLOAK_CLIENT_SECRET gotrue-dev-secret (client secret for colectivo-web in GoTrue)

Domain Model

The central organizing unit is the Collective (household group), not the individual user. All content belongs to the Collective.

Roles: admin (full CRUD + member management) | member (full CRUD on content) | guest (read-only)

Core entities: CollectiveCollectiveMember, ShoppingListShoppingItem, TaskListTask, Note, CollectiveInvitation

Key field names (English): collective_id, list_id, is_checked, checked_by, checked_at, created_by, created_at, completed_at, sort_order, display_name, avatar_type, avatar_emoji, avatar_url, language

Key business rules:

  • Only one active shopping session per list at a time.
  • Completing a list keeps items as history; it can be "reset" (uncheck all → back to active).
  • Trash retains deleted items/notes for 7 days before permanent deletion.
  • If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.

Auth Architecture (Keycloak → GoTrue → Supabase)

Strategy: Option B — GoTrue as OIDC proxy. The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses auth.uid() which resolves to the GoTrue user UUID (mapped from Keycloak sub). GoTrue maintains auth.users.

Key decisions:

  • Self-registration enabled in Keycloak — users can create accounts on the Keycloak login screen.
  • Invitations are link-only — no email sent. Admin generates a link and shares it manually.
  • Multiple collectives per user — a user can belong to several collectives and switch between them in the sidebar.

Logout is a single call — supabase.auth.signOut() — which clears the GoTrue session. Keycloak's own SSO session persists (user won't be prompted for credentials on next login until the Keycloak session expires), which is acceptable for this app.

Non-obvious integration requirements (all implemented; do not remove):

  1. colectivo-web is a confidential client — GoTrue needs a client secret to exchange codes with Keycloak. GOTRUE_EXTERNAL_KEYCLOAK_SECRET must match Keycloak's client secret (gotrue-dev-secret in dev).

  2. Custom openid client scope in Keycloak — GoTrue v2.158.1 sends scope=profile email (no openid) to Keycloak, ignoring GOTRUE_EXTERNAL_KEYCLOAK_SCOPES. Without openid in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named openid with include.in.token.scope=true is set as a default scope on colectivo-web, so Keycloak injects openid even when not requested.

  3. GOTRUE_DISABLE_SIGNUP: "false" — GoTrue's signup flag blocks OIDC-based user creation too. Must be false; Keycloak is the gatekeeper for who can register.

  4. auth.users pre-seeded with Keycloak UUIDs — GoTrue generates its own UUIDs on first login. seed.sql pre-inserts both auth.users (with instance_id='00000000-0000-0000-0000-000000000000' and empty-string token fields) and auth.identities (provider=keycloak, provider_id=Keycloak sub). This ensures auth.uid() = seed UUID = FK in all public tables.

  5. Kong env var substitution via kong-start.sh — Kong 2.8 does not interpolate ${VAR} in declarative config files. infra/kong-start.sh runs perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge' on kong.yml before starting Kong. Without this, Kong loads the literal string ${SUPABASE_ANON_KEY} as the API key, rejecting all requests.

SvelteKit + Supabase auth gotchas (do not remove these patterns):

  1. Do not call getSession() in a SvelteKit load function to gate auth. Supabase JS v2 initialises its session from localStorage asynchronously (_recoverAndRefresh). In a load function that runs immediately on mount, getSession() can return null for a valid session before initialisation completes — causing login() to fire and redirect to Keycloak unnecessarily. The correct pattern: (app)/+layout.ts only sets ssr: false, and (app)/+layout.svelte uses a $effect that redirects when !$authLoading && !$isAuthenticated. This waits for onAuthStateChange to fire (which is authoritative).

  2. onAuthStateChange fires INITIAL_SESSION on page load, not SIGNED_IN. SIGNED_IN only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root +layout.svelte: if (session) { await loadUserCollectives(...) } covering INITIAL_SESSION, SIGNED_IN, and TOKEN_REFRESHED.

  3. CSS custom properties use RGB triplets — always use rgb(), never hsl(). All design tokens in app.css store values as space-separated RGB triplets (e.g. 51 65 85). Using hsl(var(--token)) interprets the first value as a hue degree — hsl(51 65% 85%) renders as light yellow, not slate-700. The Tailwind config and any background-color/color declarations in app.css must use rgb(var(--token) / <alpha>).

  4. Supabase JS v2 + SvelteKit: loadUserCollectives (or any PostgREST query) inside onAuthStateChange must be deferred with setTimeout(..., 0). GoTrue's default navigator.locks-based auth lock is held while the onAuthStateChange callback runs. Any supabase.from(...).select(...) called inside that callback calls getAccessToken() → getSession() → initializePromise → _acquireLock, which is the same lock that's already held by the emit logic — the promise never resolves, the query hangs forever. Fix: schedule the query off the callback's microtask chain with setTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0). As a defensive extra, $lib/supabase passes a pass-through auth.lock option so the whole lock path is a no-op. Both are needed: without the defer, some browsers still deadlock; without the pass-through, others do. Removing either one breaks the Playwright E2E suite.

  5. Playwright auth: do not cache Supabase sessions via storageState. Do a live Keycloak login per test. browser.newContext({ storageState }) restores localStorage + cookies, but Supabase's _recoverAndRefresh does not reliably re-fire INITIAL_SESSION from a restored context — the page hydrates without triggering onAuthStateChange, so currentUser / currentCollective stay empty. The working pattern is await loginAs(page, USERS.ana) at the top of each test (see tests/fixtures/login.ts), which exercises the real OAuth flow and waits for the session token to land in localStorage before returning. Adds ~1s per test but is deterministic.

  6. PWA service worker: do not use injectManifest with a file named service-worker.ts. SvelteKit intercepts any file at src/service-worker.[jt]s and enforces a hard restriction: only $service-worker and $env/static/public may be imported — Workbox modules are blocked. With injectManifest, @vite-pwa/sveltekit tries to find the compiled SW output at .svelte-kit/output/client/service-worker.js, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 12a: use generateSW strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file src/sw.ts — SvelteKit does not recognise it as a service worker — and set strategies: 'injectManifest', filename: 'sw.ts' in vite.config.ts.

Sync Strategy

Content Mechanism Target latency
Items in active shopping session WebSocket (Supabase Realtime) < 1 second
Lists, tasks (outside session) SSE / polling < 5 seconds
Notes Polling < 30 seconds

Offline-first: all operations execute locally first (IndexedDB), then sync in background. Conflict resolution is last-write-wins. Conflicts are logged to a sync_conflicts table for debugging — no CRDT in MVP.

Critical Platform Gotchas

iOS Safari / PWA:

  • The app uses Supabase OAuth PKCE flow (signInWithOAuth({ provider: 'keycloak' })). No keycloak-js, no iframe-based silent refresh — Safari-compatible by design.
  • GoTrue session is persisted in localStorage and auto-refreshed by the Supabase client. No manual updateToken() needed.
  • Background Sync API is not supported in Safari. Implement a manual fallback using the online event.
  • Push notifications require iOS 16.4+ and the PWA must be installed to the home screen.
  • Test the shopping session mode on a real iPhone during development, not only in DevTools.

nginx (prod) — WebSocket for Realtime:

  • The /realtime/ location block must come before the / block.
  • proxy_read_timeout 3600s is required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions.
  • CSP connect-src must include both https:// and wss:// for the API domain.

Keycloak behind nginx:

  • X-Forwarded-Host and X-Forwarded-Port headers are mandatory. Without them, Keycloak builds redirect URIs with the internal port (8080) instead of 443, breaking the OIDC flow.

Supabase Realtime self-hosted:

  • Check MAX_REPLICATION_SLOTS in PostgreSQL and REALTIME_MAX_CONNECTIONS before going to production.
  • Presence subscriptions are more resource-intensive than Postgres Changes — limit them to lists with an active session.

Dev Test Users

Defined in keycloak/realm-export.json and supabase/seed.sql. All use password test1234.

User Email Role in test collective
ana ana@dev.local Admin
borja borja@dev.local Member
carmen carmen@dev.local Member
david david@dev.local Guest
eva eva@dev.local (no collective — for onboarding testing)

Internationalisation

Library: Paraglide JS (@inlang/paraglide-sveltekit). Compile-time, zero runtime overhead, tree-shaken per language.

  • Message files: messages/en.json (default) and messages/es.json
  • Language detection order: users.language (if logged in) → Accept-Language header → en
  • Language switching: update users.language in Supabase + call setLanguageTag() — no page reload
  • Never hardcode visible user-facing strings in components. All UI text goes through Paraglide messages.

Supported languages: en, es.

TypeScript Types

packages/types/src/database.ts is generated by just db-types (supabase gen types typescript). Never edit it manually. Domain-level types go in packages/types/src/domain.ts.