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>
This commit is contained in:
@@ -9,6 +9,8 @@ labels for the area, not log entries.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [new] Required list title + auto-numbered prefix + admin-curated title catalog (Fase 18).
|
||||
|
||||
## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16)
|
||||
|
||||
Single rollup for everything shipped before tagging started. Covers the
|
||||
|
||||
110
docs/history/fase-18-shopping-list-flow.md
Normal file
110
docs/history/fase-18-shopping-list-flow.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user