Add reusable toast notification system

Introduces a runes-based toast module (`$lib/toast.svelte.ts`) with
success/info/warning/error variants, auto-dismiss, optional action
button, plus a `Toaster` viewport mounted in the root layout.
Migrates the lone `alert(e.message)` call site (stores delete) to
`toast.error()`. Backlog updated to remove the now-completed
foundational item and rewrite dependent items to reference the
shipped API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-07 22:08:52 -05:00
parent 6780fb366e
commit 88c24b03ca
6 changed files with 256 additions and 14 deletions
+3 -13
View File
@@ -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. - **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. - 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 ### 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. - **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. - **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 ### 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. - 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. - 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.
- Belongs with the toast-component work above.
## Stores ## 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). - 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. - 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. - 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. - 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) ### 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). - 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. - 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). - 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).
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { getToasts, dismissToast, type ToastVariant } from '$lib/toast.svelte';
const toasts = $derived(getToasts());
const variantClasses: Record<ToastVariant, string> = {
success: 'bg-primary text-white',
info: 'bg-gray-800 text-white',
warning: 'bg-amber-500 text-white',
error: 'bg-danger text-white'
};
const roleFor = (variant: ToastVariant) =>
variant === 'error' || variant === 'warning' ? 'alert' : 'status';
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex flex-col items-center gap-2 px-4 toaster-safe"
aria-live="polite"
aria-atomic="false"
>
{#each toasts as t (t.id)}
<div
role={roleFor(t.variant)}
class="pointer-events-auto flex w-full max-w-md items-center gap-3 rounded-lg px-4 py-3 shadow-lg {variantClasses[
t.variant
]}"
>
<span class="flex-1 text-sm font-medium">{t.message}</span>
{#if t.action}
<button
type="button"
onclick={() => {
t.action!.onClick();
dismissToast(t.id);
}}
class="rounded px-2 py-1 text-sm font-semibold underline-offset-2 hover:underline"
>
{t.action.label}
</button>
{/if}
<button
type="button"
aria-label="Dismiss notification"
onclick={() => dismissToast(t.id)}
class="text-lg leading-none opacity-80 hover:opacity-100"
>
×
</button>
</div>
{/each}
</div>
<style>
.toaster-safe {
padding-bottom: env(safe-area-inset-bottom);
}
</style>
+75
View File
@@ -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<Toast[]>([]);
let nextId = 0;
const timers = new Map<number, ReturnType<typeof setTimeout>>();
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<ToastOptions, 'variant'> = {}) =>
showToast(message, { ...options, variant: 'success' }),
info: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
showToast(message, { ...options, variant: 'info' }),
warning: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
showToast(message, { ...options, variant: 'warning' }),
error: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
showToast(message, { ...options, variant: 'error' }),
dismiss: dismissToast
};
+3
View File
@@ -3,6 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { clearToken } from '$lib/api'; import { clearToken } from '$lib/api';
import { initAuth, isLoggedIn } from '$lib/auth.svelte'; import { initAuth, isLoggedIn } from '$lib/auth.svelte';
import Toaster from '$lib/Toaster.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -60,6 +61,8 @@
{@render children()} {@render children()}
{/if} {/if}
<Toaster />
<style> <style>
.safe-bottom { .safe-bottom {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
interface Store { interface Store {
id: number; id: number;
@@ -50,7 +51,7 @@
await api(`/api/stores/${id}`, { method: 'DELETE' }); await api(`/api/stores/${id}`, { method: 'DELETE' });
stores = stores.filter((s) => s.id !== id); stores = stores.filter((s) => s.id !== id);
} catch (e: any) { } catch (e: any) {
alert(e.message); toast.error(e.message);
} }
} }
</script> </script>
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
clearAllToasts,
dismissToast,
getToasts,
showToast,
toast
} from '$lib/toast.svelte';
beforeEach(() => {
clearAllToasts();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('showToast()', () => {
it('adds a toast with default info variant', () => {
const id = showToast('hello');
const toasts = getToasts();
expect(toasts).toHaveLength(1);
expect(toasts[0]).toMatchObject({ id, message: 'hello', variant: 'info' });
});
it('returns unique ids per call', () => {
const a = showToast('a');
const b = showToast('b');
expect(a).not.toBe(b);
});
it('auto-dismisses after the default duration', () => {
showToast('temporary');
expect(getToasts()).toHaveLength(1);
vi.advanceTimersByTime(4000);
expect(getToasts()).toHaveLength(0);
});
it('respects a custom duration', () => {
showToast('quick', { duration: 1000 });
vi.advanceTimersByTime(999);
expect(getToasts()).toHaveLength(1);
vi.advanceTimersByTime(1);
expect(getToasts()).toHaveLength(0);
});
it('does not auto-dismiss when duration is 0', () => {
showToast('persistent', { duration: 0 });
vi.advanceTimersByTime(60_000);
expect(getToasts()).toHaveLength(1);
});
it('attaches an action handler', () => {
const onClick = vi.fn();
showToast('undo me', { action: { label: 'Undo', onClick } });
const t = getToasts()[0];
expect(t.action?.label).toBe('Undo');
t.action?.onClick();
expect(onClick).toHaveBeenCalledOnce();
});
});
describe('dismissToast()', () => {
it('removes the matching toast and cancels its timer', () => {
const id = showToast('bye', { duration: 1000 });
dismissToast(id);
expect(getToasts()).toHaveLength(0);
// If the timer were still active and tried to dismiss again, no harm — just verify state stable.
vi.advanceTimersByTime(2000);
expect(getToasts()).toHaveLength(0);
});
it('is a no-op for an unknown id', () => {
showToast('still here');
dismissToast(9999);
expect(getToasts()).toHaveLength(1);
});
});
describe('toast.* helpers', () => {
it.each([
['success', 'success'],
['info', 'info'],
['warning', 'warning'],
['error', 'error']
] as const)('toast.%s sets variant to %s', (method, variant) => {
toast[method]('msg');
expect(getToasts()[0].variant).toBe(variant);
});
it('toast.dismiss removes the toast', () => {
const id = toast.error('oops', { duration: 0 });
toast.dismiss(id);
expect(getToasts()).toHaveLength(0);
});
});
describe('clearAllToasts()', () => {
it('removes every toast', () => {
showToast('a');
showToast('b');
showToast('c');
clearAllToasts();
expect(getToasts()).toHaveLength(0);
});
});