Adds a tiny planned-phase doc for visual feedback during async operations. - Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16 / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware, role="status" + sr-only m.loading() for a11y. - Integration at 5 existing sites that already render m.loading() text or own a `loading` flag: auth splash, /search, /notes/archive, CreateCollectiveModal "creating" state, /collective/manage sub-sections. - 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings. Rebrand: MVP2 reopens from 7/7 ✅ to in-progress 7/8 ✅. Headers in fases 9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
188 lines
12 KiB
Markdown
188 lines
12 KiB
Markdown
### Fase 15 — Common items management (weighted suggestions + exclude-on-list)
|
|
|
|
**MVP2 · 7/8.** Añadida después del cierre de MVP2 (2026-05-18) para integrar dos peticiones concretas sobre items frecuentes; reabre el ciclo con una fase extra.
|
|
|
|
**Estado: 📋 Planificada.** Atiende dos peticiones concretas sobre el flujo "añadir item a una lista":
|
|
|
|
1. **Promover items** — algunos items aparecen siempre primero en las sugerencias (independientemente de `use_count`).
|
|
2. **Ocultar items ya añadidos** — la lista activa no debe sugerir items que ya están en ella.
|
|
|
|
**Objetivo: dar a admin (o member, decidir) un sitio para curar el catálogo de items frecuentes del colectivo — promover los recurrentes, retirar ruido — y limpiar la UX de "ver sugerencias duplicadas de lo que ya tengo en la lista". Sin construir un sistema de plantillas — sigue siendo `item_frequency` con un dial extra.**
|
|
|
|
**Estado actual (auditado 2026-05-18):**
|
|
- `supabase/migrations/006_item_frequency.sql` — tabla `item_frequency (collective_id, name, use_count, last_used_at)`, escrita por trigger SECURITY DEFINER sobre `shopping_items` INSERT. Todos los INSERT/UPDATE/DELETE del cliente están bloqueados por policy.
|
|
- `apps/web/src/lib/stores/lists.ts:275-300` (`fetchSuggestions`) — ordena por `use_count DESC`, top 5 sin prefijo / top 10 con prefijo.
|
|
- `apps/web/src/lib/components/ItemSuggestions.svelte` — render del dropdown.
|
|
- Sin UI de gestión.
|
|
|
|
**Decisiones clave:**
|
|
- Extender `item_frequency` con `weight integer not null default 0`. UI 3-way (Hide / Normal / Boost) mapea a `-50 / 0 / 50`; el rango DB acepta cualquier integer (sin check constraint) por simplicidad y forward-compat. Sigue siendo una sola tabla; nada de catálogo separado.
|
|
- Escritura de `weight` vía SECURITY DEFINER RPC — el bloqueo all-deny de la migración 006 se mantiene; solo la RPC nueva puede modificar `weight`.
|
|
- Borrar fila de `item_frequency` por completo (purgar) vía otra RPC SECURITY DEFINER — útil para limpiar typos/ruido. No toca `shopping_items`.
|
|
- Permiso para gestionar: **admin del colectivo** (consistente con CU-H07 rename). Members ven la lista en read-only; guests ni eso.
|
|
- Suggestion query: `order by weight desc, use_count desc, last_used_at desc`. Empate por `weight` cae al `use_count` actual.
|
|
- Exclude-on-list: `fetchSuggestions` recibe `excludeNames: string[]` opcional. Se aplica `.not('name', 'in', (...))` en el query. Lista vacía = sin filtro (cero coste).
|
|
- Sin migración de datos para items existentes — todos arrancan con `weight=0`, el orden cambia solo cuando un admin promueve algo.
|
|
|
|
---
|
|
|
|
#### 15.1 Modelo + RPCs
|
|
|
|
**Fichero:** `supabase/migrations/026_item_frequency_weight.sql`.
|
|
|
|
```sql
|
|
alter table public.item_frequency
|
|
add column weight integer not null default 0;
|
|
|
|
-- Re-create the suggestion index to include weight first.
|
|
drop index if exists item_frequency_collective_count_idx;
|
|
create index item_frequency_collective_order_idx
|
|
on public.item_frequency (collective_id, weight desc, use_count desc, last_used_at desc);
|
|
|
|
-- Admin-only write path for weight.
|
|
create or replace function public.set_item_frequency_weight(
|
|
p_collective_id uuid,
|
|
p_name text,
|
|
p_weight integer
|
|
) returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public, auth
|
|
as $$
|
|
declare
|
|
v_role text;
|
|
v_norm text := lower(trim(p_name));
|
|
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 manage common items' using errcode = 'P0002';
|
|
end if;
|
|
insert into public.item_frequency (collective_id, name, use_count, weight, last_used_at)
|
|
values (p_collective_id, v_norm, 0, p_weight, now())
|
|
on conflict (collective_id, name)
|
|
do update set weight = excluded.weight;
|
|
end; $$;
|
|
|
|
grant execute on function public.set_item_frequency_weight(uuid, text, integer)
|
|
to authenticated;
|
|
|
|
-- Admin-only purge.
|
|
create or replace function public.purge_item_frequency(
|
|
p_collective_id uuid, p_name 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 purge common items' using errcode = 'P0002';
|
|
end if;
|
|
delete from public.item_frequency
|
|
where collective_id = p_collective_id and name = lower(trim(p_name));
|
|
end; $$;
|
|
|
|
grant execute on function public.purge_item_frequency(uuid, text) to authenticated;
|
|
```
|
|
|
|
**Tareas:**
|
|
|
|
- [ ] **15.1.1** Migración 026.
|
|
- [ ] **15.1.2** Tests pgTAP `supabase/tests/018_item_frequency_weight.sql`:
|
|
- default 0 al crear desde el trigger existente.
|
|
- `set_item_frequency_weight` como admin → upsert OK.
|
|
- `set_item_frequency_weight` como member → P0002.
|
|
- `purge_item_frequency` como admin borra fila.
|
|
- `purge_item_frequency` como guest → P0002.
|
|
- `set_item_frequency_weight` sobre un nombre nuevo (que no existe en `item_frequency`) inserta fila con use_count=0.
|
|
- [ ] **15.1.3** `just db-types` para regenerar `packages/types/src/database.ts`.
|
|
|
|
---
|
|
|
|
#### 15.2 Store + query
|
|
|
|
**Fichero:** `apps/web/src/lib/stores/lists.ts` (extender `fetchSuggestions`), nuevo `apps/web/src/lib/stores/commonItems.ts`.
|
|
|
|
- [ ] **15.2.1** Extender `fetchSuggestions(collectiveId, prefix, options?)` con `options = { excludeNames?: string[]; limit?: number }`. Aplicar `.not('name', 'in', '(...)')` si `excludeNames` tiene >0 entries. Cambiar `.order('use_count')` por `.order('weight', { ascending: false }).order('use_count', { ascending: false })`.
|
|
- [ ] **15.2.2** En el callsite de `/lists/[id]/+page.svelte`, pasar `excludeNames = items.map(i => i.name.toLowerCase().trim())`. Recompute on items mutation (Svelte 5 `$derived`).
|
|
- [ ] **15.2.3** `commonItems.ts` store:
|
|
- `loadCommonItems(collectiveId)` — `select *` from `item_frequency` ordered por `weight desc, use_count desc`, sin limit.
|
|
- `setWeight(collectiveId, name, weight)` — invoca RPC `set_item_frequency_weight`.
|
|
- `purge(collectiveId, name)` — invoca RPC `purge_item_frequency`.
|
|
- [ ] **15.2.4** Tests integración `packages/test-utils/tests/common-items.test.ts`:
|
|
- CI-INT-01 admin set weight → la siguiente `fetchSuggestions` lo devuelve primero.
|
|
- CI-INT-02 `excludeNames` filtra correctamente.
|
|
- CI-INT-03 member intenta set weight → falla con P0002.
|
|
|
|
---
|
|
|
|
#### 15.3 UI de gestión
|
|
|
|
**Fichero:** `apps/web/src/routes/(app)/collective/manage/+page.svelte` (admin) — nueva subsección "Items frecuentes". Si la sección crece mucho, sacarla a `apps/web/src/routes/(app)/collective/manage/common-items/+page.svelte` (decidir según diseño).
|
|
|
|
**Decisión de ubicación:** `/collective/manage` y no `/settings`, porque es config de colectivo (cross-user). `/settings` queda para preferencias del propio usuario.
|
|
|
|
- [ ] **15.3.1** Tabla con columnas: `name | weight | use_count | last_used_at | acciones`. Sort default: `weight DESC, use_count DESC`. Search box (filtro client-side por substring).
|
|
- [ ] **15.3.2** Acciones por fila:
|
|
- Selector 3-way **Hide / Normal / Boost** — segmented control de 3 botones que escriben `weight = -50 / 0 / 50` respectivamente. Estado actual destacado visualmente. Sin números expuestos al usuario.
|
|
- Botón "Purgar" (icono trash) con modal de confirmación "Esto solo borra la entrada de sugerencias; los items existentes en listas no se tocan".
|
|
- [ ] **15.3.3** Botón "Añadir item común" — abre modal con input + el mismo selector 3-way (default `Boost`, weight=50). Llama a `setWeight(collective, name, 50)`. Util para sembrar el catálogo antes de que las listas reales generen `use_count`.
|
|
- [ ] **15.3.4** Si el colectivo no tiene un solo `item_frequency` (caso colectivo nuevo), mostrar empty state explicativo.
|
|
- [ ] **15.3.5** Members ven la tabla pero todas las acciones están deshabilitadas con tooltip "Solo admins pueden gestionar items comunes". Guests no ven la sección.
|
|
|
|
---
|
|
|
|
#### 15.4 Exclude-on-list en la sugerencia
|
|
|
|
**Fichero:** `apps/web/src/routes/(app)/lists/[id]/+page.svelte` + `apps/web/src/lib/components/ItemSuggestions.svelte`.
|
|
|
|
- [ ] **15.4.1** Pasar `excludeNames` a `fetchSuggestions` en el callsite de añadir item.
|
|
- [ ] **15.4.2** Cuando el usuario añade un item desde el dropdown, refrescar el set de exclusión inmediatamente (el siguiente teclazo en el input ya no debería ver ese item). Reactivo vía `$derived`.
|
|
- [ ] **15.4.3** Si tras filtrar `excludeNames` y aplicar `weight` el resultado es vacío, no mostrar el dropdown (no fallback a use_count puro — la idea es que la lista está completa).
|
|
|
|
---
|
|
|
|
#### 15.5 Tests
|
|
|
|
- [ ] **15.5.1** Vitest unit `tag-picker-filter.test.ts`-style para el ordenador local — si se decide hacer ordering client-side adicional (probablemente no — el server hace `order by` correcto).
|
|
- [ ] **15.5.2** Playwright `tests/e2e/common-items.test.ts`:
|
|
- **CI-01** Ana (admin) clickea `Boost` sobre "leche" en `/collective/manage` (escribe weight=50) → en `/lists/<id>` el dropdown de sugerencias muestra "leche" primero, aunque su use_count sea menor que otros items.
|
|
- **CI-02** Ana añade "pan" a la lista activa → al volver al input, las sugerencias dejan de incluir "pan".
|
|
- **CI-03** Borja (member) entra a `/collective/manage` → ve la tabla, los steppers están disabled.
|
|
- **CI-04** Ana purga "agua" → la fila desaparece y el item ya no aparece en sugerencias futuras (hasta que alguien la vuelva a añadir y el trigger la recree con weight=0).
|
|
|
|
---
|
|
|
|
#### 15.Z Verificación + cierre
|
|
|
|
- [ ] **15.Z.1** `just test-all` verde. Suma esperada: +7 pgTAP + 3 integración + 4 playwright = +14.
|
|
- [ ] **15.Z.2** Auditoría manual: crear un colectivo nuevo, añadir 5 listas con items repetidos, promover 2, purgar 1, verificar orden en sugerencias.
|
|
- [ ] **15.Z.3** Lighthouse contra `/collective/manage` con 100 items frecuentes (semilla) — no regresión.
|
|
- [ ] **15.Z.4** Documentar en `docs/history/fase-15-common-items.md`.
|
|
- [ ] **15.Z.5** Actualizar `messages/en.json` + `messages/es.json` con los strings nuevos.
|
|
|
|
---
|
|
|
|
### Riesgos / notas
|
|
|
|
- **Mapeo Hide/Normal/Boost → -50/0/50**: confirmado por el usuario en el momento de planificar. La columna `weight integer` no tiene check constraint para no atarse a estos 3 valores si más adelante hace falta granularidad (p.ej. distinguir "Boost fuerte" de "Boost suave"). El cliente solo escribe los 3 valores canónicos; cualquier otro valor en la DB es histórico/manual.
|
|
- **`item_frequency` no tiene columna `created_by`** — la fila la inserta un trigger SECURITY DEFINER. Si se quiere audit "quién promovió qué", añadir tabla `item_frequency_audit` aparte. Fuera de scope MVP.
|
|
- **Realtime no está activado sobre `item_frequency`** (verificar `007_realtime_publication.sql`). Si dos admins editan a la vez no se ven en vivo. Aceptable; admin UI hace polling al recargar la página.
|
|
- **Conflicto con tags (Fase 11)**: los tags son `m:n` sobre `shopping_items`, no `item_frequency`. Un item promovido sin tags no asocia tags por defecto al añadirse. Decisión: dejarlo así — promover y etiquetar son ejes ortogonales.
|
|
- **Coste del `not in (...)` con listas largas**: si una lista tiene 200 items, la query `excludeNames` pasa un array de 200 strings. PostgREST construye un `NOT IN ('a','b',...)` literal — coste O(n*m). Para listas <100 items es despreciable; para >>100 reconsiderar a un `LEFT JOIN ... WHERE shopping_items.id IS NULL` vía RPC.
|
|
|
|
### Scope explícito fuera
|
|
|
|
- Importar/exportar items comunes entre colectivos.
|
|
- Sugerencias de categorías (frutería / lácteos / …).
|
|
- Items comunes globales del servidor (cross-collective). Solo per-colectivo.
|
|
- Recomendaciones basadas en ML/historial cruzado.
|
|
- Tags pre-asignados a items promovidos.
|