Files
collective-lists/docs/history/fase-18-shopping-list-flow.md
Oier Bravo Urtasun f7abba4888 docs(fase-18): history doc + CHANGELOG [Unreleased] entry
Records the four asks shipped in Fase 18 — required list title, auto-
numbered prefix from a catalog match, admin-curated catalog + per-
collective default — alongside the migration / pgTAP / store / UI /
e2e changes that landed across the cycle. Notes the four design
decisions worth remembering (DB stays #N-agnostic, no unique
constraint, auto-suffix only on catalog hits, bare-hash refuses to
autonumber) plus the explicit out-of-scope items.

CHANGELOG.md gains a single `[new]` line under `[Unreleased]` per the
orchestrator's spec.

Final test counts after Fase 18:
  pgTAP        191  (was 178; +13)
  Integration  185  (was 179; +6)   +3 skipped
  Unit          75  (was 56;  +19)
  Playwright   104  (was 100; +4)
  Gated          1  (unchanged)
  Total        555  +3 skipped       (was 514 +3)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 02:46:18 +02:00

9.4 KiB

Fase 18 — Shopping list creation flow (required title + auto-numbered prefix + autocomplete + defaults)

v1.0 · 2/4. Second pass of the v1.0 cycle.

Status: Shipped 2026-05-19.

Four coupled asks on the "I create a new shopping list" moment:

  1. Required title — no creating without a name; client blocks on empty trim, DB enforces NOT NULL (already in migration 005).
  2. Auto-numbered #N — when the typed title matches an admin-curated prefix (e.g. Compra), the modal auto-suffixes #N with N = max(N) + 1 from existing lists in the same collective. #N is part of the title (column name), not a separate column.
  3. Default list title — admins set a per-collective default that prefills the create-list modal's input.
  4. Curated title catalog — admins manage a small set of "known prefixes" that drive both the autocomplete dropdown and the auto-suffix decision.

Plan: plan/fase-18-shopping-list-flow.md. Migration: supabase/migrations/027_list_title_catalog.sql. pgTAP: supabase/tests/019_list_title_catalog.sql.

What changed

18.1 — Data model (migration 027 + pgTAP 019)

  • collectives.default_list_title text (nullable). Empty / NULL means no prefill (current behaviour preserved).
  • collective_list_titles (collective_id, title, created_at) with composite PK and ON DELETE CASCADE from the parent collective. Mirrors the shape of item_frequency from Fase 15 — members read via is_member, writes are all-deny RLS; the two SECURITY DEFINER RPCs are the only entry points.
  • add_list_title(p_collective_id uuid, p_title text) — trims, rejects empty (P0001), gates on collective_members.role = 'admin' (P0002), inserts with ON CONFLICT DO NOTHING so the UI can call it idempotently.
  • remove_list_title(p_collective_id uuid, p_title text) — same role check, deletes the row.
  • Both GRANT EXECUTE ... TO authenticated. PostgREST routes them as RPCs.
  • Realtime: collective_list_titles is added to the supabase_realtime publication with REPLICA IDENTITY FULL so the manage UI sees admin edits live across tabs/browsers (matches the migration 007 pattern for shopping_lists / shopping_items).
  • pgTAP 019_list_title_catalog.sql (13 assertions): schema invariants on the column + table, PK shape, RLS posture, policy count, RPC existence + SECURITY DEFINER flag, realtime membership, default-NULL behaviour. The role-gate denial path is covered by the integration suite (postgres bypasses auth.uid() inside pgTAP, same caveat as Fase 15's 018).

18.2 — Stores + numbering utils (pure module)

  • apps/web/src/lib/utils/list-title.ts is the pure I/O-free helper:
    • parseTitle(input){ prefix, number }. The regex ^(.+?)\s+#(\d+)$ requires whitespace between prefix and #, so Compra#5 stays literal and bare #5 returns prefix: null (the chip is for catalog-curated prefixes only).
    • nextNumberFromNumbers(numbers)max(numbers) + 1, returns 1 for an empty array.
    • computeNextNumberFromNames(prefix, names) — case-insensitive prefix match per row, bare-prefix occurrence counts as N=0, no matches returns 1.
  • apps/web/src/lib/stores/listTitles.ts:
    • loadTitleCatalog(collectiveId) and a listTitleCatalog writable.
    • addTitle / removeTitle wrap the RPCs with optimistic store updates.
    • setDefaultListTitle(collectiveId, value) is a plain UPDATE on collectives — RLS already gates UPDATE to admins.
    • fetchTitleSuggestions(collectiveId, prefix) — union of catalog + last 10 distinct shopping_lists.name, deduped case-insensitively, capped at 15.
    • computeNextNumber(collectiveId, basePrefix) — drives the SQL or(name.ilike.<prefix>,name.ilike.<prefix> #%) filter and pipes the row names through the pure helper.
  • 19 unit tests on the pure module (covers LT-U-01..05 from the plan plus 7 boundary cases).
  • 6 integration tests on the RPC + DB-driven numbering path (LT-INT-01..06).

18.3 — Create-list modal (UX shift)

apps/web/src/lib/components/CreateListModal.svelte replaces the legacy "click New list → create with empty name → land on detail and rename inline" flow with an explicit modal:

  • Input prefilled with currentCollective.default_list_title || ''.
  • A dropdown of fetchTitleSuggestions candidates (debounced 150 ms) and an inline chip Sugerencia: Compra #6 when the typed value exactly matches a catalog prefix and computeNextNumber resolves a value.
  • Submit disabled while the trimmed value is empty or creation is in flight.
  • On submit, if the catalog match still holds, the modal auto-suffixes #N before inserting; the lists page surfaces a transient toast Creada como "Compra #6".

The prefill $effect is guarded by a wasOpen edge-tracker — a naive $effect(() => { if (open) value = default }) re-fires whenever $currentCollective updates (e.g. a realtime UPDATE landing after the user typed something) and clobbered the typed value. The edge guard limits prefill to the false→true transition.

/lists/+page.svelte swaps the old handleCreate() handler for openCreateModal() and mounts <CreateListModal /> at the page root. Existing per-list action menus and trash flow are untouched.

18.4 — /collective/manage "Suggested titles" subsection

Admins see a default-title input (save-on-blur, optimistic patch on currentCollective) and a curated catalog list with inline Remove per row, plus an "Add title" button that opens a small modal. Members see the same surface read-only — the input is disabled, the add button is hidden, and a banner explains "Only admins can curate these titles." Guests don't see the section at all (mirrors the canSeeCommonItems gate from Fase 15).

The $effect syncing defaultTitleDraft from $currentCollective.default_list_title is gated by document.activeElement?.id !== 'default-list-title-input' so realtime UPDATEs from another tab don't clobber an unsaved local edit.

18.5 — E2E coverage

apps/web/tests/e2e/list-title-flow.test.ts ships 4 specs:

  • LTF-01 Ana sets default_list_title (via __sb rather than the manage UI to isolate the modal under test from the section under test); the create modal opens prefilled; submitting yields a list with the prefilled name as-is (no #N because the default was never added to the catalog).
  • LTF-02 Ana adds the prefix to the catalog via the manage UI, sets the default via the same UI, then drives the create modal three times — confirms the third list lands as ${prefix} #3. Exercises the full path: manage RPC → store update → modal auto-suffix on submit.
  • LTF-03 Empty input keeps submit disabled; whitespace-only stays disabled; typing real text enables.
  • LTF-04 Borja (member) opens manage, sees the section, sees the read-only banner, sees no "Add title" button, and the default-title input is disabled.

Existing specs adjusted for the new flow: tests/e2e/lists.test.ts createList() helper + C-13 guest path now drive the modal; tests/e2e/session.test.ts S-03 uses the modal for the fresh-list setup.

Test counts (final)

  • pgTAP: 191 (was 178; +13)
  • Vitest integration: 185 (was 179; +6) + 3 skipped (2 Realtime presence — upstream handle_out/3 bug; 1 rate-limit — gated)
  • Vitest unit: 75 (was 56; +19)
  • Playwright: 104 (was 100; +4)
  • Gated rate-limit: 1 (unchanged)

Total: 555 + 3 skipped (was 514 + 3).

Decisions worth remembering

  • The DB is agnostic about #N format. shopping_lists.name stores the final string. The client decides if/when to suffix #N. Future formats like Compra YYYY-MM won't need a migration.
  • No unique constraint on (collective_id, name). Two clients racing on Compra #6 will both succeed and produce two rows with the same name; this is accepted (documented in migration 027 header). A unique constraint would block deliberate duplicates and is rejected.
  • Auto-suffix only fires for catalog matches. If the typed value doesn't exactly match a catalog entry (case-insensitive), the modal inserts it verbatim — even when parseTitle could extract a prefix. This keeps random / one-off names from being silently renamed.
  • Bare #5 does not trigger autonumbering. The parser requires a prefix before the hash; bare #5 returns { prefix: null, number: null } so the chip stays hidden.
  • The default-title input is debounce-free. Save on blur + Enter, mirroring the collective-name input from Fase 10.2. The catalog list-add uses a modal because we wanted clear "I'm adding a new thing" semantics; a plain input would risk accidentally creating rows on every keystroke.

Out of scope (deliberately)

  • Item templates (lists with pre-loaded items). Just the title.
  • Month/year numbering (Compra 2026-05). Only #N integer.
  • Bulk CSV import/export of the catalog. Inline CRUD only.
  • Cross-collective suggestions (titles from other collectives a user belongs to).

Migrations + tests indexed

  • supabase/migrations/027_list_title_catalog.sql
  • supabase/tests/019_list_title_catalog.sql
  • apps/web/src/lib/utils/list-title.ts + list-title.test.ts
  • apps/web/src/lib/stores/listTitles.ts
  • packages/test-utils/tests/list-title-flow.test.ts
  • apps/web/src/lib/components/CreateListModal.svelte
  • apps/web/src/routes/(app)/lists/+page.svelte (wired)
  • apps/web/src/routes/(app)/collective/manage/+page.svelte (subsection)
  • apps/web/tests/e2e/list-title-flow.test.ts
  • apps/web/tests/e2e/lists.test.ts (helper updated for new modal)
  • apps/web/tests/e2e/session.test.ts (helper updated for new modal)