diff --git a/BACKLOG.md b/BACKLOG.md index b4fa163..d570c98 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -15,14 +15,6 @@ Prerequisite for the product catalog and unit catalog work — both rely on a `F - **Bootstrap migration for the existing single-family deployment:** at upgrade time, auto-create one `Family` from the current `FAMILY_CODE` env value, assign all existing rows to it, and migrate the env var off. Flag explicitly in the runbook so deployment isn't a surprise. - Auth changes: JWT carries `FamilyId` claim. `OnTokenValidated` rejects tokens whose user has been removed from the family. -### Toast / notification component (foundational) -- App has no toast system today; current error UX is `alert()` on `stores/+page.svelte` delete failures. Several tracked items depend on this: - - Duplicate store name → toast (Stores section) - - Block create-list when no stores → toast (Lists section) - - Confirm-before-delete error surfacing (Stores section) -- Build a small reusable Svelte component (success / info / warning / error variants, auto-dismiss with timeout, optional action button for "Undo"), expose via `$lib/toast.ts`. -- Migrate existing `alert(e.message)` call sites as part of landing it. - ### Account & session lifecycle - **Password reset.** Currently no flow, no email infrastructure. Required before multi-family launches publicly. Needs SMTP config, email template, single-use token table, rate limiting on the reset endpoint. - **JWT refresh tokens.** Today's tokens are signed with HS256 and (per `Program.cs`) likely have no refresh path; they expire and the user gets bounced to login. Add refresh tokens with rotation + revocation list. @@ -221,8 +213,7 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure ### Block create-list flow when no stores exist - A shopping list requires a `Store` (see `ListSummary.store` and the `newStoreId` state in `lists/+page.svelte`), so the create-list flow shouldn't be available until at least one store exists. -- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a toast or modal that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form. -- Belongs with the toast-component work above. +- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a `toast.warning()` (or modal) that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form. ## Stores @@ -239,12 +230,11 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure - Add a modal confirmation prompt before deleting a store (currently one click → gone, no undo). - Disallow deletion of a store that has any active shopping lists associated with it. - Backend status: `StoreEndpoints.cs` DELETE already returns 400 with `{error: "Store has shopping lists. Remove them first."}` if any list references the store. Consider switching to 409 Conflict for semantic correctness. -- Frontend status: `stores/+page.svelte` `deleteStore` currently shows the backend message via `alert()`. Replace with the new confirmation modal that also surfaces this error inline. +- Frontend status: `stores/+page.svelte` `deleteStore` currently surfaces the backend message via `toast.error()`. Replace with the new confirmation modal that also surfaces this error inline (or as a toast on confirm-action failure). - Open question: what counts as "active"? Backend currently blocks on *any* list. Decide whether archived/completed lists should still block deletion. ### Duplicate store name → 500 + silent frontend (bug) - Adding a store with a name that already exists triggers the unique constraint on `Store.Name`, which leaks out as a 500 from `StoreEndpoints.cs` POST (no exception handling). - Frontend `addStore` in `stores/+page.svelte` swallows the error — the form just sits there with no feedback. -- Fix: backend should pre-check (or catch the unique-violation) and return 409 with a helpful message; frontend should display a **toast** (e.g. "A store named 'Kroger' already exists") rather than a silent failure or a blocking `alert()`. +- Fix: backend should pre-check (or catch the unique-violation) and return 409 with a helpful message; frontend should display a `toast.error()` (e.g. "A store named 'Kroger' already exists") rather than failing silently. - Same pattern likely affects PUT (rename to an existing name). -- Note: app does not currently have a toast system. This work probably needs a small reusable toast component before the duplicate-name handling can land — worth adopting it across the app (the existing `alert(e.message)` calls on delete failures should also move to toasts). diff --git a/src/frontend/src/lib/Toaster.svelte b/src/frontend/src/lib/Toaster.svelte new file mode 100644 index 0000000..d5c9aa0 --- /dev/null +++ b/src/frontend/src/lib/Toaster.svelte @@ -0,0 +1,58 @@ + + +
+ {#each toasts as t (t.id)} +
+ {t.message} + {#if t.action} + + {/if} + +
+ {/each} +
+ + diff --git a/src/frontend/src/lib/toast.svelte.ts b/src/frontend/src/lib/toast.svelte.ts new file mode 100644 index 0000000..0bce981 --- /dev/null +++ b/src/frontend/src/lib/toast.svelte.ts @@ -0,0 +1,75 @@ +export type ToastVariant = 'success' | 'info' | 'warning' | 'error'; + +export interface ToastAction { + label: string; + onClick: () => void; +} + +export interface Toast { + id: number; + message: string; + variant: ToastVariant; + action?: ToastAction; +} + +export interface ToastOptions { + variant?: ToastVariant; + /** Auto-dismiss after this many ms. Pass 0 to keep the toast until dismissed manually. */ + duration?: number; + action?: ToastAction; +} + +const DEFAULT_DURATION_MS = 4000; + +let toasts = $state([]); +let nextId = 0; +const timers = new Map>(); + +export function getToasts(): Toast[] { + return toasts; +} + +export function showToast(message: string, options: ToastOptions = {}): number { + const id = ++nextId; + toasts.push({ + id, + message, + variant: options.variant ?? 'info', + action: options.action + }); + + const duration = options.duration ?? DEFAULT_DURATION_MS; + if (duration > 0) { + const handle = setTimeout(() => dismissToast(id), duration); + timers.set(id, handle); + } + + return id; +} + +export function dismissToast(id: number): void { + const handle = timers.get(id); + if (handle !== undefined) { + clearTimeout(handle); + timers.delete(id); + } + toasts = toasts.filter((t) => t.id !== id); +} + +export function clearAllToasts(): void { + for (const handle of timers.values()) clearTimeout(handle); + timers.clear(); + toasts = []; +} + +export const toast = { + success: (message: string, options: Omit = {}) => + showToast(message, { ...options, variant: 'success' }), + info: (message: string, options: Omit = {}) => + showToast(message, { ...options, variant: 'info' }), + warning: (message: string, options: Omit = {}) => + showToast(message, { ...options, variant: 'warning' }), + error: (message: string, options: Omit = {}) => + showToast(message, { ...options, variant: 'error' }), + dismiss: dismissToast +}; diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte index f9a2973..b307374 100644 --- a/src/frontend/src/routes/+layout.svelte +++ b/src/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { clearToken } from '$lib/api'; import { initAuth, isLoggedIn } from '$lib/auth.svelte'; + import Toaster from '$lib/Toaster.svelte'; import { goto } from '$app/navigation'; import { onMount } from 'svelte'; @@ -60,6 +61,8 @@ {@render children()} {/if} + +