diff --git a/CLAUDE.md b/CLAUDE.md index 26c58b3..ceead50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Fase 16: loading spinner** — `Spinner.svelte` (sizes sm/md/lg = 16/24/40px, `currentColor` for theme, `motion-reduce:animate-none`, `role="status"` + sr-only `m.loading()`) integrated at 7 loading sites (auth splash + search + notes archive + create-collective modal + manage page: members-list / invite generator / add-common-item button). No DB migration; reuses existing `m.loading()`. Added 5 Vitest unit (SP-U-01..05) + 3 Playwright e2e (SP-E-01..03). Vitest config gained `resolve.conditions: ['browser']` + `server.deps.inline: [/^svelte/]` so Svelte 5's `mount()` resolves the browser entry; see `docs/history/fase-16-spinner.md`. **Final cumulative MVP2 (fases 9–16): 177 pgTAP + 179 Vitest integration + 52 Vitest unit + 96 Playwright + 1 gated rate-limit = 505 tests green; 3 skipped.** +**v1.0 — planned (📋):** four fases on `mvp2` branch (will retag at the end). Aimed at producing the first tagged release: **Fase 17 versioning** — git tags (`vX.Y.Z` semver), `CHANGELOG.md` in repo root (Keep-a-Changelog format, `[new]/[fix]/[tweak]` prefixes), one big `[v0.0.0-beta]` backfill section covering Fases 0–16, About-modal in `/settings` rendering bundled CHANGELOG via `?raw` import, deploy-script tag check + tag column in `.deploys.log` (`plan/fase-17-versioning-changelog.md`) | **Fase 18 shopping list flow** — required title (client + DB tightening), `#N` auto-numbering parsed from prefix per collective, `collectives.default_list_title` + `collective_list_titles` catalog with admin-only RPCs, autocomplete union catalog + last-10 titles (`plan/fase-18-shopping-list-flow.md`, migration 027) | **Fase 19 emoji avatars** — `EMOJI_CATALOG` lazy-load per category (faces / food / animals / objects, ~280 codepoints, Unicode 14 baseline for iOS<17 compat), new `EmojiPicker.svelte` replacing the 8-emoji hardcoded selector at 4 sites (`plan/fase-19-emoji-avatars.md`, no migration) | **Fase 20 Euskera locale** — `messages/eu.json` via machine-translate seed + `[needs review]` marker, `users.language` check constraint extended to `('en','es','eu')`, `accept-language` parser + selector in `/settings`; v1.0.0 tag lands at the end of this fase (`plan/fase-20-euskera-locale.md`, migration 028). + ## Documentation Map - `README.md` — full development plan, confirmed tech stack, Justfile reference, technical warnings diff --git a/README.md b/README.md index 35caeae..3025c45 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ | ↳ Fase 14 — PWA hardening | ✅ | | ↳ Fase 15 — Common items management | ✅ | | ↳ Fase 16 — Loading spinner | ✅ | +| **v1.0** — Fase 17 → 20 | 📋 Planificada | +| ↳ Fase 17 — Versioning + CHANGELOG | 📋 | +| ↳ Fase 18 — Shopping list flow | 📋 | +| ↳ Fase 19 — Emoji avatars | 📋 | +| ↳ Fase 20 — Euskera locale | 📋 (cierra `v1.0.0`) | | Suite de tests (pgTAP + Vitest + Playwright) | ✅ **505 tests verdes** (177 pgTAP + 179 integración + 52 unit + 96 E2E) + 1 rate-limit gated (`just test-rate-limit`). 3 skipped en `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). | --- @@ -63,6 +68,10 @@ Las fases ya cerradas (0, 1, 2a) reflejan este patrón retroactivamente: la secc | **MVP2 — Fase 14** | [fase-14-pwa-hardening.md](plan/fase-14-pwa-hardening.md) | 3–5 días | | **MVP2 — Fase 15** | [fase-15-common-items.md](plan/fase-15-common-items.md) | 3–5 días | | **MVP2 — Fase 16** | [fase-16-spinner.md](plan/fase-16-spinner.md) | 1 día | +| **v1.0 — Fase 17** | [fase-17-versioning-changelog.md](plan/fase-17-versioning-changelog.md) | 2–3 días | +| **v1.0 — Fase 18** | [fase-18-shopping-list-flow.md](plan/fase-18-shopping-list-flow.md) | 3–5 días | +| **v1.0 — Fase 19** | [fase-19-emoji-avatars.md](plan/fase-19-emoji-avatars.md) | 1–2 días | +| **v1.0 — Fase 20** | [fase-20-euskera-locale.md](plan/fase-20-euskera-locale.md) | 1–2 días (+ review humana) | **Total estimado MVP (Fase 0–6): 11–16 semanas. MVP2 (Fase 9–14, rama `mvp2`): 10–14 semanas. Fase 7+8 entre MVP y MVP2 como cleanup post-launch.** diff --git a/plan/fase-17-versioning-changelog.md b/plan/fase-17-versioning-changelog.md new file mode 100644 index 0000000..d26a631 --- /dev/null +++ b/plan/fase-17-versioning-changelog.md @@ -0,0 +1,126 @@ +### Fase 17 — Versioning, git tags + CHANGELOG + about modal + +**v1.0 · 1/4.** First fase of the v1.0 cycle (post-MVP2). Lands the version-tracking infrastructure that every later v1.0 fase will register entries against. + +**Estado: 📋 Planificada.** Atiende cuatro asks acoplados: + +1. **Git tags** — usar `vMAJOR.MINOR.PATCH` semver. Tag creado localmente; el script de deploy verifica que `HEAD` apunta a un tag antes de empezar (warn pero no bloquea). +2. **CHANGELOG.md** — formato Keep-a-Changelog. Cada release una sección `## [vX.Y.Z] — YYYY-MM-DD` con sub-bloques `### Added / Changed / Fixed`. Cada bullet anotado `[new]`, `[fix]` o `[tweak]`. Backfill de toda la historia previa como una sola sección beta. +3. **About modal en `/settings`** — abre desde la sección "Acerca de" (ya existe el chip de versión, Fase 14.3.4); el modal renderiza `CHANGELOG.md` completo, importado en build-time (`?raw`) — sin fetch en runtime. +4. **v1.0.0** se etiqueta al cierre de Fase 20 (Euskera), no aquí. + +**Decisiones clave:** +- Versión canónica vive en el `package.json` de la raíz (lo usa Fase 14.3 via `__APP_VERSION__`). Bump manual antes de tag. +- `CHANGELOG.md` en raíz, no en `docs/`. Tooling como GitHub releases lo busca ahí por convención. +- Backfill BETA = **una sola sección** `## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16)` con 30–50 bullets organizados por área (auth, lists, tasks, notes, PWA, infra). Sin sub-secciones por fase — los plan/history docs ya cubren ese nivel. +- Modal de changelog: parser markdown ligero (no librería; basta `marked` si ya está en el bundle, si no `String.replace` para `###`/`-`/`**`). Solo renderiza secciones — no enlaces externos. +- Tag creation: manual `git tag -a vX.Y.Z -m "..."` por ahora. No automatizar push de tags hasta que el flujo esté probado. + +--- + +#### 17.1 CHANGELOG.md backfill + +**Fichero:** nuevo `CHANGELOG.md` en raíz. + +**Tareas:** + +- [ ] **17.1.1** Crear `CHANGELOG.md` con cabecera estándar Keep-a-Changelog: + ```markdown + # Changelog + + All notable changes are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow [Semantic Versioning](https://semver.org/). + + ## [Unreleased] + + ## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16) + ... + ``` +- [ ] **17.1.2** Backfill el bloque BETA leyendo cada `docs/history/fase-*.md` + `plan/fase-*.md`. 30–50 bullets agrupados: + - **Auth + identity** (Fase 1, 7, 8) + - **Shopping lists + items** (Fase 2a, 2b, 4, 5, 7, 10, 11, 15) + - **Tasks + notes** (Fase 3, 9.3) + - **Search** (Fase 4) + - **Mobile UX + accessibility** (Fase 5, 9, 16) + - **PWA + offline** (Fase 6, 14) + - **Collective management** (Fase 10, 12, 13) + - **Server admin** (Fase 13) + - **i18n** (Fase 0, 4) + - **Infra + deploy** (Fase 0, 6, 8, 14.3, plus the off-stack proxy + Caddy + backup/rollback work) +- [ ] **17.1.3** Cada bullet con prefix `[new]` / `[fix]` / `[tweak]`. Ejemplos: + - `[new] Item tags scoped per collective + drag-reorder (Fase 11).` + - `[fix] auth.users role/aud trigger to keep PostgREST routing past Keycloak provisioning (migrations 013–014).` + - `[tweak] Edge proxy off-stack — internal Caddy on :3000 + external TLS terminator.` + +--- + +#### 17.2 About modal + changelog viewer + +**Ficheros:** nuevo `apps/web/src/lib/components/ChangelogModal.svelte`, edits a `apps/web/src/routes/(app)/settings/+page.svelte`. + +**Tareas:** + +- [ ] **17.2.1** Importar el CHANGELOG en build-time: añadir `import changelogRaw from '../../../../CHANGELOG.md?raw'` en el módulo del modal. Verificar que el path se resuelve desde `apps/web/` (3 niveles arriba); ajustar si falla bajo el adapter-node. +- [ ] **17.2.2** Parser ligero inline: split por líneas, formatear `##`/`###` como headers, `-` como list items, `**text**` como bold via ``. Sin XSS surface — el input es estático del repo, no usuario. +- [ ] **17.2.3** `ChangelogModal.svelte`: + - Trigger: prop `open: boolean` + `onClose`. + - Layout: overlay + panel centered (reusar el patrón de `CreateCollectiveModal.svelte`). + - Header: "Historial de cambios" + close icon. + - Body: scrollable, max-h-[70vh]. Sticky version chips a la izquierda (opcional). + - Cierra con Esc o click en overlay. +- [ ] **17.2.4** En `/settings`, debajo del chip de versión existente, añadir un botón discreto "Ver historial" (text-xs subrayado) que setea `showChangelog = true`. + +--- + +#### 17.3 Deploy script tag check + +**Fichero:** `infra/scripts/deploy-erosi.sh`. + +- [ ] **17.3.1** Al inicio (antes del rsync), si `git describe --exact-match --tags HEAD 2>/dev/null` falla (HEAD no es un tag), imprimir advertencia amarilla: + ``` + WARNING: HEAD is not at a tagged commit. Deploys without a tag won't + map cleanly to CHANGELOG entries. Continue? [5s abort] + ``` + Sleep 5s con Ctrl-C abort. Si está taggeado, mostrar el tag al lado de la SHA en el banner. +- [ ] **17.3.2** Cuando esté en un tag, incluir el tag (no solo la SHA) en `.deploys.log` como columna 4 opcional: `\t\t\t`. Mantener compat: las 3 columnas anteriores siguen ahí. +- [ ] **17.3.3** `rollback-erosi.sh --list` muestra el tag si está presente. + +--- + +#### 17.4 Tests + +- [ ] **17.4.1** Vitest unit `apps/web/src/lib/components/ChangelogModal.test.ts`: + - CHM-U-01 modal cerrado no renderiza nada. + - CHM-U-02 modal abierto renderiza el `

` del CHANGELOG. + - CHM-U-03 click en overlay dispara `onClose`. + - CHM-U-04 tecla Esc dispara `onClose`. +- [ ] **17.4.2** Playwright `tests/e2e/changelog.test.ts`: + - CL-01 Ana abre `/settings`, clica "Ver historial", modal aparece con la cabecera "v0.0.0-beta" visible. + - CL-02 Esc cierra el modal. +- [ ] **17.4.3** Unit linter: chequeo del CHANGELOG con un script `scripts/check-changelog.mjs` que valida (a) cabecera Keep-a-Changelog presente, (b) cada release tiene formato `## [vX.Y.Z] — YYYY-MM-DD`, (c) cada bullet empieza con `[new]`/`[fix]`/`[tweak]`. Wire en `just check`. + +--- + +#### 17.Z Verificación + cierre + +- [ ] **17.Z.1** `just test-all` verde. Suma esperada: +4 unit + 2 e2e + 0 pgTAP = +6. +- [ ] **17.Z.2** Smoke: abrir `/settings` en prod, ver el botón "Ver historial", abrir el modal, verificar contenido. +- [ ] **17.Z.3** Bump `package.json` version → `1.0.0-rc.0` para empezar el ciclo v1.0 (el `1.0.0` se reserva para el cierre de Fase 20). Tag `v1.0.0-rc.0` desde HEAD. +- [ ] **17.Z.4** Documentar en `docs/history/fase-17-versioning-changelog.md`. +- [ ] **17.Z.5** Strings Paraglide nuevos: `settings_about_view_changelog`, `changelog_modal_title`, `changelog_modal_close`. + +--- + +### Riesgos / notas + +- **`?raw` import del CHANGELOG**: Vite lo bundlea como string al servir el módulo. Bundle size sube ~5–20KB. Si crece más allá de eso (varias releases), considerar lazy-load del modal vía dynamic `import()`. +- **Tag check no bloquea el deploy**: warn + sleep, no abort. Mantener el flujo "deploy en cualquier commit" disponible para hotfixes urgentes. +- **Linter de CHANGELOG**: no fail-loud en pre-commit hooks; rompe el flujo si alguien edita el CHANGELOG manualmente. Mejor warn-only. +- **Markdown parser inline**: lo suficiente para Keep-a-Changelog (headers, lists, bold). Si el CHANGELOG crece con tablas, code blocks, links → swap a `marked` (que ya está si se añade un plugin de markdown para algún otro flujo) o `micromark-light`. + +### Scope explícito fuera + +- Auto-generación del changelog desde commits (conventional-commits + standard-version). Manual por ahora. +- GitHub Releases sync. Solo tags locales. +- Push de tags al remoto desde el deploy script. Manual. +- Versionado de la API/REST. Solo del cliente. +- Notificación push de "nueva versión" — Fase 14.1 ya cubre el toast. diff --git a/plan/fase-18-shopping-list-flow.md b/plan/fase-18-shopping-list-flow.md new file mode 100644 index 0000000..65bb6f3 --- /dev/null +++ b/plan/fase-18-shopping-list-flow.md @@ -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). diff --git a/plan/fase-19-emoji-avatars.md b/plan/fase-19-emoji-avatars.md new file mode 100644 index 0000000..e003341 --- /dev/null +++ b/plan/fase-19-emoji-avatars.md @@ -0,0 +1,114 @@ +### Fase 19 — Emoji avatars expansion (food + animal categories) + +**v1.0 · 3/4.** Tercera fase del ciclo v1.0. + +**Estado: 📋 Planificada.** Petición concreta: el avatar picker actual (`/settings`, también en colective-onboarding y `CreateCollectiveModal`) ofrece 8 emojis hardcoded. Ampliar con **todos los emojis oficiales** de food + animals según Unicode CLDR. + +**Objetivo: agrupar emojis en categorías navegables (no scroll infinito flat), añadir food + animals completos (~270 glyphs combinados según Unicode 15), mantener performance del picker.** + +**Decisiones clave:** +- Fuente de verdad: lista hardcoded en `apps/web/src/lib/data/emoji-catalog.ts`, no fetched. Generada una sola vez desde Unicode CLDR; commit del JSON. Tamaño ~5–10KB. +- Categorías visibles: **Caras** (mantiene los 8 actuales + ~30 más comunes), **Comida** (todos los food/drink), **Animales** (todos los animal/nature), **Objetos** (un pequeño set útil). El picker abre por defecto en "Caras"; pestañas en la cabecera. +- Sin emoji search en MVP de la fase — solo navegación por pestaña. Search si crece. +- Variantes de skin tone parked. + +--- + +#### 19.1 Catálogo + +**Fichero:** nuevo `apps/web/src/lib/data/emoji-catalog.ts`. + +```ts +export type EmojiCategory = 'faces' | 'food' | 'animals' | 'objects'; + +export const EMOJI_CATALOG: Record = { + faces: ['😀', '😎', '🥳', '🤓', '👻', '🤖', '👽', '🥸', /* ~30 más */], + food: ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', /* ~85 más, todo Unicode "Food & Drink" */], + animals: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', /* ~150 más, todo "Animals & Nature" */], + objects: ['🏠', '🏡', '🌳', '🌻', '⭐', '🔥', '🎁', '🎈', /* ~20 más */], +} as const; + +export const CATEGORY_LABELS: Record = { + faces: 'Caras', + food: 'Comida', + animals: 'Animales', + objects: 'Objetos', +}; +``` + +**Tareas:** + +- [ ] **19.1.1** Generar los arrays. Fuente recomendada: [unicode-emoji-json](https://github.com/muan/unicode-emoji-json) `data-by-group.json`. Copiar el bloque "Food & Drink" + "Animals & Nature". Filtrar variantes (eliminar items con ZWJ secuencias raras como `🐕‍🦺` si Vite no las renderiza consistente). Mantener Unicode 15 baseline (sin Unicode 16 que iOS aún no incluye). +- [ ] **19.1.2** Tamaño final esperado: ~280 strings, ~10KB del bundle. Verificar. +- [ ] **19.1.3** Generador deterministic — un script `scripts/build-emoji-catalog.mjs` ejecutable manualmente que rebuild el `.ts` desde una copia local del JSON. No corre en CI; solo cuando Unicode se actualiza. + +--- + +#### 19.2 Componente EmojiPicker + +**Fichero:** nuevo `apps/web/src/lib/components/EmojiPicker.svelte`. + +- [ ] **19.2.1** Props: `selected: string | null`, `onSelect: (emoji: string) => void`, `onClose?: () => void`. +- [ ] **19.2.2** Layout: header con pestañas (4 botones), body grid `grid-cols-8` (responsive `sm:grid-cols-10`), cada celda 32×32 con `text-2xl`. Click selecciona + dispara `onSelect`. +- [ ] **19.2.3** Categoría seleccionada en `$state`, default `'faces'`. Switch instantáneo (no animación). +- [ ] **19.2.4** Lazy render: solo monta el grid de la categoría activa (no las 4 al mismo tiempo) — evita coste de ~280 nodos vacíos. +- [ ] **19.2.5** A11y: pestañas son `role="tab"`, grid `role="grid"`, cada celda `role="gridcell"` + aria-label con el codepoint name. Keyboard nav (arrow keys) entre celdas. + +--- + +#### 19.3 Integraciones + +**Ficheros:** +- `apps/web/src/routes/(app)/settings/+page.svelte` (avatar del usuario). +- `apps/web/src/routes/(app)/onboarding/+page.svelte` (emoji inicial del colectivo). +- `apps/web/src/lib/components/CreateCollectiveModal.svelte` (emoji al crear). +- `apps/web/src/routes/(app)/collective/manage/+page.svelte` (emoji al renombrar — Fase 10.2). + +- [ ] **19.3.1** Reemplazar los selectores hardcoded de emoji por ``. Mantener el mismo padding/border del contenedor para no romper layouts. +- [ ] **19.3.2** Verificar que `users.avatar_emoji` y `collectives.avatar_emoji` siguen aceptando los nuevos glyphs (text column — sí). + +--- + +#### 19.4 Tests + +- [ ] **19.4.1** Vitest unit `apps/web/src/lib/components/EmojiPicker.test.ts`: + - EP-U-01 4 pestañas presentes. + - EP-U-02 default category = 'faces'. + - EP-U-03 click en una celda dispara `onSelect` con el emoji correcto. + - EP-U-04 cambiar pestaña re-renderiza el grid con la categoría correcta. + - EP-U-05 arrow keys mueven foco entre celdas. +- [ ] **19.4.2** Vitest unit `emoji-catalog.test.ts`: + - EC-U-01 `EMOJI_CATALOG.food.length >= 85`. + - EC-U-02 `EMOJI_CATALOG.animals.length >= 130`. + - EC-U-03 cada categoría no contiene duplicados. + - EC-U-04 no overlap entre categorías (un emoji solo en una). +- [ ] **19.4.3** Playwright `tests/e2e/emoji-picker.test.ts`: + - **EP-E-01** Ana en `/settings`, abre el picker, cambia a "Comida", selecciona 🍎, guarda → `users.avatar_emoji` actualizado, avatar renderiza 🍎. + - **EP-E-02** Onboarding: Eva (nuevo usuario) crea colectivo con emoji 🐱 desde "Animales". + +--- + +#### 19.Z Verificación + cierre + +- [ ] **19.Z.1** `just test-all` verde. Suma esperada: +9 unit + 2 e2e = +11. +- [ ] **19.Z.2** Smoke en iOS Safari + Android Chrome — verificar que TODOS los emojis del catálogo renderizan (no tofu □). Si algunos no, eliminar de la lista. +- [ ] **19.Z.3** Bundle-size check: bundle gz total no debe subir más de ~10KB respecto al baseline. +- [ ] **19.Z.4** Entrada en CHANGELOG: `[new] Emoji picker expanded with full food + animals categories (Fase 19).` +- [ ] **19.Z.5** Documentar en `docs/history/fase-19-emoji-avatars.md`. + +--- + +### Riesgos / notas + +- **iOS vs Android emoji rendering**: algunos Unicode 15 codepoints (🩷, 🩵, 🪿) aún no están en iOS 16 (sí en 17.4+). Filtrar a Unicode 14 si la base de usuarios incluye iOS < 17. Documentar la decisión. +- **CLDR shortName as aria-label**: para a11y, necesitamos el nombre del emoji ("red apple"). Si añadir el JSON completo de nombres infla el bundle, fallback a `aria-label={emoji}` (lee como "ÄPFel" en VoiceOver — feo pero funcional). +- **ZWJ sequences**: 👨‍👩‍👧 (familia) o 🏳️‍🌈 (rainbow flag) son secuencias múltiples. Los excluimos del catálogo en MVP de la fase (variantes de skin tone tampoco). +- **Performance del grid grande**: ~280 nodos en el DOM si renderizamos todas las pestañas. Por eso lazy-render por pestaña activa. + +### Scope explícito fuera + +- Buscar emoji por nombre. +- Skin tone variants. +- Recently-used (tracking de los últimos emojis del usuario). +- Custom emojis subidos por el colectivo. +- Sincronización del emoji con el sistema operativo del usuario. diff --git a/plan/fase-20-euskera-locale.md b/plan/fase-20-euskera-locale.md new file mode 100644 index 0000000..977fe3f --- /dev/null +++ b/plan/fase-20-euskera-locale.md @@ -0,0 +1,136 @@ +### Fase 20 — Euskera locale (eu) + +**v1.0 · 4/4.** Última fase del ciclo v1.0. Tras cerrarla, se taggea `v1.0.0`. + +**Estado: 📋 Planificada.** Añadir Euskera (Basque, ISO 639-1 `eu`) como tercer idioma soportado, sumándose a `en` y `es`. Tras esta fase la app está disponible para los hogares vasco-parlantes. + +**Estado actual (auditado 2026-05-19):** +- Paraglide en `apps/web/messages/{en,es}.json`. Compile-time tree-shaken por idioma. +- Selector de idioma actual: `users.language text` (probablemente `en` | `es` por check constraint o convención). Auto-detect ya en Fase 10.8. +- ~300+ keys en cada locale (estimado por el tamaño actual). + +**Decisiones clave:** +- **Machine-translate seed + flag for review (opción B):** generar `messages/eu.json` con traducción automática (DeepL-formatted-text o GPT-class) marcando cada valor con sufijo `[needs review]` para que la persona euskera-hablante pueda repasar y borrar el marcador. +- Soporte de idiomas declarados en la lista canónica: `apps/web/src/lib/i18n/supported.ts` (verificar nombre exacto, puede que viva en `paraglide` config). Añadir `'eu'`. +- `users.language` accept `'eu'` en el check constraint (si existe). Si no hay constraint, añadirlo ahora con 3 valores. +- Auto-detect (Fase 10.8): reconocer `eu` / `eu-ES` / `eu-FR` en `accept-language`. +- Selector visible: `/settings` ya tiene un control de idioma. Añadir opción "Euskera". + +--- + +#### 20.1 Migración: language check + +**Fichero:** `supabase/migrations/028_users_language_eu.sql`. + +```sql +-- Drop the existing check (if it exists; some seeds may not have it). +alter table public.users + drop constraint if exists users_language_check; + +-- Re-add with eu allowed. +alter table public.users + add constraint users_language_check + check (language in ('en','es','eu')); +``` + +**Tareas:** + +- [ ] **20.1.1** Migración 028. +- [ ] **20.1.2** Tests pgTAP `supabase/tests/020_users_language_eu.sql`: + - update a `'eu'` accepted. + - update a `'fr'` rejected by check. +- [ ] **20.1.3** `just db-types`. + +--- + +#### 20.2 Locale file + +**Fichero:** nuevo `apps/web/messages/eu.json`. + +- [ ] **20.2.1** Pipeline de generación (manual, una vez): + 1. Tomar `messages/es.json` como source-of-truth (lengua romance + base traductor más fiable hacia euskera que `en`). + 2. Traducir cada valor con DeepL API o GPT con prompt "Translate to Euskera (Basque), keep ICU placeholders intact, preserve punctuation, formal register". + 3. Marcar cada valor traducido con sufijo ` [needs review]` antes del cierre de la string. **Excepción**: las keys con placeholders ICU (`{name}`, `{count}`) llevan también el sufijo pero la review debe verificar que los placeholders no se hayan movido a posiciones gramaticalmente raras para euskera (SOV order vs SVO). +- [ ] **20.2.2** Validar el JSON: misma key set que `en.json` y `es.json`. Script `scripts/check-locales.mjs` que diff las keys y falla si faltan o sobran. +- [ ] **20.2.3** Bundle Paraglide rebuild: `pnpm --filter @colectivo/web exec paraglide-compile` (o cualquiera que sea el comando — verificar en `package.json`). + +--- + +#### 20.3 Wiring + +**Ficheros:** +- `apps/web/src/lib/i18n/*` (configuración de Paraglide — locales soportados). +- `apps/web/src/lib/utils/accept-language.ts` (Fase 10.8 — añadir `'eu'` al set conocido). +- `apps/web/src/routes/(app)/settings/+page.svelte` (selector de idioma). + +**Tareas:** + +- [ ] **20.3.1** Añadir `'eu'` al array de idiomas en la config de Paraglide (`project.inlang/settings.json` u otro fichero del plugin). +- [ ] **20.3.2** `accept-language.ts` parser: reconocer `eu`, `eu-ES`, `eu-FR` → `'eu'`. +- [ ] **20.3.3** Selector de idioma en `/settings`: nueva opción "Euskera". Label en cada idioma: + - `en`: "Basque" + - `es`: "Euskera" + - `eu`: "Euskera" +- [ ] **20.3.4** Cambiar idioma a `eu` recarga Paraglide tags sin reload de página (`setLanguageTag('eu')`) + escribe `users.language = 'eu'`. + +--- + +#### 20.4 Tests + +- [ ] **20.4.1** Vitest unit `apps/web/src/lib/utils/accept-language.test.ts` (extender): + - LANG-U-EU-01 `eu` → `'eu'`. + - LANG-U-EU-02 `eu-ES` → `'eu'`. + - LANG-U-EU-03 `eu-FR;q=0.5,en` → `'eu'` (preferencia primaria). +- [ ] **20.4.2** Vitest integración `language-bootstrap.test.ts` (extender): + - LANG-INT-EU-01 user setea language=eu, update OK. + - LANG-INT-EU-02 user intenta language='fr' → check constraint violation. +- [ ] **20.4.3** Playwright `tests/e2e/euskera.test.ts`: + - **EU-01** Ana cambia idioma a Euskera en `/settings` → strings nav cambian (Listas → "Erosketak", o lo que diga el JSON), sin reload. + - **EU-02** Eva (usuario nuevo) con header `Accept-Language: eu` → onboarding renderiza en Euskera. + +--- + +#### 20.5 Review checklist (post-translation) + +Antes del v1.0.0 release, abrir issue/asignar tarea para alguien euskera-hablante: + +- [ ] **20.5.1** Repasar `messages/eu.json`, eliminar el sufijo ` [needs review]` de cada string corregido. +- [ ] **20.5.2** Para keys con plurales (`item_count_one`, `item_count_other` si existen), verificar que ambos son correctos (euskera no distingue como inglés/español; puede ser una sola forma). +- [ ] **20.5.3** Términos del dominio: "colectivo", "lista", "tarea", "nota", "compra". Acordar la traducción con el cliente (no asumir que la máquina la acertó). + +--- + +#### 20.Z Verificación + cierre + +- [ ] **20.Z.1** `just test-all` verde. Suma esperada: +2 pgTAP + 3 unit + 2 integración + 2 e2e = +9. +- [ ] **20.Z.2** Smoke manual: rotar las 3 pantallas top-level (`/lists`, `/tasks`, `/notes`) en cada idioma. Ninguna debe mostrar la key cruda (`__SKIPPED__` o el id). +- [ ] **20.Z.3** Bundle-size check: el chunk de `eu` es lazy-loaded por Paraglide; verificar que solo se carga cuando el user lo elige. No debe pesar más que `es.json`. +- [ ] **20.Z.4** Entrada en CHANGELOG: `[new] Euskera (Basque) locale (Fase 20). Machine-translated seed pending native review.` +- [ ] **20.Z.5** Documentar en `docs/history/fase-20-euskera-locale.md`. + +--- + +#### 20.ZZ v1.0.0 release + +- [ ] **20.ZZ.1** Bump `package.json` version → `1.0.0`. +- [ ] **20.ZZ.2** Editar `CHANGELOG.md`: mover entradas de `[Unreleased]` a `## [v1.0.0] — YYYY-MM-DD`. Incluir resumen de las 4 fases del ciclo (17–20). +- [ ] **20.ZZ.3** `git tag -a v1.0.0 -m "v1.0.0 — versioning + shopping list flow + emoji avatars + euskera"`. +- [ ] **20.ZZ.4** Deploy desde el tag. Smoke en prod. +- [ ] **20.ZZ.5** Anuncio interno + nota sobre euskera review. + +--- + +### Riesgos / notas + +- **Calidad de la traducción automática**: euskera no es un idioma soportado de primera por DeepL (verificar fecha actual). GPT-4 class lo hace pero con errores. El sufijo `[needs review]` es explícito para no shipear texto incorrecto sin marcar. +- **ICU placeholders en euskera**: el orden de palabras (SOV) puede empujar `{name}` a posiciones no naturales en inglés/español. Revisar. +- **Selector ya soporta `system` (auto-detect)**: por la Fase 10.8 ya hay lógica de fallback. Asegurar que `eu` propaga por todo el árbol. +- **Test fixture users**: si los seeds (Ana, Borja, …) tienen `language='es'` hardcoded, ningún test rompe — solo los nuevos. + +### Scope explícito fuera + +- Galego, Català, Português, Francés. Solo Euskera en este release. +- Pluralización ICU avanzada (one/two/few/many/other) — euskera usa pluralización simple. +- Traducción de los strings de Keycloak (login screen). Ese es el operador del Keycloak externo. +- Localización del CHANGELOG. Solo en inglés. +- Localización de los nombres de los emojis (Fase 19). Aria-label sigue siendo el codepoint name CLDR en inglés.