Files
collective-lists/plan/fase-15-common-items.md
Oier Bravo Urtasun b994fc7c95 docs(plan): add Fase 15 — common items management (MVP2 reopened to 7 fases)
Adds a planned-phase doc for the next ask on top of the closed MVP2 cycle:
weighted suggestion ordering + exclude items already on the active list.

- Extends item_frequency (migration 026) with `weight integer` written via
  two admin-only SECURITY DEFINER RPCs (set_item_frequency_weight +
  purge_item_frequency). The client never writes weight directly — the
  existing all-deny policies on item_frequency stay in place.
- fetchSuggestions reorders by `weight desc, use_count desc, last_used_at
  desc` and accepts an excludeNames arg to drop already-added items from
  the dropdown.
- UI lives in /collective/manage as a 3-way segmented control per row
  (Hide / Normal / Boost mapping to -50 / 0 / 50). Admin writes; member
  read-only; guest hidden.

Rebrand: MVP2 reopens from 6/6  to in-progress 6/7 , with Fase 15
pending. Headers in fases 9–14 bump from "·N/6" + "fases 9 → 14" to
"·N/7" + "fases 9 → 15". README status table + index updated, CLAUDE.md
project status appends the Fase 15 pending paragraph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:08:00 +02:00

12 KiB

Fase 15 — Common items management (weighted suggestions + exclude-on-list)

MVP2 · 7/7. 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.

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.