Files
collective-lists/plan/fase-18-shopping-list-flow.md
Oier Bravo Urtasun 427fe33c9c 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>
2026-05-19 01:01:34 +02:00

10 KiB

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.

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


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