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>
12 KiB
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":
- Promover items — algunos items aparecen siempre primero en las sugerencias (independientemente de
use_count). - 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— tablaitem_frequency (collective_id, name, use_count, last_used_at), escrita por trigger SECURITY DEFINER sobreshopping_itemsINSERT. Todos los INSERT/UPDATE/DELETE del cliente están bloqueados por policy.apps/web/src/lib/stores/lists.ts:275-300(fetchSuggestions) — ordena poruse_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_frequencyconweight 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
weightvía SECURITY DEFINER RPC — el bloqueo all-deny de la migración 006 se mantiene; solo la RPC nueva puede modificarweight. - Borrar fila de
item_frequencypor completo (purgar) vía otra RPC SECURITY DEFINER — útil para limpiar typos/ruido. No tocashopping_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 porweightcae aluse_countactual. - Exclude-on-list:
fetchSuggestionsrecibeexcludeNames: 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.
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_weightcomo admin → upsert OK.set_item_frequency_weightcomo member → P0002.purge_item_frequencycomo admin borra fila.purge_item_frequencycomo guest → P0002.set_item_frequency_weightsobre un nombre nuevo (que no existe enitem_frequency) inserta fila con use_count=0.
- 15.1.3
just db-typespara regenerarpackages/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?)conoptions = { excludeNames?: string[]; limit?: number }. Aplicar.not('name', 'in', '(...)')siexcludeNamestiene >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, pasarexcludeNames = items.map(i => i.name.toLowerCase().trim()). Recompute on items mutation (Svelte 5$derived). - 15.2.3
commonItems.tsstore:loadCommonItems(collectiveId)—select *fromitem_frequencyordered porweight desc, use_count desc, sin limit.setWeight(collectiveId, name, weight)— invoca RPCset_item_frequency_weight.purge(collectiveId, name)— invoca RPCpurge_item_frequency.
- 15.2.4 Tests integración
packages/test-utils/tests/common-items.test.ts:- CI-INT-01 admin set weight → la siguiente
fetchSuggestionslo devuelve primero. - CI-INT-02
excludeNamesfiltra correctamente. - CI-INT-03 member intenta set weight → falla con P0002.
- CI-INT-01 admin set weight → la siguiente
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 / 50respectivamente. 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".
- Selector 3-way Hide / Normal / Boost — segmented control de 3 botones que escriben
- 15.3.3 Botón "Añadir item común" — abre modal con input + el mismo selector 3-way (default
Boost, weight=50). Llama asetWeight(collective, name, 50). Util para sembrar el catálogo antes de que las listas reales generenuse_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
excludeNamesafetchSuggestionsen 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
excludeNamesy aplicarweightel 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 haceorder bycorrecto). - 15.5.2 Playwright
tests/e2e/common-items.test.ts:- CI-01 Ana (admin) clickea
Boostsobre "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).
- CI-01 Ana (admin) clickea
15.Z Verificación + cierre
- 15.Z.1
just test-allverde. 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/managecon 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.jsoncon los strings nuevos.
Riesgos / notas
- Mapeo Hide/Normal/Boost → -50/0/50: confirmado por el usuario en el momento de planificar. La columna
weight integerno 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_frequencyno tiene columnacreated_by— la fila la inserta un trigger SECURITY DEFINER. Si se quiere audit "quién promovió qué", añadir tablaitem_frequency_auditaparte. Fuera de scope MVP.- Realtime no está activado sobre
item_frequency(verificar007_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:nsobreshopping_items, noitem_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 queryexcludeNamespasa un array de 200 strings. PostgREST construye unNOT IN ('a','b',...)literal — coste O(n*m). Para listas <100 items es despreciable; para >>100 reconsiderar a unLEFT JOIN ... WHERE shopping_items.id IS NULLví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.