feat(fase-11): tags on item row + edit modal + list filter bar (11.3.3-5)

Wires the tag model into the list detail view end-to-end.

`/lists/[id]/+page.svelte`:
  * switches the cold-load to loadListItems() so every row carries its
    tag array via the PostgREST embedding (one query, not N+1)
  * items state typed as ItemWithTags; the existing realtime shopping_items
    feed shallow-merges the row payload back onto the cached tag array,
    so attaches survive an UPDATE echo for the same row
  * new tagLinkChannel subscribes to shopping_item_tags `*` events and
    refreshes just the affected item's tags from the server — the delta
    payload doesn't carry the joined tag rows, refetching is simplest
  * item-row chips render below the name on a separate wrap-friendly
    line; each chip is its own filter button (TagChip onSelect)
  * filter bar between the title and the list shows the collective's
    tags (active ones first); selecting toggles a tag; multi-selection
    is intersection; the selection persists in `?tags=foo,bar` via
    history.replaceState so the URL is deep-linkable
  * edit overlay gains a "Tags" section powered by TagPicker — attach +
    detach are optimistic and write through to shopping_item_tags; the
    "Create" affordance creates the tag then attaches it in one click

Drag-reorder handlers were updated to merge `e.detail.items` (the dndzone
subset, which is the post-filter unchecked rows) with EVERY off-zone row,
not just the checked half. Without this, dragging while a tag filter is
active would drop hidden rows from `items`.

E2E (tags.test.ts): TG-01 verifies the full create → attach → filter
loop (3 items shown after activating the filter, seed items hidden); TG-02
deep-links via `?tags=a,b` and asserts intersection; TG-03 sanity-checks
the picker for a second user in the same collective (cross-collective
denial is fully covered by the Vitest IT-03 integration assertion).

All 3 e2e tests pass against the dev stack.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:16:03 +02:00
parent 8fa629ad74
commit f28577e165
2 changed files with 461 additions and 32 deletions

View File

@@ -8,7 +8,7 @@
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import {
loadItems,
loadListItems,
addItem,
updateItem,
checkItem,
@@ -31,9 +31,18 @@
import { enqueueOp, hydrateSyncState } from '$lib/sync';
import { scheduleUndoable } from '$lib/sync/undoQueue';
import SyncBanner from '$lib/components/SyncBanner.svelte';
import TagChip from '$lib/components/TagChip.svelte';
import TagPicker from '$lib/components/TagPicker.svelte';
import {
tags as tagsStore,
loadTags,
createTag,
attachTag,
detachTag
} from '$lib/stores/tags';
import { getSupabase } from '$lib/supabase';
import { generateId } from '$lib/utils/id';
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
import type { ShoppingItem, ShoppingList, ItemTag, ItemWithTags } from '@colectivo/types';
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
import {
ArrowLeft,
@@ -55,9 +64,41 @@
// ── State ──────────────────────────────────────────────────────────────────
let list = $state<ShoppingList | null>(null);
let items = $state<ShoppingItem[]>([]);
let items = $state<ItemWithTags[]>([]);
let loading = $state(true);
// Tags ── filter selection lives in the URL query string ("?tags=foo,bar")
// so a filtered view can be deep-linked + shared. Filter is intersection.
let activeTagNames = $state<string[]>([]);
function parseTagsFromQuery(): string[] {
const raw = $page.url.searchParams.get('tags');
if (!raw) return [];
return raw
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
async function setActiveTagNames(next: string[]) {
activeTagNames = next;
const url = new URL($page.url);
if (next.length === 0) {
url.searchParams.delete('tags');
} else {
url.searchParams.set('tags', next.join(','));
}
await goto(url.pathname + url.search, { replaceState: true, noScroll: true, keepFocus: true });
}
function toggleTagFilter(name: string) {
if (activeTagNames.includes(name)) {
void setActiveTagNames(activeTagNames.filter((n) => n !== name));
} else {
void setActiveTagNames([...activeTagNames, name]);
}
}
// Inline rename of the list (replaces the old /lists sticky input flow).
let listName = $state('');
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
@@ -73,11 +114,18 @@
let suggestionsTimer: ReturnType<typeof setTimeout>;
// Double-tap edit overlay (replaces inline edit)
let overlayItem = $state<ShoppingItem | null>(null);
let overlayItem = $state<ItemWithTags | null>(null);
let overlayName = $state('');
let lastTapById = new Map<string, number>();
const DOUBLE_TAP_WINDOW_MS = 300;
// Live view of the overlay's tags (kept in sync with the items store so
// the picker re-renders after attach/detach).
const overlayTags = $derived(
overlayItem ? items.find((i) => i.id === overlayItem!.id)?.tags ?? [] : []
);
const overlayTagIds = $derived(overlayTags.map((t) => t.id));
// Symmetric toggle swipe — replaces the old swipe-to-delete.
// Unchecked + swipe right → check. Checked + swipe left → uncheck.
// The opposite direction for each state is a no-op (snaps back).
@@ -203,8 +251,37 @@
// ── Derived ────────────────────────────────────────────────────────────────
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
const checkedItems = $derived(items.filter((i) => i.is_checked));
// Apply the tag filter (intersection) on top of the checked split. If no
// tags are active, all items pass through. Matching is by tag NAME so a
// `?tags=foo` URL keeps working after a tag is renamed elsewhere (the
// URL chip would just disappear — acceptable for MVP).
const filteredItems = $derived(
activeTagNames.length === 0
? items
: items.filter((i) =>
activeTagNames.every((name) => i.tags.some((t) => t.name === name))
)
);
const uncheckedItems = $derived(filteredItems.filter((i) => !i.is_checked));
const checkedItems = $derived(filteredItems.filter((i) => i.is_checked));
// Tag chips to show in the filter bar — show the active filters first,
// then the rest of the collective's tags. Capped to keep the bar lean.
const filterBarTags = $derived.by(() => {
const ordered: ItemTag[] = [];
const seen = new Set<string>();
for (const name of activeTagNames) {
const tag = $tagsStore.find((t) => t.name === name);
if (tag) {
ordered.push(tag);
seen.add(tag.id);
}
}
for (const tag of $tagsStore) {
if (!seen.has(tag.id)) ordered.push(tag);
}
return ordered.slice(0, 20);
});
// ── Load + Realtime ────────────────────────────────────────────────────────
@@ -217,7 +294,7 @@
onMount(async () => {
const [listRes, itemsRes] = await Promise.all([
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
loadItems(listId)
loadListItems(listId)
]);
if (listRes.error || !listRes.data) {
@@ -228,10 +305,13 @@
list = listRes.data as ShoppingList;
listName = list.name ?? '';
items = itemsRes;
activeTagNames = parseTagsFromQuery();
loading = false;
if ($currentCollective) {
suggestions = await fetchSuggestions($currentCollective.id, '');
// Load tags for this collective — picker + filter need them.
await loadTags($currentCollective.id);
}
await hydrateSyncState();
@@ -246,17 +326,80 @@
i.sort_order === evt.row.sort_order
);
if (optimistic) {
items = items.map((i) => (i.id === optimistic.id ? evt.row : i));
items = items.map((i) =>
i.id === optimistic.id ? { ...evt.row, tags: i.tags } : i
);
pendingTempIds.delete(optimistic.id);
return;
}
}
items = applyItemEvent(items, evt);
// applyItemEvent operates on ShoppingItem fields only — it preserves
// `tags: ItemTag[]` on the existing row when shallow-merging the new
// UPDATE payload. INSERTs of brand-new rows arrive without tags
// (they're attached separately); seed an empty array.
const next = applyItemEvent(items as unknown as ShoppingItem[], evt);
items = next.map((row) => {
const existing = items.find((i) => i.id === row.id);
return { ...row, tags: existing?.tags ?? [] };
});
});
// Listen for shopping_item_tags changes so attach/detach in another tab
// or device propagates. We re-load the affected row's tags rather than
// trying to mutate the local cache from the delta payload.
tagLinkChannel = getSupabase().channel(`list:${listId}:item-tags`);
tagLinkChannel.on(
'postgres_changes' as never,
{
event: '*',
schema: 'public',
table: 'shopping_item_tags'
},
async (payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: { item_id?: string };
old: { item_id?: string };
}) => {
const itemId = payload.new?.item_id ?? payload.old?.item_id;
if (!itemId) return;
// Only refresh if the item is in our list.
if (!items.some((i) => i.id === itemId)) return;
const { data } = await getSupabase()
.from('shopping_item_tags')
.select('item_tags(*)')
.eq('item_id', itemId);
type Row = { item_tags: ItemTag | null };
const nextTags = ((data as Row[] | null) ?? [])
.map((r) => r.item_tags)
.filter((t): t is ItemTag => t !== null);
items = items.map((i) => (i.id === itemId ? { ...i, tags: nextTags } : i));
}
);
await new Promise<void>((resolve) => {
tagLinkChannel!.subscribe((status) => {
if (status === 'SUBSCRIBED') resolve();
else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
// don't block the page render on this aux channel
resolve();
}
});
});
});
// Channel handle for shopping_item_tags realtime updates.
let tagLinkChannel: import('@supabase/supabase-js').RealtimeChannel | null = null;
onDestroy(async () => {
await realtimeSub?.unsubscribe();
if (tagLinkChannel) {
try {
await tagLinkChannel.unsubscribe();
await getSupabase().removeChannel(tagLinkChannel);
} catch {
// idempotent
}
tagLinkChannel = null;
}
if (nameSaveTimer) {
clearTimeout(nameSaveTimer);
await flushNameSave();
@@ -308,7 +451,7 @@
const sortOrder = items.length;
const tempId = generateId();
const optimistic: ShoppingItem = {
const optimistic: ItemWithTags = {
id: tempId,
list_id: listId,
name,
@@ -318,7 +461,8 @@
checked_at: null,
sort_order: sortOrder,
created_by: $currentUser.id,
created_at: new Date().toISOString()
created_at: new Date().toISOString(),
tags: []
};
// Use tempId as the REAL server id. The server accepts a client-provided
@@ -416,7 +560,8 @@
}
function openOverlay(item: ShoppingItem) {
overlayItem = item;
const full = items.find((i) => i.id === item.id) ?? null;
overlayItem = full ?? { ...item, tags: [] };
overlayName = item.name;
}
@@ -523,15 +668,22 @@
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: ShoppingItem[] }>) {
const checked = items.filter((i) => i.is_checked);
items = [...e.detail.items, ...checked];
// Dnd handlers. The dndzone only sees `uncheckedItems` (post-filter), so
// when a tag filter is active we must merge the reordered subset back with
// the rows hidden by the filter (unchecked-but-filtered-out) AND the
// checked rows. Without this, dragging while a filter is on would drop
// every off-screen row from `items`.
function handleDndConsider(e: CustomEvent<{ items: ItemWithTags[] }>) {
const reorderedIds = new Set(e.detail.items.map((i) => i.id));
const untouched = items.filter((i) => !reorderedIds.has(i.id));
items = [...e.detail.items, ...untouched];
}
async function handleDndFinalize(e: CustomEvent<{ items: ShoppingItem[] }>) {
const checked = items.filter((i) => i.is_checked);
async function handleDndFinalize(e: CustomEvent<{ items: ItemWithTags[] }>) {
const reordered = e.detail.items;
items = [...reordered, ...checked];
const reorderedIds = new Set(reordered.map((i) => i.id));
const untouched = items.filter((i) => !reorderedIds.has(i.id));
items = [...reordered, ...untouched];
await reorderItems(reordered);
}
@@ -709,6 +861,34 @@
{/if}
</div>
<!-- Tag filter bar (only renders when the collective has tags).
Selecting toggles a tag — multiple selected = intersection. -->
{#if filterBarTags.length > 0 && !selectionMode}
<div
data-testid="list-tag-filter"
class="flex flex-wrap items-center gap-1.5 px-4 pb-2 md:px-8"
aria-label={m.list_filter_by_tag()}
>
{#each filterBarTags as tag (tag.id)}
<TagChip
name={tag.name}
color={tag.color}
active={activeTagNames.includes(tag.name)}
onSelect={() => toggleTagFilter(tag.name)}
/>
{/each}
{#if activeTagNames.length > 0}
<button
type="button"
onclick={() => setActiveTagNames([])}
class="rounded-full px-2 py-0.5 text-xs font-medium text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
{m.list_filter_clear()}
</button>
{/if}
</div>
{/if}
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>
@@ -803,20 +983,37 @@
</button>
{/if}
<!-- Name as button; tap=nothing/toggle-selection, dbltap=overlay -->
<button
type="button"
onclick={() => {
if (selectionMode) toggleSelection(item.id);
else onRowTap(item);
}}
class="flex-1 min-w-0 text-left"
aria-label={item.name}
>
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
</button>
<!-- Name + tag chips. The name button stays a button (tap = noop /
toggle-selection, dbltap = open overlay). Tag chips render on
a second line and are themselves clickable filter triggers,
so they live OUTSIDE the name button. -->
<div class="flex-1 min-w-0">
<button
type="button"
onclick={() => {
if (selectionMode) toggleSelection(item.id);
else onRowTap(item);
}}
class="block w-full text-left"
aria-label={item.name}
>
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
</button>
{#if !selectionMode && item.tags.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each item.tags as tag (tag.id)}
<TagChip
name={tag.name}
color={tag.color}
active={activeTagNames.includes(tag.name)}
onSelect={() => toggleTagFilter(tag.name)}
/>
{/each}
</div>
{/if}
</div>
<!-- Quantity stepper — hidden in selection mode -->
{#if !selectionMode}
@@ -1057,6 +1254,43 @@
autofocus
class="mb-4 w-full rounded-md bg-background px-3 py-2 text-base text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
/>
<!-- Tags section. Inline create + toggle; mutations are awaited so the
surrounding `items` array reflects the new tag arrays before the
overlay re-renders the picker. -->
<div class="mb-4">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-text-secondary">
{m.tag_picker_label()}
</p>
<TagPicker
availableTags={$tagsStore}
selectedIds={overlayTagIds}
onToggle={async (tag) => {
if (!overlayItem) return;
const id = overlayItem.id;
const already = overlayTagIds.includes(tag.id);
// Optimistic: patch the item's tag list immediately.
items = items.map((i) =>
i.id !== id
? i
: { ...i, tags: already ? i.tags.filter((t) => t.id !== tag.id) : [...i.tags, tag] }
);
if (already) await detachTag(id, tag.id);
else await attachTag(id, tag.id);
}}
onCreate={async (name) => {
if (!overlayItem || !$currentCollective) return;
const id = overlayItem.id;
const created = await createTag($currentCollective.id, name);
if (!created) return;
items = items.map((i) =>
i.id !== id ? i : { ...i, tags: [...i.tags, created] }
);
await attachTag(id, created.id);
}}
/>
</div>
<div class="flex items-center justify-between gap-2">
<button
type="button"