feat: Fase 2a — shopping lists CRUD

DB: shopping_lists + shopping_items tables with full RLS, pg_cron jobs
(archive completed lists after 7 days, purge trash after 7 days).
item_frequency table with SECURITY DEFINER trigger that normalises and
upserts on every shopping_items INSERT.

Frontend: /lists overview (featured card + 2-col grid + trash toggle),
/lists/[id] detail (qty stepper, inline edit, swipe-to-reveal-delete,
drag & drop reorder via svelte-dnd-action, ItemSuggestions chips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 22:25:31 +02:00
parent ce1bcfb7a6
commit 3af1276c15
11 changed files with 1609 additions and 12 deletions

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { ItemFrequency } from '@colectivo/types';
interface Props {
suggestions: ItemFrequency[];
activePrefix: string;
onselect: (name: string) => void;
}
let { suggestions, activePrefix, onselect }: Props = $props();
</script>
{#if suggestions.length > 0}
<div class="flex flex-wrap gap-1.5 px-4 pb-2 pt-1">
{#each suggestions as item (item.name)}
<button
type="button"
onclick={() => onselect(item.name)}
class="rounded-full px-3 py-1 text-[13px] font-medium transition-colors
{activePrefix && item.name.startsWith(activePrefix.toLowerCase().trim())
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{item.name}
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,215 @@
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types';
// ── Global list store (overview page) ─────────────────────────────────────────
export const lists = writable<ShoppingList[]>([]);
export const listsLoading = writable(false);
export async function loadLists(collectiveId: string) {
listsLoading.set(true);
const { data, error } = await getSupabase()
.from('shopping_lists')
.select('*')
.eq('collective_id', collectiveId)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (!error && data) {
lists.set(data as ShoppingList[]);
}
listsLoading.set(false);
}
export async function loadTrashedLists(collectiveId: string): Promise<ShoppingList[]> {
const { data, error } = await getSupabase()
.from('shopping_lists')
.select('*')
.eq('collective_id', collectiveId)
.not('deleted_at', 'is', null)
.order('deleted_at', { ascending: false });
if (error || !data) return [];
return data as ShoppingList[];
}
// ── List mutations (optimistic) ───────────────────────────────────────────────
export async function createList(
collectiveId: string,
name: string,
userId: string
): Promise<ShoppingList | null> {
const tempId = crypto.randomUUID();
const optimistic: ShoppingList = {
id: tempId,
collective_id: collectiveId,
name,
status: 'active',
created_by: userId,
created_at: new Date().toISOString(),
completed_at: null,
deleted_at: null
};
lists.update((ls) => [optimistic, ...ls]);
const { data, error } = await getSupabase()
.from('shopping_lists')
.insert({ collective_id: collectiveId, name, created_by: userId })
.select()
.single();
if (error || !data) {
lists.update((ls) => ls.filter((l) => l.id !== tempId));
return null;
}
const real = data as ShoppingList;
lists.update((ls) => ls.map((l) => (l.id === tempId ? real : l)));
return real;
}
export async function renameList(id: string, name: string) {
lists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l)));
await getSupabase().from('shopping_lists').update({ name }).eq('id', id);
}
export async function softDeleteList(id: string) {
lists.update((ls) => ls.filter((l) => l.id !== id));
await getSupabase()
.from('shopping_lists')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
}
export async function restoreList(id: string, collectiveId: string) {
await getSupabase().from('shopping_lists').update({ deleted_at: null }).eq('id', id);
await loadLists(collectiveId);
}
export async function permanentDeleteList(id: string) {
await getSupabase().from('shopping_lists').delete().eq('id', id);
}
export async function archiveList(id: string) {
lists.update((ls) => ls.filter((l) => l.id !== id));
await getSupabase().from('shopping_lists').update({ status: 'archived' }).eq('id', id);
}
export async function completeList(id: string) {
const completed_at = new Date().toISOString();
lists.update((ls) =>
ls.map((l) => (l.id === id ? { ...l, status: 'completed' as const, completed_at } : l))
);
await getSupabase()
.from('shopping_lists')
.update({ status: 'completed', completed_at })
.eq('id', id);
}
export async function resetList(id: string) {
lists.update((ls) =>
ls.map((l) =>
l.id === id ? { ...l, status: 'active' as const, completed_at: null } : l
)
);
await Promise.all([
getSupabase()
.from('shopping_lists')
.update({ status: 'active', completed_at: null })
.eq('id', id),
getSupabase()
.from('shopping_items')
.update({ is_checked: false, checked_by: null, checked_at: null })
.eq('list_id', id)
]);
}
// ── Item operations (used by [id] page) ───────────────────────────────────────
export async function loadItems(listId: string): Promise<ShoppingItem[]> {
const { data, error } = await getSupabase()
.from('shopping_items')
.select('*')
.eq('list_id', listId)
.order('sort_order', { ascending: true });
if (error || !data) return [];
return data as ShoppingItem[];
}
export async function addItem(
listId: string,
name: string,
quantity: number | null,
unit: string | null,
userId: string,
sortOrder: number
): Promise<ShoppingItem | null> {
const { data, error } = await getSupabase()
.from('shopping_items')
.insert({ list_id: listId, name, quantity, unit, sort_order: sortOrder, created_by: userId })
.select()
.single();
if (error || !data) return null;
return data as ShoppingItem;
}
export async function updateItem(
id: string,
patch: Partial<
Pick<
ShoppingItem,
'name' | 'quantity' | 'unit' | 'is_checked' | 'checked_by' | 'checked_at' | 'sort_order'
>
>
) {
await getSupabase().from('shopping_items').update(patch).eq('id', id);
}
export async function checkItem(id: string, userId: string, checked: boolean) {
const patch = checked
? { is_checked: true, checked_by: userId, checked_at: new Date().toISOString() }
: { is_checked: false, checked_by: null, checked_at: null };
await getSupabase().from('shopping_items').update(patch).eq('id', id);
}
export async function deleteItem(id: string) {
await getSupabase().from('shopping_items').delete().eq('id', id);
}
export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
await Promise.all(
items.map((item, index) =>
getSupabase().from('shopping_items').update({ sort_order: index }).eq('id', item.id)
)
);
}
// ── Suggestions (item_frequency) ──────────────────────────────────────────────
export async function fetchSuggestions(
collectiveId: string,
prefix: string
): Promise<ItemFrequency[]> {
if (!prefix.trim()) {
const { data } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.order('use_count', { ascending: false })
.limit(5);
return (data as ItemFrequency[]) ?? [];
}
const { data } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.ilike('name', `${prefix.toLowerCase().trim()}%`)
.order('use_count', { ascending: false })
.limit(10);
return (data as ItemFrequency[]) ?? [];
}