docs(plan): v1.0 cycle — fases 17–20 (versioning + list flow + emojis + euskera)
Four planned-fase docs for the first tagged release.
- Fase 17 — semver git tags + CHANGELOG.md (Keep-a-Changelog, [new]/
[fix]/[tweak] prefixes), one big v0.0.0-beta backfill block covering
Fases 0–16, About-modal in /settings rendering bundled CHANGELOG via
?raw import, deploy-script tag check + tag column in .deploys.log.
- Fase 18 — shopping list flow: required title (client + DB tighten),
per-collective per-prefix #N auto-numbering parsed from typed title,
collectives.default_list_title, collective_list_titles catalog + admin
RPCs, autocomplete union (catalog + last-10). Migration 027.
- Fase 19 — EMOJI_CATALOG with faces/food/animals/objects categories
(~280 codepoints, Unicode 14 baseline for iOS<17 compat), new
EmojiPicker.svelte replacing the 8-emoji hardcoded selector at 4
sites. No migration.
- Fase 20 — Euskera locale: messages/eu.json via machine-translate
seed flagged with [needs review], users.language check constraint
extended to ('en','es','eu'), accept-language parser + selector in
/settings. Migration 028. v1.0.0 tag lands at the end of this fase.
README + CLAUDE.md updated with the v1.0 cycle pointers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
189
plan/fase-18-shopping-list-flow.md
Normal file
189
plan/fase-18-shopping-list-flow.md
Normal file
@@ -0,0 +1,189 @@
|
||||
### Fase 18 — Shopping list creation flow (required title + numbered prefix + autocomplete + defaults)
|
||||
|
||||
**v1.0 · 2/4.** Segunda fase del ciclo v1.0.
|
||||
|
||||
**Estado: 📋 Planificada.** Cuatro asks acoplados sobre el momento "creo una lista nueva":
|
||||
|
||||
1. **Título requerido** — no permitir crear sin nombre. Cliente bloquea + DB tightens.
|
||||
2. **Auto-numerado `#N`** — si el título escrito coincide con un prefijo usado en el colectivo (ej. `Compra`), sugerir `Compra #N` con `N = max(N) + 1`. El `#N` es **parte del título** (campo `name`), no columna separada.
|
||||
3. **Default list title** — admin del colectivo configura un título por defecto que prefilla el input del modal create-list.
|
||||
4. **Catálogo de títulos** — admin gestiona una tabla de títulos sugeridos (CRUD inline en `/collective/manage`); aparecen en el autocomplete junto a los últimos 10 títulos reales.
|
||||
|
||||
**Decisiones clave:**
|
||||
- `shopping_lists.name` ya es `NOT NULL` (migración 005). Falta enforce mínimo en cliente + check de longitud > 0 con trim.
|
||||
- Nuevo `collectives.default_list_title text` (nullable). Vacío = no prefill.
|
||||
- Nuevo `collective_list_titles (collective_id, title text, primary key)` — mirror del shape de `item_frequency`. Solo admin escribe via RPC.
|
||||
- Autocomplete: union de (a) `collective_list_titles` + (b) últimos 10 `name` distintos de `shopping_lists` (mismo colectivo). Deduplicate case-insensitive.
|
||||
- `#N` parsing: regex `^(.+?)\s*(?:#(\d+))?\s*$`. Prefijo = grupo 1. Si el usuario escribe `Compra`, miramos `name ILIKE 'Compra %' OR name ILIKE 'Compra'` y extraemos el `#N` maximal. Sugerimos `Compra #N+1`. Si el usuario escribe `Compra #3` explícitamente, no auto-bump.
|
||||
- Numbering scope = per-collective per-prefix (case-insensitive prefix match).
|
||||
|
||||
---
|
||||
|
||||
#### 18.1 Modelo
|
||||
|
||||
**Fichero:** `supabase/migrations/027_list_title_catalog.sql`.
|
||||
|
||||
```sql
|
||||
-- Default per-collective title (single string, nullable).
|
||||
alter table public.collectives
|
||||
add column default_list_title text;
|
||||
|
||||
-- Catalog of admin-curated suggested titles.
|
||||
create table public.collective_list_titles (
|
||||
collective_id uuid not null references public.collectives(id) on delete cascade,
|
||||
title text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (collective_id, title)
|
||||
);
|
||||
alter table public.collective_list_titles enable row level security;
|
||||
|
||||
-- Members read; only RPC writes.
|
||||
create policy "select_member" on public.collective_list_titles
|
||||
for select using (public.is_member(collective_id));
|
||||
create policy "deny_direct_writes" on public.collective_list_titles
|
||||
for all using (false) with check (false);
|
||||
|
||||
-- Admin-only RPCs.
|
||||
create or replace function public.add_list_title(
|
||||
p_collective_id uuid, p_title text
|
||||
) returns void
|
||||
language plpgsql security definer
|
||||
set search_path = public, auth
|
||||
as $$
|
||||
declare
|
||||
v_role text;
|
||||
v_norm text := trim(p_title);
|
||||
begin
|
||||
if auth.uid() is null then raise exception 'not authenticated' using errcode = 'P0001'; end if;
|
||||
if v_norm = '' then raise exception 'title cannot be empty' using errcode = 'P0001'; end if;
|
||||
select role into v_role from public.collective_members
|
||||
where collective_id = p_collective_id and user_id = auth.uid();
|
||||
if v_role is distinct from 'admin' then
|
||||
raise exception 'only admins can curate list titles' using errcode = 'P0002';
|
||||
end if;
|
||||
insert into public.collective_list_titles (collective_id, title)
|
||||
values (p_collective_id, v_norm)
|
||||
on conflict do nothing;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.remove_list_title(
|
||||
p_collective_id uuid, p_title text
|
||||
) returns void
|
||||
language plpgsql security definer
|
||||
set search_path = public, auth
|
||||
as $$
|
||||
declare v_role text;
|
||||
begin
|
||||
if auth.uid() is null then raise exception 'not authenticated' using errcode = 'P0001'; end if;
|
||||
select role into v_role from public.collective_members
|
||||
where collective_id = p_collective_id and user_id = auth.uid();
|
||||
if v_role is distinct from 'admin' then
|
||||
raise exception 'only admins can curate list titles' using errcode = 'P0002';
|
||||
end if;
|
||||
delete from public.collective_list_titles
|
||||
where collective_id = p_collective_id and title = trim(p_title);
|
||||
end; $$;
|
||||
|
||||
grant execute on function public.add_list_title(uuid, text) to authenticated;
|
||||
grant execute on function public.remove_list_title(uuid, text) to authenticated;
|
||||
```
|
||||
|
||||
**Tareas:**
|
||||
|
||||
- [ ] **18.1.1** Migración 027.
|
||||
- [ ] **18.1.2** Tests pgTAP `supabase/tests/019_list_title_catalog.sql`:
|
||||
- default_list_title default NULL, admin update OK, member update denied via RLS.
|
||||
- add_list_title como admin OK, idempotente por unique.
|
||||
- add_list_title como member → P0002.
|
||||
- add_list_title con título vacío → P0001.
|
||||
- remove_list_title como admin borra fila.
|
||||
- remove_list_title como guest → P0002.
|
||||
- [ ] **18.1.3** `just db-types`.
|
||||
|
||||
---
|
||||
|
||||
#### 18.2 Stores
|
||||
|
||||
**Fichero:** nuevo `apps/web/src/lib/stores/listTitles.ts`, extender `apps/web/src/lib/stores/lists.ts`.
|
||||
|
||||
- [ ] **18.2.1** Store `listTitles`:
|
||||
- `loadTitleCatalog(collectiveId)` — select * from `collective_list_titles`.
|
||||
- `addTitle(collectiveId, title)` / `removeTitle(collectiveId, title)` — RPCs.
|
||||
- [ ] **18.2.2** Helper `fetchTitleSuggestions(collectiveId, prefix)` — union catalog + últimos 10 `shopping_lists.name` distintos. Dedupe case-insensitive. Limit 15.
|
||||
- [ ] **18.2.3** Helper `computeNextNumber(collectiveId, basePrefix)`:
|
||||
- Query: `select name from shopping_lists where collective_id = $1 and name ILIKE $2 and deleted_at is null`.
|
||||
- Pattern `$2 = basePrefix || ' #%'` plus `basePrefix` exact.
|
||||
- Parse trailing `#(\d+)` con regex. Tomar `max(N) + 1`. Si ninguno tiene número, devolver `1` solo si el prefijo bare existe (ya hay una sin número), de otro modo `null`.
|
||||
- [ ] **18.2.4** Tests integración `packages/test-utils/tests/list-title-flow.test.ts`:
|
||||
- LT-INT-01 admin add → suggestions incluyen el título.
|
||||
- LT-INT-02 member add → P0002.
|
||||
- LT-INT-03 computeNextNumber con 3 listas `Compra #1`, `Compra #2`, `Compra #5` → devuelve 6.
|
||||
- LT-INT-04 computeNextNumber sin matches → null.
|
||||
- [ ] **18.2.5** Tests unit `apps/web/src/lib/utils/list-title.test.ts` (extraer la regex + numbering en un módulo puro):
|
||||
- LT-U-01 parse de "Compra #5" → `{ prefix: 'Compra', number: 5 }`.
|
||||
- LT-U-02 parse de "Compra" → `{ prefix: 'Compra', number: null }`.
|
||||
- LT-U-03 parse de "#5" (sin prefix) → no autonumera (returns null prefix).
|
||||
- LT-U-04 nextNumber sobre lista de números `[1,2,5]` → 6.
|
||||
- LT-U-05 nextNumber sobre lista vacía → 1.
|
||||
|
||||
---
|
||||
|
||||
#### 18.3 UI — create-list modal
|
||||
|
||||
**Ficheros:** ubicación actual de creación de lista (probablemente `apps/web/src/routes/(app)/lists/+page.svelte` o similar — auditar).
|
||||
|
||||
- [ ] **18.3.1** Input prefilled con `currentCollective.default_list_title || ''`.
|
||||
- [ ] **18.3.2** Mientras el usuario escribe:
|
||||
- Si el typed value matches un prefijo conocido + no incluye `#N`, mostrar un chip discreto `Sugerencia: Compra #6` (texto pequeño debajo del input, click rellena el input).
|
||||
- Suggestion list aparece como dropdown con `fetchTitleSuggestions(prefix)`. Mismo patrón que `TagPicker` (Fase 11).
|
||||
- [ ] **18.3.3** Submit deshabilitado si `name.trim() === ''`. Sin spinner cuando deshabilitado.
|
||||
- [ ] **18.3.4** Si submit ocurre y el título matches un prefijo conocido sin `#N` y `computeNextNumber` devuelve un valor, **auto-suffix** el `#N` antes de insertar. Visible para el usuario en un toast no intrusivo: "Creada como `Compra #6`".
|
||||
|
||||
**Decisión UX:** auto-suffix solo si el typed value coincide con un título del catálogo. Para prefijos libres (no en catalog), respetar lo que el usuario escribió tal cual.
|
||||
|
||||
---
|
||||
|
||||
#### 18.4 UI — `/collective/manage` catálogo
|
||||
|
||||
**Fichero:** `apps/web/src/routes/(app)/collective/manage/+page.svelte`.
|
||||
|
||||
- [ ] **18.4.1** Nueva subsección "Títulos sugeridos" debajo de "Items frecuentes":
|
||||
- Input `default_list_title` (text input, single-line, save on blur).
|
||||
- Lista de títulos curados con botón "Borrar" por fila.
|
||||
- Botón "Añadir título" + modal pequeño con input + Save.
|
||||
- [ ] **18.4.2** Members ven la sección read-only (lista visible; sin acciones). Guests no la ven.
|
||||
|
||||
---
|
||||
|
||||
#### 18.5 Tests E2E
|
||||
|
||||
- [ ] **18.5.1** Playwright `tests/e2e/list-title-flow.test.ts`:
|
||||
- **LTF-01** Ana setea `default_list_title = "Compra"` en manage; crea lista nueva → input prefilled "Compra"; submit → lista creada como "Compra" (no `#1` aún porque no había prefix-match en catalog antes).
|
||||
- **LTF-02** Ana añade "Compra" al catálogo; crea 3 listas; verifica que la 3ª se nombra "Compra #2" automáticamente (la 1ª = "Compra", 2ª = "Compra #1" si #1 ausente; ajustar según `computeNextNumber` exact behavior).
|
||||
- **LTF-03** Ana intenta crear con input vacío → botón disabled, no fire.
|
||||
- **LTF-04** Borja (member) en manage ve la subsección con acciones disabled.
|
||||
|
||||
---
|
||||
|
||||
#### 18.Z Verificación + cierre
|
||||
|
||||
- [ ] **18.Z.1** `just test-all` verde. Suma esperada: +6 pgTAP + 4 integración + 5 unit + 4 e2e = +19.
|
||||
- [ ] **18.Z.2** Smoke: crear 3 listas seguidas con prefijo común; verificar numbering correcto.
|
||||
- [ ] **18.Z.3** Entrada en CHANGELOG: `[new] Required list titles + auto-numbered prefix + admin-curated title catalog (Fase 18).`
|
||||
- [ ] **18.Z.4** Documentar en `docs/history/fase-18-shopping-list-flow.md`.
|
||||
|
||||
---
|
||||
|
||||
### Riesgos / notas
|
||||
|
||||
- **Race condition en numbering**: dos usuarios crean a la vez con prefijo `Compra`. Ambos clientes resuelven `computeNextNumber → 6`. Resultado: dos `Compra #6`. Mitigación posible: unique constraint `(collective_id, name)` — pero rompe casos donde es deliberado tener duplicados. Aceptar el race; documentar.
|
||||
- **Caso "el usuario quiere típo libre"**: si el typed value no matches un prefix conocido, no auto-suffix. Asegurar que el chip de sugerencia solo aparece para matches del catálogo.
|
||||
- **Default title sensible al colectivo activo**: al cambiar de colectivo en la sidebar, el modal create-list debe re-leer `currentCollective.default_list_title`. Reactivo vía store.
|
||||
- **i18n**: el `#N` formato es universal; no traducir. Strings nuevos: `create_list_input_placeholder`, `create_list_suggestion_chip`, `create_list_auto_numbered_toast`, `manage_list_titles_section_title`, `manage_default_list_title_label`.
|
||||
|
||||
### Scope explícito fuera
|
||||
|
||||
- Plantillas de items (lista con items pre-cargados). Solo título.
|
||||
- Numbering por mes/año (formato `Compra 2026-05`). Solo `#N` integer.
|
||||
- Edición masiva del catálogo via CSV. Solo CRUD inline.
|
||||
- Sugerencias cross-collective (títulos de otros colectivos del mismo usuario).
|
||||
Reference in New Issue
Block a user