# 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.sql` — `users` table, `handle_new_user` trigger
- `supabase/migrations/002_collectives.sql` — `collectives`, `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.sql` — `avatars` bucket (private, signed URLs)
- `apps/web/src/lib/supabase.ts` — Supabase singleton with PKCE flow
- `apps/web/src/lib/auth.ts` — `login()` / `logout()` via `supabase.auth.signInWithOAuth`
- `apps/web/src/lib/stores/auth.ts` — `currentUser`, `authLoading`, `isAuthenticated`, `displayName`
- `apps/web/src/lib/stores/collective.ts` — `currentCollective`, `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.ts` — `createClientAs(userId)` signs HS256 JWT with `SUPABASE_JWT_SECRET`; `createAdminClient()` uses service role key
  - `src/db-helpers.ts` — `sql()` 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.sql` — `accept_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.ts` — `loginAs(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:**

```bash
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 (26–32px) 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 `scheduleUndoable` → `UndoToast`.
- **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.ts` — `scheduleUndoable({ 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.svelte` — **critical 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.sql` — `task_lists`, `tasks` with completion-consistency CHECK (`is_completed ⇔ completed_by ⇔ completed_at`), helper `task_list_collective_id()`, RLS by role
- `supabase/migrations/009_notes.sql` — `notes` 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.ts` — `loadNotes/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.ts` — `subscribeToList(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.ts` — `SyncQueue` 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/*.json` — `sync_offline`, `sync_syncing`, `list_start_session`
- `packages/test-utils/src/realtime-helpers.ts` — `subscribePostgresChanges(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.sql` — `shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
- `supabase/migrations/006_item_frequency.sql` — `item_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)

```bash
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:** `Collective` → `CollectiveMember`, `ShoppingList` → `ShoppingItem`, `TaskList` → `Task`, `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):**

6. **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).

7. **`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`.

9. **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>)`.

11. **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.

12. **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.

10. **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 1–2a: 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`.
