# Fase 15 — Common items management (weighted suggestions + exclude-on-list) **MVP2 · 7/7. Re-opened the cycle after Fase 14 with two concrete asks on the "add item" flow.** **Status: ✅ Shipped 2026-05-18.** Two user-driven additions on top of the existing `item_frequency` table from migration 006: 1. **Promote items** — admins can boost specific names so they always lead the suggestion strip, regardless of historical `use_count`. 2. **Hide items already on the list** — the dropdown no longer nudges the user to re-add names that are already present in the active list. Plan: `plan/fase-15-common-items.md`. Migration: `supabase/migrations/026_item_frequency_weight.sql`. pgTAP: `supabase/tests/018_item_frequency_weight.sql`. ## What changed ### 15.1 — `item_frequency.weight` + admin RPCs - `weight integer NOT NULL DEFAULT 0` added to `item_frequency`. No CHECK constraint — the column stays open so future tiers ("strong boost", "soft hide") don't force another schema diff. The UI is the contract for the 3-way mapping, not the DB. - `item_frequency_collective_count_idx` dropped, replaced by `item_frequency_collective_order_idx (collective_id, weight DESC, use_count DESC, last_used_at DESC)` so the suggestion query scans in order without a sort step. - Two SECURITY DEFINER RPCs gate writes: - `public.set_item_frequency_weight(p_collective_id uuid, p_name text, p_weight integer)` — upsert: if the row doesn't exist, inserts with `use_count = 0`; if it does, sets `weight`. Raises `P0002` for non-admins, `P0001` for unauthenticated / empty name. - `public.purge_item_frequency(p_collective_id uuid, p_name text)` — deletes the row from `item_frequency` only. Does NOT touch `shopping_items` (the modal copy explicitly says this; existing items on lists are unaffected). The next time someone adds that name to a list, the existing trigger recreates the row with `weight = 0`. - The deny-all INSERT/UPDATE/DELETE policies from migration 006 are preserved; the RPCs are the only write path. Both `GRANT EXECUTE ... TO authenticated` so PostgREST will call them. ### 15.2 — Store + query - `fetchSuggestions(collectiveId, prefix, options?)` in `apps/web/src/lib/stores/lists.ts` extended with `{ excludeNames?: string[]; limit?: number }`. Ordering switches from `.order('use_count')` to `weight DESC, use_count DESC, last_used_at DESC`. `excludeNames` is lowercased + trimmed + deduped before being sent; when empty, the `.not()` filter is skipped entirely (PostgREST renders an empty `(...)` list as a SQL error, and zero entries would mean zero effect anyway). When the list exceeds `EXCLUDE_NAMES_HARD_CAP = 200`, the filter degrades to a no-op rather than fail the whole query — past that point we'd risk hitting Kong's ~16 KiB request URI cap (see "Gotchas" below). - New `apps/web/src/lib/stores/commonItems.ts` is the unbounded counterpart used by the manage view: `loadCommonItems` (full catalogue, same ordering, no `LIMIT`), `setWeight` (RPC + optimistic in-store update), `purge` (RPC + optimistic remove). ### 15.3 — Manage UI `apps/web/src/routes/(app)/collective/manage/+page.svelte` gains a new "Common items" subsection visible to admins + members (guests see nothing): - Table per row: name, last_used + use_count blurb, a 3-way **Hide / Normal / Boost** segmented control that writes `weight = -50 / 0 / 50`, a small "Remove" button that opens a confirmation modal explaining "items already in lists are not touched". - Members see the same table but every action button is disabled with a tooltip "Only admins can manage common items". - "Add common item" button (admin only) — opens a modal with a name input + the same 3-way control, defaulting to **Boost**. Useful for seeding the catalogue before lists actually mention an item. - Client-side substring search filters the table; empty / filtered-empty states have their own copy. - `data-weight-state` attribute on each `
  • ` exposes the current visual state to the e2e suite so the segmented control is testable without scraping classnames. - The segmented control is a row of three `