diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte index 58296e7..68c3ca5 100644 --- a/apps/web/src/routes/(app)/lists/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -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(null); - let items = $state([]); + let items = $state([]); 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([]); + + 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 | null = null; @@ -73,11 +114,18 @@ let suggestionsTimer: ReturnType; // Double-tap edit overlay (replaces inline edit) - let overlayItem = $state(null); + let overlayItem = $state(null); let overlayName = $state(''); let lastTapById = new Map(); 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(); + 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((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} + + {#if filterBarTags.length > 0 && !selectionMode} +
+ {#each filterBarTags as tag (tag.id)} + toggleTagFilter(tag.name)} + /> + {/each} + {#if activeTagNames.length > 0} + + {/if} +
+ {/if} + {#if uncheckedItems.length === 0 && checkedItems.length === 0}

{m.list_items_empty()}

@@ -803,20 +983,37 @@ {/if} - - + +
+ + {#if !selectionMode && item.tags.length > 0} +
+ {#each item.tags as tag (tag.id)} + toggleTagFilter(tag.name)} + /> + {/each} +
+ {/if} +
{#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" /> + + +
+

+ {m.tag_picker_label()} +

+ { + 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); + }} + /> +
+