# Fase 11 — Item tags + importance UX (MVP2 3/6)

**Status: ✅ complete · 2026-05-18.**

Third slice of the MVP2 cycle. Adds collective-scoped item tags (model + UI + filter) and closes the loop on drag-reorder by making remote reorders visible without a reload.

Final test counts: **104 pgTAP + 157 Vitest integration + 45 Vitest unit + 79 Playwright = 385 green; 3 skipped (2 upstream Realtime presence + 1 gated rate-limit).** Delta vs Fase 10:

| Suite | Before | After | Δ |
|---|---|---|---|
| pgTAP | 96 | 104 | +8 |
| Vitest integration | 151 | 157 | +6 |
| Vitest unit | 38 | 45 | +7 |
| Playwright E2E | 74 | 79 | +5 |
| **Total** | **359** | **385** | **+26** |

---

## 11.1 — Tag model

**Migration `022_item_tags.sql`** — two new tables under `public`:

- `item_tags(id, collective_id, name, color, created_at)` with `UNIQUE (collective_id, name)` and `CHECK color IN ('slate','red','amber','green','sky','indigo','pink','stone')`. Scope is collective: the same string in two collectives is two distinct rows.
- `shopping_item_tags(item_id, tag_id)` many-to-many bridge, both ends `ON DELETE CASCADE`.

RLS:

- `item_tags`: `is_member(collective_id)` for SELECT (guests included), `is_active_member(collective_id)` for INSERT / UPDATE / DELETE (guests read-only).
- `shopping_item_tags`: gated via a new `item_collective_id(uuid)` STABLE helper that resolves the parent collective in a single function call. The INSERT policy also enforces `item.collective = tag.collective` so a foreign tag cannot be attached to a home item.

Both tables join `supabase_realtime` with `REPLICA IDENTITY FULL`, matching `007_realtime_publication.sql`.

`packages/types/src/database.ts` and `domain.ts` hand-extended with `ItemTag`, `ItemWithTags`, `ItemTagColor`, and the new `item_collective_id` Function entry. `just db-types` is gated and skips with a notice; the Supabase CLI is not installed on this host.

**pgTAP `014_item_tags.sql`** — 8 schema-level assertions: publication membership × 2, REPLICA IDENTITY × 2, UNIQUE violation, CHECK violation, cascade on tag delete, cascade on collective delete. RLS semantics (which roles can write, cross-collective attach rejection, no read from foreign collective) are covered by Vitest integration because pgTAP runs as superuser.

---

## 11.2 — Stores + domain types

**`apps/web/src/lib/stores/tags.ts`** (new): collective-scoped store with `loadTags(collectiveId)`, `createTag`, `renameTag`, `recolorTag`, `deleteTag`, `attachTag(itemId, tagId)`, `detachTag(itemId, tagId)`, and a realtime subscription per active collective. Re-subscribes when the active collective changes. Per the CLAUDE.md GoTrue-lock gotcha, this store is created from `onMount` lifecycles only, never from inside `onAuthStateChange`.

**`apps/web/src/lib/stores/lists.ts`** — new `loadListItems(listId)` issues a single PostgREST embedding (`*, shopping_item_tags(item_tags(*))`) and flattens to `ItemWithTags[]`. The cast goes through `unknown` because the curated `database.ts` does not declare the `shopping_items ↔ shopping_item_tags` relationship; runtime resolves it from the FK. The original `loadItems(listId)` is kept for tests + the realtime fallback path.

**Vitest integration `tests/rls-item-tags.test.ts`** — 6 IT-series assertions: member create OK, guest reject, non-member empty read, foreign-tag attach rejected, UNIQUE violation, cascade on item delete.

---

## 11.3 — UI: chips, picker, modal, filter, settings

**`TagChip.svelte`** — pill (`text-xs px-2 py-0.5 rounded-full`) with `bg-{color}-100 text-{color}-700` and a `dark:` variant. Three usage shapes:

- display-only `<span>`
- `onRemove` → renders an inline X (used by picker-selected chips)
- `onSelect` → renders the whole chip as a single `<button>` (used by the filter bar and item-row chips that filter the list on click)

`onSelect` and `onRemove` are mutually exclusive — supplying both was the SSR-warning bug from the first cut (`<button>` cannot nest inside `<button>`).

**`TagPicker.svelte`** — combobox driven by `filterTagOptions(...)` (pure helper in `tag-picker-filter.ts` so it's unit-testable). Substring match, case-insensitive, with a "Create `{query}`" affordance shown only when no existing tag matches the trimmed query exactly. Enter key prefers toggling the first match, falls back to create. Re-used from the item-edit modal, the per-list filter bar, and the settings tag list.

**Unit test `tag-picker-filter.test.ts`** — 7 TP-series cases: substring filter, empty-query passthrough, create gating, exact-match disables create, exclude already-selected ids, whitespace trim, verbatim createName preservation.

**Tailwind safelist** for the runtime-built `bg-/text-/ring-/dot` classes for all 8 preset colours (Tailwind JIT can't see dynamic class names).

**`/lists/[id]/+page.svelte`** — the largest single change. Switches cold-load to `loadListItems()`, types `items` as `ItemWithTags[]`. Adds:

- item-row chips on a wrap-friendly second line below the name; each chip is its own filter button
- TagPicker section inside the existing edit overlay (attach + detach are optimistic; "Create" creates the tag and attaches it in one click)
- filter bar between the title and the list, with active-tags-first ordering and a "Clear" button. Filter is intersection (`every`).
- query-string persistence (`?tags=foo,bar`) via `history.replaceState` so URLs are deep-linkable
- second realtime channel on `shopping_item_tags *` that re-fetches the affected item's tags from PostgREST (the delta payload has no joined tag rows; refetch is simplest)
- drag-reorder handlers merge `e.detail.items` with EVERY off-zone row (both the checked half AND any rows hidden by an active tag filter)

**E2E `tests/e2e/tags.test.ts`** — TG-01 full loop (create, attach to 3 items, filter, assert 3 visible), TG-02 deep-link via `?tags=a,b` intersection, TG-03 sanity check that the picker renders for Borja in the same collective (cross-collective denial is covered by IT-03).

**Settings — Tags section** (`/settings`): inline list of every tag in the active collective with rename (tap chip → edit → Enter), recolor (8-dot swatch row), and delete. Uses the same `tagsStore` realtime subscription, so concurrent edits propagate live.

Messages: 9 new keys in `en` + `es`.

---

## 11.4 — Drag-reorder UI

The drag handle + `svelte-dnd-action` `dndzone` + `reorderItems` batch UPDATE were already in place from Fase 5.10. The missing piece was the **realtime side**: when another client reordered, the local `applyItemEvent` shallow-merged the new payload onto the existing row but did NOT re-position it in the array, so the new order stayed invisible until reload.

**Fix in `+page.svelte`**: sort the `items` array by `sort_order` after every realtime UPDATE / INSERT merge. Cheap (n is small per list) and matches the cold-load order.

**E2E `tests/e2e/reorder-items.test.ts`**:

- RO-01: create 3 items, write a new `sort_order` (via the same PATCH the page's `reorderItems` helper does), reload, assert reorder persisted.
- RO-02: dual-context realtime, Ana reorders, Borja sees the new order without a refresh within the 10 s window — this test failed before the sort fix.

**Drag emulation note**: headless Chromium handles `svelte-dnd-action`'s native HTML5 DnD poorly. Both tests drive the same write path the page uses (`reorderItems` = batch PATCH on `sort_order`) via `fetch` from inside the browser context, using the session token the SDK has already stored. Realtime + reload paths are unchanged. Documented inside the test file.

---

## Out of plan / explicitly skipped

- **11.4.1 long-press-to-enable-drag on mobile**: the page already wires long-press to enter selection mode (Fase 5.10). Having a second long-press meaning (arm drag) would clash. Drag handle stays always-visible — small icon, low cost. Documented in the source comments.
- **11.4.4 long-press vs swipe-delete reconciliation**: not needed because the drag handle is its own dedicated `pointerdown` target, isolated from the row body.
- **11.Z.2 Lighthouse on 50 items + 8 tags**: not run in CI; will be checked manually during the next Lighthouse pass.

---

## Risks the plan flagged

- **Embedding shape from PostgREST** — confirmed working. The runtime returns `{ ...item, shopping_item_tags: [{ item_tags: {...tag} }] }`; the loader flattens to `tags: ItemTag[]`. The typed cast goes through `unknown` because the curated `database.ts` doesn't declare the cross-table relationship.
- **`is_member` double subquery in `shopping_item_tags` policy** — mitigated upfront by introducing `item_collective_id(uuid)` STABLE helper that resolves the collective in a single function call. The planner gets one STABLE call per row, not two.

---

## File map (new)

- `supabase/migrations/022_item_tags.sql`
- `supabase/tests/014_item_tags.sql`
- `packages/test-utils/tests/rls-item-tags.test.ts`
- `apps/web/src/lib/stores/tags.ts`
- `apps/web/src/lib/components/TagChip.svelte`
- `apps/web/src/lib/components/TagPicker.svelte`
- `apps/web/src/lib/components/tag-picker-filter.ts`
- `apps/web/src/lib/components/tag-picker-filter.test.ts`
- `apps/web/tests/e2e/tags.test.ts`
- `apps/web/tests/e2e/reorder-items.test.ts`

Modified:

- `apps/web/src/lib/stores/lists.ts` — new `loadListItems`
- `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — tags wiring + filter bar + realtime re-sort
- `apps/web/src/routes/(app)/settings/+page.svelte` — Tags section
- `apps/web/messages/{en,es}.json` — 9 keys
- `apps/web/tailwind.config.ts` — safelist for the 8 tag colours
- `packages/types/src/database.ts` + `domain.ts` — `ItemTag`, `ItemWithTags`, `ItemTagColor`, `item_collective_id` Function
