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:
28
apps/web/src/lib/components/ItemSuggestions.svelte
Normal file
28
apps/web/src/lib/components/ItemSuggestions.svelte
Normal 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}
|
||||
215
apps/web/src/lib/stores/lists.ts
Normal file
215
apps/web/src/lib/stores/lists.ts
Normal 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[]) ?? [];
|
||||
}
|
||||
Reference in New Issue
Block a user