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:
13
CLAUDE.md
13
CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
|
|
||||||
**Fase 0 complete. Fase 1 complete. ✅ Acceptance test passed (all 5 users authenticate; `auth.uid()` resolves correctly in RLS).**
|
**Fase 0 complete. Fase 1 complete. Fase 2a complete. ✅**
|
||||||
|
|
||||||
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
||||||
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
||||||
@@ -34,11 +34,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator
|
- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator
|
||||||
- `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware)
|
- `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware)
|
||||||
- `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out
|
- `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out
|
||||||
- `apps/web/messages/en.json` + `es.json` — all Fase 1 strings
|
- `apps/web/messages/en.json` + `es.json` — all Fase 1 + 2a strings
|
||||||
- `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired)
|
- `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired)
|
||||||
- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
|
- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
|
||||||
- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively)
|
- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively)
|
||||||
|
|
||||||
|
### Fase 2a — what has been built
|
||||||
|
|
||||||
|
- `supabase/migrations/005_shopping.sql` — `shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
|
||||||
|
- `supabase/migrations/006_item_frequency.sql` — `item_frequency` table, `fn_record_item_frequency` trigger (SECURITY DEFINER, fires on every `shopping_items` INSERT)
|
||||||
|
- `apps/web/src/lib/stores/lists.ts` — shopping lists store (optimistic CRUD) + item operations + suggestion fetching
|
||||||
|
- `apps/web/src/lib/components/ItemSuggestions.svelte` — frequency chips with active-prefix highlighting
|
||||||
|
- `apps/web/src/routes/(app)/lists/+page.svelte` — overview: featured card + 2-col grid + completed section + trash view + sticky create input
|
||||||
|
- `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — list detail: items with checkbox, qty stepper (−/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via `svelte-dnd-action`, sticky add form with `ItemSuggestions`
|
||||||
|
|
||||||
## Local Dev: Required /etc/hosts Entry
|
## Local Dev: Required /etc/hosts Entry
|
||||||
|
|
||||||
GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine:
|
GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine:
|
||||||
|
|||||||
@@ -70,6 +70,23 @@
|
|||||||
"invite_email_label": "Email address",
|
"invite_email_label": "Email address",
|
||||||
"invite_send": "Send invitation",
|
"invite_send": "Send invitation",
|
||||||
"lists_title": "Shopping Lists",
|
"lists_title": "Shopping Lists",
|
||||||
|
"lists_new_list": "Create",
|
||||||
|
"lists_no_lists": "No lists yet",
|
||||||
|
"lists_create_first": "Create your first shopping list",
|
||||||
|
"lists_trash": "Trash",
|
||||||
|
"list_status_active": "Active List",
|
||||||
|
"list_status_completed": "Completed",
|
||||||
|
"list_status_archived": "Archived",
|
||||||
|
"list_name_placeholder": "e.g. Weekly shop",
|
||||||
|
"list_items_empty": "List is empty",
|
||||||
|
"list_items_empty_hint": "Add your first item below",
|
||||||
|
"list_reset": "Reset list",
|
||||||
|
"list_archive": "Archive",
|
||||||
|
"list_delete": "Move to trash",
|
||||||
|
"list_actions": "List actions",
|
||||||
|
"list_item_placeholder": "Add item (e.g. Yogurt 500g)",
|
||||||
|
"list_qty_label": "Qty",
|
||||||
|
"list_unit_label": "Unit",
|
||||||
"list_add_item": "Add item…",
|
"list_add_item": "Add item…",
|
||||||
"list_to_buy": "To buy",
|
"list_to_buy": "To buy",
|
||||||
"list_checked": "Checked",
|
"list_checked": "Checked",
|
||||||
|
|||||||
@@ -70,6 +70,23 @@
|
|||||||
"invite_email_label": "Correo electrónico",
|
"invite_email_label": "Correo electrónico",
|
||||||
"invite_send": "Enviar invitación",
|
"invite_send": "Enviar invitación",
|
||||||
"lists_title": "Listas de la compra",
|
"lists_title": "Listas de la compra",
|
||||||
|
"lists_new_list": "Crear",
|
||||||
|
"lists_no_lists": "Sin listas",
|
||||||
|
"lists_create_first": "Crea tu primera lista de la compra",
|
||||||
|
"lists_trash": "Papelera",
|
||||||
|
"list_status_active": "Lista activa",
|
||||||
|
"list_status_completed": "Completada",
|
||||||
|
"list_status_archived": "Archivada",
|
||||||
|
"list_name_placeholder": "p. ej. Compra semanal",
|
||||||
|
"list_items_empty": "Lista vacía",
|
||||||
|
"list_items_empty_hint": "Añade tu primer producto abajo",
|
||||||
|
"list_reset": "Reiniciar lista",
|
||||||
|
"list_archive": "Archivar",
|
||||||
|
"list_delete": "Mover a papelera",
|
||||||
|
"list_actions": "Acciones de lista",
|
||||||
|
"list_item_placeholder": "Añadir producto…",
|
||||||
|
"list_qty_label": "Cant.",
|
||||||
|
"list_unit_label": "Unidad",
|
||||||
"list_add_item": "Añadir producto…",
|
"list_add_item": "Añadir producto…",
|
||||||
"list_to_buy": "Por comprar",
|
"list_to_buy": "Por comprar",
|
||||||
"list_checked": "Marcado",
|
"list_checked": "Marcado",
|
||||||
|
|||||||
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[]) ?? [];
|
||||||
|
}
|
||||||
@@ -1,17 +1,426 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
|
import { currentUser } from '$lib/stores/auth';
|
||||||
|
import {
|
||||||
|
lists,
|
||||||
|
listsLoading,
|
||||||
|
loadLists,
|
||||||
|
loadTrashedLists,
|
||||||
|
createList,
|
||||||
|
softDeleteList,
|
||||||
|
restoreList,
|
||||||
|
permanentDeleteList,
|
||||||
|
archiveList,
|
||||||
|
resetList
|
||||||
|
} from '$lib/stores/lists';
|
||||||
|
import type { ShoppingList } from '@colectivo/types';
|
||||||
|
import { ShoppingCart, Plus, MoreHorizontal, Trash2, Archive, RotateCcw } from 'lucide-svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let newListName = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
let nameInput: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
let showTrash = $state(false);
|
||||||
|
let trashedLists = $state<ShoppingList[]>([]);
|
||||||
|
let trashLoading = $state(false);
|
||||||
|
|
||||||
|
let openMenuId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const activeLists = $derived($lists.filter((l) => l.status === 'active'));
|
||||||
|
const completedLists = $derived($lists.filter((l) => l.status === 'completed'));
|
||||||
|
const featuredList = $derived(activeLists[0] ?? null);
|
||||||
|
const gridLists = $derived(activeLists.slice(1));
|
||||||
|
|
||||||
|
// ── Load ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($currentCollective) {
|
||||||
|
loadLists($currentCollective.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleTrash() {
|
||||||
|
showTrash = !showTrash;
|
||||||
|
if (showTrash && $currentCollective) {
|
||||||
|
trashLoading = true;
|
||||||
|
trashedLists = await loadTrashedLists($currentCollective.id);
|
||||||
|
trashLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
const name = newListName.trim();
|
||||||
|
if (!name || !$currentCollective || !$currentUser) return;
|
||||||
|
creating = true;
|
||||||
|
newListName = '';
|
||||||
|
await createList($currentCollective.id, name, $currentUser.id);
|
||||||
|
creating = false;
|
||||||
|
nameInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleMenu(id: string, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
openMenuId = openMenuId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive(id: string) {
|
||||||
|
closeMenu();
|
||||||
|
await archiveList(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(id: string) {
|
||||||
|
closeMenu();
|
||||||
|
await resetList(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
closeMenu();
|
||||||
|
await softDeleteList(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(id: string) {
|
||||||
|
if (!$currentCollective) return;
|
||||||
|
await restoreList(id, $currentCollective.id);
|
||||||
|
trashedLists = trashedLists.filter((l) => l.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePermanentDelete(id: string) {
|
||||||
|
await permanentDeleteList(id);
|
||||||
|
trashedLists = trashedLists.filter((l) => l.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss open menu on outside click
|
||||||
|
function handleWindowClick() {
|
||||||
|
openMenuId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function trashDaysLeft(deletedAt: string): number {
|
||||||
|
const expiry = new Date(deletedAt).getTime() + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
return Math.max(0, Math.ceil((expiry - Date.now()) / (24 * 60 * 60 * 1000)));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleWindowClick} />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
<header class="px-8 pb-4 pt-8">
|
<!-- Header -->
|
||||||
|
<header class="flex items-end justify-between px-8 pb-4 pt-8">
|
||||||
|
<div>
|
||||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
{m.nav_lists()}
|
{$currentCollective?.name ?? m.app_name()}
|
||||||
</p>
|
</p>
|
||||||
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||||
{m.lists_title()}
|
{m.lists_title()}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={toggleTrash}
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium
|
||||||
|
{showTrash
|
||||||
|
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
|
||||||
|
: 'text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
{m.lists_trash()}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
|
||||||
<!-- Fase 2a: shopping lists content -->
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-8 pb-24">
|
||||||
|
{#if showTrash}
|
||||||
|
<!-- Trash view -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.lists_trash()}
|
||||||
|
</p>
|
||||||
|
{#if trashLoading}
|
||||||
|
<p class="text-sm text-text-muted">{m.loading()}</p>
|
||||||
|
{:else if trashedLists.length === 0}
|
||||||
|
<p class="text-sm text-text-muted">{m.trash_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each trashedLists as list (list.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 rounded-lg bg-surface px-4 py-3"
|
||||||
|
>
|
||||||
|
<ShoppingCart size={16} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm text-text-primary">{list.name}</span>
|
||||||
|
<span class="shrink-0 text-[12px] text-text-muted">
|
||||||
|
{m.trash_expires_in({ days: trashDaysLeft(list.deleted_at!) })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => handleRestore(list.id)}
|
||||||
|
class="shrink-0 rounded px-2 py-1 text-[12px] font-medium text-slate-600
|
||||||
|
hover:bg-black/5 dark:text-slate-400 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{m.trash_restore()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handlePermanentDelete(list.id)}
|
||||||
|
class="shrink-0 rounded px-2 py-1 text-[12px] font-medium text-destructive
|
||||||
|
hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
{m.trash_delete_permanent()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if $listsLoading}
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3] as i}
|
||||||
|
<div class="h-24 animate-pulse rounded-lg bg-surface-container-low" aria-hidden="true"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if activeLists.length === 0 && completedLists.length === 0}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div class="mb-4 rounded-full bg-surface-container-low p-4">
|
||||||
|
<ShoppingCart size={24} strokeWidth={1.5} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 text-sm font-medium text-text-primary">{m.lists_no_lists()}</p>
|
||||||
|
<p class="text-sm text-text-muted">{m.lists_create_first()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Featured list (first active list) -->
|
||||||
|
{#if featuredList}
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<a
|
||||||
|
href="/lists/{featuredList.id}"
|
||||||
|
class="block rounded-lg bg-surface p-5 shadow-[0px_2px_8px_rgba(15,23,42,0.04)]
|
||||||
|
hover:shadow-[0px_4px_16px_rgba(15,23,42,0.08)] transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-start justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ShoppingCart size={16} strokeWidth={1.5} class="text-text-muted" />
|
||||||
|
<span class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.list_status_active()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-[18px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||||
|
{featuredList.name}
|
||||||
|
</h2>
|
||||||
|
</a>
|
||||||
|
<!-- Action menu -->
|
||||||
|
<div class="absolute right-3 top-3">
|
||||||
|
<button
|
||||||
|
onclick={(e) => toggleMenu(featuredList.id, e)}
|
||||||
|
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={m.list_actions()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === featuredList.id}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
|
||||||
|
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => handleReset(featuredList.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_reset()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleArchive(featuredList.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Archive size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_archive()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(featuredList.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
|
||||||
|
hover:bg-destructive/10 rounded-b-lg"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 2-column grid of remaining active lists -->
|
||||||
|
{#if gridLists.length > 0}
|
||||||
|
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||||
|
{#each gridLists as list (list.id)}
|
||||||
|
<div class="relative">
|
||||||
|
<a
|
||||||
|
href="/lists/{list.id}"
|
||||||
|
class="block rounded-lg bg-surface p-4 shadow-[0px_2px_8px_rgba(15,23,42,0.04)]
|
||||||
|
hover:shadow-[0px_4px_16px_rgba(15,23,42,0.08)] transition-shadow"
|
||||||
|
>
|
||||||
|
<div class="mb-1 flex items-center gap-1.5">
|
||||||
|
<ShoppingCart size={14} strokeWidth={1.5} class="text-text-muted" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-[15px] font-semibold tracking-[-0.01em] text-slate-900
|
||||||
|
dark:text-slate-50 truncate">
|
||||||
|
{list.name}
|
||||||
|
</h3>
|
||||||
|
</a>
|
||||||
|
<div class="absolute right-2 top-2">
|
||||||
|
<button
|
||||||
|
onclick={(e) => toggleMenu(list.id, e)}
|
||||||
|
class="rounded-md p-1 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={m.list_actions()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === list.id}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
|
||||||
|
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => handleReset(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_reset()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleArchive(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Archive size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_archive()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
|
||||||
|
hover:bg-destructive/10 rounded-b-lg"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Completed lists -->
|
||||||
|
{#if completedLists.length > 0}
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.list_status_completed()}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each completedLists as list (list.id)}
|
||||||
|
<div class="relative flex items-center gap-3 rounded-lg bg-surface px-4 py-3">
|
||||||
|
<ShoppingCart size={15} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
||||||
|
<a
|
||||||
|
href="/lists/{list.id}"
|
||||||
|
class="min-w-0 flex-1 truncate text-sm text-slate-600 hover:text-slate-900
|
||||||
|
dark:text-slate-400 dark:hover:text-slate-50"
|
||||||
|
>
|
||||||
|
{list.name}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onclick={(e) => toggleMenu(list.id, e)}
|
||||||
|
class="shrink-0 rounded-md p-1 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={m.list_actions()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
{#if openMenuId === list.id}
|
||||||
|
<div
|
||||||
|
class="absolute right-8 top-1 z-10 w-44 rounded-lg bg-surface
|
||||||
|
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => handleReset(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_reset()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleArchive(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<Archive size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_archive()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(list.id)}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
|
||||||
|
hover:bg-destructive/10 rounded-b-lg"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky bottom: create new list -->
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-56 right-0 flex items-center gap-3 px-8 py-4
|
||||||
|
bg-background/80 backdrop-blur-[12px]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-4 py-2.5
|
||||||
|
shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
|
||||||
|
<Plus size={16} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
||||||
|
<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={newListName}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
type="text"
|
||||||
|
placeholder={m.list_name_placeholder()}
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-muted
|
||||||
|
outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleCreate}
|
||||||
|
disabled={!newListName.trim() || creating}
|
||||||
|
class="rounded-md bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
|
||||||
|
hover:opacity-90 disabled:opacity-40 dark:bg-white dark:text-slate-900
|
||||||
|
transition-opacity"
|
||||||
|
>
|
||||||
|
{m.lists_new_list()}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
581
apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal file
581
apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
|
import { currentUser } from '$lib/stores/auth';
|
||||||
|
import {
|
||||||
|
loadItems,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
checkItem,
|
||||||
|
deleteItem,
|
||||||
|
reorderItems,
|
||||||
|
fetchSuggestions
|
||||||
|
} from '$lib/stores/lists';
|
||||||
|
import { getSupabase } from '$lib/supabase';
|
||||||
|
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
|
||||||
|
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
GripVertical,
|
||||||
|
Minus,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
RotateCcw,
|
||||||
|
Check
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
const listId = $page.params.id!;
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let list = $state<ShoppingList | null>(null);
|
||||||
|
let items = $state<ShoppingItem[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
// Add-item form
|
||||||
|
let newName = $state('');
|
||||||
|
let newQty = $state<number | null>(null);
|
||||||
|
let newUnit = $state('');
|
||||||
|
let nameInput: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
|
// Suggestions
|
||||||
|
let suggestions = $state<Awaited<ReturnType<typeof fetchSuggestions>>>([]);
|
||||||
|
let suggestionsTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
// Inline edit
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editName = $state('');
|
||||||
|
let editQty = $state<number | null>(null);
|
||||||
|
let editUnit = $state('');
|
||||||
|
|
||||||
|
// Swipe-to-delete (touch only)
|
||||||
|
let swipeOffsets = $state<Record<string, number>>({});
|
||||||
|
let openSwipeId = $state<string | null>(null);
|
||||||
|
let swipeStartX = 0;
|
||||||
|
let swipeStartY = 0;
|
||||||
|
let swipeHorizontal = false;
|
||||||
|
const SWIPE_MAX = 72;
|
||||||
|
const SWIPE_THRESHOLD = 48;
|
||||||
|
|
||||||
|
// List action menu
|
||||||
|
let showMenu = $state(false);
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
|
||||||
|
const checkedItems = $derived(items.filter((i) => i.is_checked));
|
||||||
|
|
||||||
|
// ── Load ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [listRes, itemsRes] = await Promise.all([
|
||||||
|
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
|
||||||
|
loadItems(listId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (listRes.error || !listRes.data) {
|
||||||
|
goto('/lists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list = listRes.data as ShoppingList;
|
||||||
|
items = itemsRes;
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
// Load initial suggestions (empty prefix = top 5)
|
||||||
|
if ($currentCollective) {
|
||||||
|
suggestions = await fetchSuggestions($currentCollective.id, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function updateSuggestions(prefix: string) {
|
||||||
|
clearTimeout(suggestionsTimer);
|
||||||
|
if (!$currentCollective) return;
|
||||||
|
suggestionsTimer = setTimeout(async () => {
|
||||||
|
suggestions = await fetchSuggestions($currentCollective!.id, prefix);
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
updateSuggestions(newName);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectSuggestion(name: string) {
|
||||||
|
newName = name;
|
||||||
|
nameInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add item ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || !$currentUser) return;
|
||||||
|
|
||||||
|
const sortOrder = items.length;
|
||||||
|
const tempId = crypto.randomUUID();
|
||||||
|
const optimistic: ShoppingItem = {
|
||||||
|
id: tempId,
|
||||||
|
list_id: listId,
|
||||||
|
name,
|
||||||
|
quantity: newQty,
|
||||||
|
unit: newUnit.trim() || null,
|
||||||
|
is_checked: false,
|
||||||
|
checked_by: null,
|
||||||
|
checked_at: null,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
created_by: $currentUser.id,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
items = [...items, optimistic];
|
||||||
|
newName = '';
|
||||||
|
newQty = null;
|
||||||
|
newUnit = '';
|
||||||
|
nameInput?.focus();
|
||||||
|
|
||||||
|
const real = await addItem(listId, name, newQty, optimistic.unit, $currentUser.id, sortOrder);
|
||||||
|
if (real) {
|
||||||
|
items = items.map((i) => (i.id === tempId ? real : i));
|
||||||
|
} else {
|
||||||
|
items = items.filter((i) => i.id !== tempId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleAdd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check / uncheck ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleCheck(item: ShoppingItem) {
|
||||||
|
if (!$currentUser) return;
|
||||||
|
const checked = !item.is_checked;
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.id === item.id
|
||||||
|
? {
|
||||||
|
...i,
|
||||||
|
is_checked: checked,
|
||||||
|
checked_by: checked ? $currentUser!.id : null,
|
||||||
|
checked_at: checked ? new Date().toISOString() : null
|
||||||
|
}
|
||||||
|
: i
|
||||||
|
);
|
||||||
|
await checkItem(item.id, $currentUser.id, checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quantity stepper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function adjustQty(item: ShoppingItem, delta: number) {
|
||||||
|
const current = item.quantity ?? 1;
|
||||||
|
const next = Math.max(1, current + delta);
|
||||||
|
items = items.map((i) => (i.id === item.id ? { ...i, quantity: next } : i));
|
||||||
|
await updateItem(item.id, { quantity: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline edit ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(item: ShoppingItem) {
|
||||||
|
// Don't start edit while swiped open
|
||||||
|
if (openSwipeId === item.id) return;
|
||||||
|
editingId = item.id;
|
||||||
|
editName = item.name;
|
||||||
|
editQty = item.quantity;
|
||||||
|
editUnit = item.unit ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitEdit(item: ShoppingItem) {
|
||||||
|
if (editingId !== item.id) return;
|
||||||
|
editingId = null;
|
||||||
|
const name = editName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (name === item.name && editQty === item.quantity && (editUnit || null) === item.unit) return;
|
||||||
|
|
||||||
|
items = items.map((i) =>
|
||||||
|
i.id === item.id
|
||||||
|
? { ...i, name, quantity: editQty, unit: editUnit.trim() || null }
|
||||||
|
: i
|
||||||
|
);
|
||||||
|
await updateItem(item.id, {
|
||||||
|
name,
|
||||||
|
quantity: editQty,
|
||||||
|
unit: editUnit.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditKeydown(e: KeyboardEvent, item: ShoppingItem) {
|
||||||
|
if (e.key === 'Enter') commitEdit(item);
|
||||||
|
if (e.key === 'Escape') editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
swipeOffsets[id] = 0;
|
||||||
|
openSwipeId = null;
|
||||||
|
items = items.filter((i) => i.id !== id);
|
||||||
|
await deleteItem(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Swipe to reveal delete (touch) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function onSwipeStart(e: PointerEvent, id: string) {
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
swipeStartX = e.clientX;
|
||||||
|
swipeStartY = e.clientY;
|
||||||
|
swipeHorizontal = false;
|
||||||
|
// Close other open swipe
|
||||||
|
if (openSwipeId && openSwipeId !== id) {
|
||||||
|
swipeOffsets[openSwipeId] = 0;
|
||||||
|
openSwipeId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSwipeMove(e: PointerEvent, id: string) {
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
const dx = e.clientX - swipeStartX;
|
||||||
|
const dy = e.clientY - swipeStartY;
|
||||||
|
|
||||||
|
if (!swipeHorizontal) {
|
||||||
|
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx)) return; // vertical dominates
|
||||||
|
swipeHorizontal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
swipeOffsets[id] = Math.max(-SWIPE_MAX, Math.min(0, dx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSwipeEnd(e: PointerEvent, id: string) {
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
if (!swipeHorizontal) return;
|
||||||
|
|
||||||
|
const offset = swipeOffsets[id] ?? 0;
|
||||||
|
if (offset <= -SWIPE_THRESHOLD) {
|
||||||
|
swipeOffsets[id] = -SWIPE_MAX;
|
||||||
|
openSwipeId = id;
|
||||||
|
} else {
|
||||||
|
swipeOffsets[id] = 0;
|
||||||
|
openSwipeId = null;
|
||||||
|
}
|
||||||
|
swipeHorizontal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag & drop reorder ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
function handleDndConsider(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
||||||
|
const checked = items.filter((i) => i.is_checked);
|
||||||
|
items = [...e.detail.items, ...checked];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDndFinalize(e: CustomEvent<{ items: ShoppingItem[] }>) {
|
||||||
|
const checked = items.filter((i) => i.is_checked);
|
||||||
|
const reordered = e.detail.items;
|
||||||
|
items = [...reordered, ...checked];
|
||||||
|
await reorderItems(reordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
if (!list) return;
|
||||||
|
showMenu = false;
|
||||||
|
items = items.map((i) => ({ ...i, is_checked: false, checked_by: null, checked_at: null }));
|
||||||
|
await getSupabase().from('shopping_lists').update({ status: 'active', completed_at: null }).eq('id', listId);
|
||||||
|
await getSupabase().from('shopping_items').update({ is_checked: false, checked_by: null, checked_at: null }).eq('list_id', listId);
|
||||||
|
if (list) list = { ...list, status: 'active', completed_at: null };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<span class="text-sm text-text-muted">{m.loading()}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex items-center gap-3 px-8 pb-4 pt-8">
|
||||||
|
<a
|
||||||
|
href="/lists"
|
||||||
|
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} strokeWidth={1.5} />
|
||||||
|
</a>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="mb-0.5 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.list_status_active()}
|
||||||
|
</p>
|
||||||
|
<h1 class="truncate text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
|
||||||
|
{list?.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<!-- Actions menu -->
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<button
|
||||||
|
onclick={() => (showMenu = !showMenu)}
|
||||||
|
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label={m.list_actions()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
{#if showMenu}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
|
||||||
|
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={handleReset}
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||||
|
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} strokeWidth={1.5} />
|
||||||
|
{m.list_reset()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="flex-1 overflow-y-auto pb-32">
|
||||||
|
{#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>
|
||||||
|
<p class="text-sm text-text-muted">{m.list_items_empty_hint()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Unchecked items (drag & droppable) -->
|
||||||
|
{#if uncheckedItems.length > 0}
|
||||||
|
<div
|
||||||
|
use:dndzone={{ items: uncheckedItems, flipDurationMs, type: 'items' }}
|
||||||
|
onconsider={handleDndConsider}
|
||||||
|
onfinalize={handleDndFinalize}
|
||||||
|
class="px-8"
|
||||||
|
>
|
||||||
|
{#each uncheckedItems as item (item.id)}
|
||||||
|
<div animate:flip={{ duration: flipDurationMs }} class="relative overflow-hidden">
|
||||||
|
<!-- Delete zone (behind item, revealed on swipe) -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 flex w-[72px] items-center justify-center
|
||||||
|
bg-red-500 rounded-r-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(item.id)}
|
||||||
|
class="flex h-full w-full items-center justify-center text-white"
|
||||||
|
aria-label={m.action_delete()}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item row -->
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
class="relative flex items-center gap-3 py-3 bg-background touch-pan-y select-none
|
||||||
|
transition-transform duration-150"
|
||||||
|
style="transform: translateX({swipeOffsets[item.id] ?? 0}px)"
|
||||||
|
onpointerdown={(e) => onSwipeStart(e, item.id)}
|
||||||
|
onpointermove={(e) => onSwipeMove(e, item.id)}
|
||||||
|
onpointerup={(e) => onSwipeEnd(e, item.id)}
|
||||||
|
>
|
||||||
|
<!-- Drag handle (desktop) -->
|
||||||
|
<div class="shrink-0 cursor-grab text-text-muted active:cursor-grabbing hidden sm:flex">
|
||||||
|
<GripVertical size={16} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<button
|
||||||
|
onclick={() => handleCheck(item)}
|
||||||
|
class="shrink-0 h-5 w-5 rounded border-2 border-slate-300 dark:border-slate-600
|
||||||
|
flex items-center justify-center hover:border-slate-400 dark:hover:border-slate-500
|
||||||
|
transition-colors"
|
||||||
|
aria-label="Toggle item"
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if editingId === item.id}
|
||||||
|
<!-- Inline edit form -->
|
||||||
|
<div class="flex flex-1 items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editName}
|
||||||
|
onblur={() => commitEdit(item)}
|
||||||
|
onkeydown={(e) => handleEditKeydown(e, item)}
|
||||||
|
class="flex-1 min-w-0 bg-transparent text-sm text-text-primary outline-none
|
||||||
|
border-b border-slate-300 dark:border-slate-600 pb-0.5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={editQty}
|
||||||
|
onblur={() => commitEdit(item)}
|
||||||
|
min="0"
|
||||||
|
placeholder={m.list_qty_label()}
|
||||||
|
class="w-14 bg-transparent text-sm text-text-secondary outline-none
|
||||||
|
border-b border-slate-300 dark:border-slate-600 pb-0.5 text-right"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editUnit}
|
||||||
|
onblur={() => commitEdit(item)}
|
||||||
|
onkeydown={(e) => handleEditKeydown(e, item)}
|
||||||
|
placeholder={m.list_unit_label()}
|
||||||
|
class="w-14 bg-transparent text-sm text-text-secondary outline-none
|
||||||
|
border-b border-slate-300 dark:border-slate-600 pb-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Item display -->
|
||||||
|
<button
|
||||||
|
class="flex-1 min-w-0 text-left"
|
||||||
|
onclick={() => startEdit(item)}
|
||||||
|
>
|
||||||
|
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{#if item.unit}
|
||||||
|
<span class="text-[12px] text-text-muted">{item.unit}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Quantity stepper -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => adjustQty(item, -1)}
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
|
||||||
|
hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label="Decrease quantity"
|
||||||
|
>
|
||||||
|
<Minus size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<span class="w-7 text-center text-sm tabular-nums text-slate-700 dark:text-slate-300">
|
||||||
|
{item.quantity ?? 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => adjustQty(item, 1)}
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
|
||||||
|
hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
aria-label="Increase quantity"
|
||||||
|
>
|
||||||
|
<Plus size={12} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop delete button (hover) -->
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(item.id)}
|
||||||
|
class="hidden shrink-0 rounded p-1 text-text-muted hover:text-destructive
|
||||||
|
hover:bg-destructive/10 sm:flex"
|
||||||
|
aria-label={m.action_delete()}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Checked items section -->
|
||||||
|
{#if checkedItems.length > 0}
|
||||||
|
<div class="px-8 mt-4">
|
||||||
|
<p class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-muted">
|
||||||
|
{m.list_checked()} ({checkedItems.length})
|
||||||
|
</p>
|
||||||
|
{#each checkedItems as item (item.id)}
|
||||||
|
<div class="flex items-center gap-3 py-3 opacity-50">
|
||||||
|
<!-- Drag handle placeholder -->
|
||||||
|
<div class="hidden sm:flex shrink-0 w-4"></div>
|
||||||
|
|
||||||
|
<!-- Checked checkbox -->
|
||||||
|
<button
|
||||||
|
onclick={() => handleCheck(item)}
|
||||||
|
class="shrink-0 h-5 w-5 rounded border-2 border-slate-400 bg-slate-400
|
||||||
|
dark:border-slate-500 dark:bg-slate-500
|
||||||
|
flex items-center justify-center transition-colors"
|
||||||
|
aria-label="Uncheck item"
|
||||||
|
>
|
||||||
|
<Check size={12} strokeWidth={2.5} class="text-white dark:text-slate-900" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="flex-1 min-w-0 truncate text-sm text-text-muted line-through">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(item.id)}
|
||||||
|
class="shrink-0 rounded p-1 text-text-muted hover:text-destructive
|
||||||
|
hover:bg-destructive/10"
|
||||||
|
aria-label={m.action_delete()}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky bottom: suggestions + add form -->
|
||||||
|
<div class="absolute bottom-0 left-56 right-0 bg-background/80 backdrop-blur-[12px]">
|
||||||
|
<!-- Suggestions -->
|
||||||
|
<ItemSuggestions
|
||||||
|
{suggestions}
|
||||||
|
activePrefix={newName}
|
||||||
|
onselect={selectSuggestion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Add input -->
|
||||||
|
<div class="flex items-center gap-2 px-4 pb-4 pt-1">
|
||||||
|
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-4 py-2.5
|
||||||
|
shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
|
||||||
|
<input
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={newName}
|
||||||
|
onkeydown={handleAddKeydown}
|
||||||
|
type="text"
|
||||||
|
placeholder={m.list_item_placeholder()}
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-muted
|
||||||
|
outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newQty}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder={m.list_qty_label()}
|
||||||
|
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
|
||||||
|
outline-none text-right"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newUnit}
|
||||||
|
type="text"
|
||||||
|
placeholder={m.list_unit_label()}
|
||||||
|
onkeydown={handleAddKeydown}
|
||||||
|
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
|
||||||
|
outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleAdd}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg
|
||||||
|
bg-slate-900 text-white hover:opacity-90 disabled:opacity-40
|
||||||
|
dark:bg-white dark:text-slate-900 transition-opacity"
|
||||||
|
aria-label={m.add()}
|
||||||
|
>
|
||||||
|
<Plus size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -7,6 +7,7 @@ export type Json = string | number | boolean | null | { [key: string]: Json | un
|
|||||||
export type LanguageCode = 'en' | 'es';
|
export type LanguageCode = 'en' | 'es';
|
||||||
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
|
export type AvatarTypeEnum = 'initials' | 'emoji' | 'upload';
|
||||||
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
|
export type MemberRoleEnum = 'admin' | 'member' | 'guest';
|
||||||
|
export type ListStatusEnum = 'active' | 'completed' | 'archived';
|
||||||
|
|
||||||
export interface Database {
|
export interface Database {
|
||||||
public: {
|
public: {
|
||||||
@@ -161,6 +162,102 @@ export interface Database {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
shopping_lists: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
collective_id: string;
|
||||||
|
name: string;
|
||||||
|
status: ListStatusEnum;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
collective_id: string;
|
||||||
|
name: string;
|
||||||
|
status?: ListStatusEnum;
|
||||||
|
created_by: string;
|
||||||
|
created_at?: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
collective_id?: string;
|
||||||
|
name?: string;
|
||||||
|
status?: ListStatusEnum;
|
||||||
|
created_by?: string;
|
||||||
|
created_at?: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
shopping_items: {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
list_id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
is_checked: boolean;
|
||||||
|
checked_by: string | null;
|
||||||
|
checked_at: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
list_id: string;
|
||||||
|
name: string;
|
||||||
|
quantity?: number | null;
|
||||||
|
unit?: string | null;
|
||||||
|
is_checked?: boolean;
|
||||||
|
checked_by?: string | null;
|
||||||
|
checked_at?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
created_by: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
id?: string;
|
||||||
|
list_id?: string;
|
||||||
|
name?: string;
|
||||||
|
quantity?: number | null;
|
||||||
|
unit?: string | null;
|
||||||
|
is_checked?: boolean;
|
||||||
|
checked_by?: string | null;
|
||||||
|
checked_at?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
created_by?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
item_frequency: {
|
||||||
|
Row: {
|
||||||
|
collective_id: string;
|
||||||
|
name: string;
|
||||||
|
use_count: number;
|
||||||
|
last_used_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
collective_id: string;
|
||||||
|
name: string;
|
||||||
|
use_count?: number;
|
||||||
|
last_used_at?: string;
|
||||||
|
};
|
||||||
|
Update: {
|
||||||
|
collective_id?: string;
|
||||||
|
name?: string;
|
||||||
|
use_count?: number;
|
||||||
|
last_used_at?: string;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
Views: Record<string, never>;
|
Views: Record<string, never>;
|
||||||
Functions: {
|
Functions: {
|
||||||
@@ -185,6 +282,7 @@ export interface Database {
|
|||||||
language_code: LanguageCode;
|
language_code: LanguageCode;
|
||||||
avatar_type: AvatarTypeEnum;
|
avatar_type: AvatarTypeEnum;
|
||||||
member_role: MemberRoleEnum;
|
member_role: MemberRoleEnum;
|
||||||
|
list_status: ListStatusEnum;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ export interface ShoppingItem {
|
|||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
deleted_at: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemFrequency {
|
export interface ItemFrequency {
|
||||||
|
|||||||
154
supabase/migrations/005_shopping.sql
Normal file
154
supabase/migrations/005_shopping.sql
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
-- Migration 005: shopping_lists and shopping_items
|
||||||
|
--
|
||||||
|
-- Trash model: shopping_lists has deleted_at for soft-delete (7-day recovery window).
|
||||||
|
-- shopping_items are hard-deleted — no trash, no recovery needed.
|
||||||
|
|
||||||
|
-- ── Enum ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TYPE public.list_status AS ENUM ('active', 'completed', 'archived');
|
||||||
|
|
||||||
|
-- ── shopping_lists ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.shopping_lists (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
status public.list_status NOT NULL DEFAULT 'active',
|
||||||
|
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
completed_at timestamptz NULL,
|
||||||
|
deleted_at timestamptz NULL -- soft delete; pg_cron purges after 7 days
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Partial index: main query always filters by collective + status and excludes deleted rows
|
||||||
|
CREATE INDEX shopping_lists_collective_status_idx
|
||||||
|
ON public.shopping_lists (collective_id, status)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- ── shopping_items ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.shopping_items (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
list_id uuid NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
quantity numeric NULL,
|
||||||
|
unit text NULL,
|
||||||
|
is_checked boolean NOT NULL DEFAULT false,
|
||||||
|
checked_by uuid NULL REFERENCES public.users(id) ON DELETE SET NULL,
|
||||||
|
checked_at timestamptz NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_by uuid NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.shopping_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Composite index for the main list view: all items for a list in display order
|
||||||
|
CREATE INDEX shopping_items_list_order_idx
|
||||||
|
ON public.shopping_items (list_id, sort_order);
|
||||||
|
|
||||||
|
-- ── Helper: resolve collective for an item (used in RLS policies) ─────────────
|
||||||
|
-- STABLE lets PostgreSQL cache the result within a single query.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_collective_id(p_list_id uuid)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
SELECT collective_id FROM public.shopping_lists WHERE id = p_list_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── RLS: shopping_lists ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- All members (including guests) can see non-deleted lists, plus soft-deleted
|
||||||
|
-- lists within the 7-day recovery window (trash view).
|
||||||
|
CREATE POLICY shopping_lists_select
|
||||||
|
ON public.shopping_lists FOR SELECT
|
||||||
|
USING (
|
||||||
|
public.is_member(collective_id)
|
||||||
|
AND (
|
||||||
|
deleted_at IS NULL
|
||||||
|
OR deleted_at > now() - INTERVAL '7 days'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Active members (admin + member) can create lists.
|
||||||
|
CREATE POLICY shopping_lists_insert
|
||||||
|
ON public.shopping_lists FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
public.is_active_member(collective_id)
|
||||||
|
AND created_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Active members can update lists (rename, change status, set completed_at / deleted_at).
|
||||||
|
CREATE POLICY shopping_lists_update
|
||||||
|
ON public.shopping_lists FOR UPDATE
|
||||||
|
USING (public.is_active_member(collective_id))
|
||||||
|
WITH CHECK (public.is_active_member(collective_id));
|
||||||
|
|
||||||
|
-- Active members can permanently hard-delete a list only once it has been soft-deleted.
|
||||||
|
-- pg_cron also purges old trash as superuser, bypassing RLS — intentional.
|
||||||
|
CREATE POLICY shopping_lists_delete
|
||||||
|
ON public.shopping_lists FOR DELETE
|
||||||
|
USING (
|
||||||
|
public.is_active_member(collective_id)
|
||||||
|
AND deleted_at IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── RLS: shopping_items ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- All members (including guests) can read items.
|
||||||
|
CREATE POLICY shopping_items_select
|
||||||
|
ON public.shopping_items FOR SELECT
|
||||||
|
USING (public.is_member(public.list_collective_id(list_id)));
|
||||||
|
|
||||||
|
-- Active members can add items.
|
||||||
|
CREATE POLICY shopping_items_insert
|
||||||
|
ON public.shopping_items FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
public.is_active_member(public.list_collective_id(list_id))
|
||||||
|
AND created_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Active members can edit items (name, qty, unit, is_checked, sort_order, etc.).
|
||||||
|
CREATE POLICY shopping_items_update
|
||||||
|
ON public.shopping_items FOR UPDATE
|
||||||
|
USING (public.is_active_member(public.list_collective_id(list_id)))
|
||||||
|
WITH CHECK (public.is_active_member(public.list_collective_id(list_id)));
|
||||||
|
|
||||||
|
-- Active members can hard-delete items (no trash for items).
|
||||||
|
CREATE POLICY shopping_items_delete
|
||||||
|
ON public.shopping_items FOR DELETE
|
||||||
|
USING (public.is_active_member(public.list_collective_id(list_id)));
|
||||||
|
|
||||||
|
-- ── pg_cron jobs ──────────────────────────────────────────────────────────────
|
||||||
|
-- Supabase self-hosted ships with pg_cron enabled.
|
||||||
|
-- These jobs run as the database superuser and bypass RLS — intentional for maintenance.
|
||||||
|
|
||||||
|
-- Job 1: Nightly — move 'completed' lists to 'archived' after they have been
|
||||||
|
-- completed for more than 7 days.
|
||||||
|
SELECT cron.schedule(
|
||||||
|
'archive-completed-lists',
|
||||||
|
'0 3 * * *',
|
||||||
|
$$
|
||||||
|
UPDATE public.shopping_lists
|
||||||
|
SET status = 'archived'
|
||||||
|
WHERE status = 'completed'
|
||||||
|
AND completed_at < now() - INTERVAL '7 days'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
$$
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Job 2: Nightly — permanently delete lists that have been in the trash for > 7 days.
|
||||||
|
SELECT cron.schedule(
|
||||||
|
'purge-deleted-lists',
|
||||||
|
'5 3 * * *',
|
||||||
|
$$
|
||||||
|
DELETE FROM public.shopping_lists
|
||||||
|
WHERE deleted_at < now() - INTERVAL '7 days';
|
||||||
|
$$
|
||||||
|
);
|
||||||
70
supabase/migrations/006_item_frequency.sql
Normal file
70
supabase/migrations/006_item_frequency.sql
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
-- Migration 006: item_frequency — per-collective item usage tracking.
|
||||||
|
--
|
||||||
|
-- Write path: SECURITY DEFINER trigger only. The app role has SELECT access only.
|
||||||
|
-- Names are normalized (lower + trim) before upsert so "Milk" and "milk" are the same entry.
|
||||||
|
|
||||||
|
-- ── item_frequency ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE public.item_frequency (
|
||||||
|
collective_id uuid NOT NULL REFERENCES public.collectives(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL, -- normalized: lower(trim(original name))
|
||||||
|
use_count integer NOT NULL DEFAULT 1,
|
||||||
|
last_used_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (collective_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.item_frequency ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Index for the suggestion query: ordered by use_count descending per collective
|
||||||
|
CREATE INDEX item_frequency_collective_count_idx
|
||||||
|
ON public.item_frequency (collective_id, use_count DESC);
|
||||||
|
|
||||||
|
-- ── RLS ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Members (any role) can read frequency data for collectives they belong to.
|
||||||
|
CREATE POLICY item_frequency_select
|
||||||
|
ON public.item_frequency FOR SELECT
|
||||||
|
USING (public.is_member(collective_id));
|
||||||
|
|
||||||
|
-- All direct writes are blocked; the SECURITY DEFINER trigger below is the only
|
||||||
|
-- write path. This prevents clients from inflating suggestion scores artificially.
|
||||||
|
CREATE POLICY item_frequency_insert_deny
|
||||||
|
ON public.item_frequency FOR INSERT
|
||||||
|
WITH CHECK (false);
|
||||||
|
|
||||||
|
CREATE POLICY item_frequency_update_deny
|
||||||
|
ON public.item_frequency FOR UPDATE
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
CREATE POLICY item_frequency_delete_deny
|
||||||
|
ON public.item_frequency FOR DELETE
|
||||||
|
USING (false);
|
||||||
|
|
||||||
|
-- ── Trigger: upsert frequency on shopping_items insert ───────────────────────
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.fn_record_item_frequency()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_collective_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT collective_id INTO v_collective_id
|
||||||
|
FROM public.shopping_lists
|
||||||
|
WHERE id = NEW.list_id;
|
||||||
|
|
||||||
|
INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at)
|
||||||
|
VALUES (v_collective_id, lower(trim(NEW.name)), 1, now())
|
||||||
|
ON CONFLICT (collective_id, name) DO UPDATE SET
|
||||||
|
use_count = item_frequency.use_count + 1,
|
||||||
|
last_used_at = now();
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_item_frequency
|
||||||
|
AFTER INSERT ON public.shopping_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.fn_record_item_frequency();
|
||||||
Reference in New Issue
Block a user