# Modern Error Displays — Design **Status:** Approved, ready for implementation planning. **Origin:** `docs/IDEAS.md`'s "modern error displays" backlog item. ## Goal Replace every page/component's ad-hoc inline `error`-state paragraph (`

{error}

` and its several near-duplicates) with a single, consistent banner system: ephemeral, color-coded, full-width bars (red for error, orange for warning, blue for notice, green for success) that stack, auto-dismiss, and can be closed manually. This is a full replacement, not an addition alongside the old pattern — every existing inline error display in the frontend is migrated to the new system, with one deliberate exception (OnboardingWizard's field-validation messages — see below). ## Non-goals - No backend changes. This is purely a frontend presentation change; no API shape changes anything about how errors are already communicated to the frontend. - No new "logout failed" error path. `handleSessionLogout` on the backend has no failure mode today (unconditional cookie-clear + redirect) — the banner system will be *capable* of showing a logout error if one is ever added, but nothing is invented here just to exercise it. ## Architecture ### `frontend/src/banner.ts` — the store A plain module, not a React Context/Provider. The codebase has no existing `createContext`/`useContext` usage anywhere, and — critically — `frontend/src/api/client.ts` is a plain module (not a component), so a Context wouldn't be reachable from it without extra plumbing. A module-level singleton store is simpler and callable from anywhere: ```ts type Severity = "error" | "warning" | "notice" | "success"; type Banner = { id: number; severity: Severity; message: string }; // newest-on-top; auto-dismisses after 8000ms; manually dismissible early. export function showError(message: string): void; export function showWarning(message: string): void; export function showNotice(message: string): void; export function showSuccess(message: string): void; export function dismiss(id: number): void; // useSyncExternalStore plumbing for the render side. export function subscribe(listener: () => void): () => void; export function getSnapshot(): Banner[]; ``` Multiple banners stack (newest on top); each dismisses independently on its own timer. A banner's `message` may contain embedded newlines (`\n`) for the sync-result-plus-nudges case (see below) — the renderer treats them as line breaks within one banner, not separate banners. ### `frontend/src/components/BannerStack.tsx` + `BannerStack.css` — the renderer - `useSyncExternalStore(subscribe, getSnapshot)` to read the current list. - Renders one `
` per active banner: the message text (respecting embedded newlines) plus a `×` close button that calls `dismiss(id)`. - Four CSS variants — `banner-error` (red bg), `banner-warning` (orange bg), `banner-notice` (blue bg), `banner-success` (green bg) — each with readable foreground text/icon color against its background. - **Mounted exactly once**, in `frontend/src/main.tsx`, as a sibling above ``: ```tsx createRoot(document.getElementById("root")!).render( , ); ``` Rendered in normal document flow (not `position: fixed`), so it naturally pushes down whatever's currently showing below it — the login screen, the onboarding wizard, or the full app (header + tabs + content) — without needing a separate mount point wired into each of those three top-level layouts individually. When no banners are active, it renders nothing (no reserved empty space). ### `frontend/src/api/client.ts` — friendlier network-failure messages `request()`'s `fetch()` call is wrapped so a network-level failure (the `fetch()` promise itself rejecting — offline, connection refused, CORS, etc.) throws a friendly, fixed message instead of the raw browser error text: ```ts let res: Response; try { res = await fetch(`${BASE_URL}${path}`, { ... }); } catch { throw new Error("Can't reach the server — check your connection."); } ``` A real HTTP response that just happens to be non-2xx (4xx/5xx) is unaffected — that branch keeps its existing `${status} ${body}` message, since that's a legitimate API-level error, not a "the backend isn't there" condition. This means every call site's existing pattern — `.catch((e) => setError(String(e)))` — becomes `.catch((e) => showError(String(e)))`, and the friendly message for a truly unreachable backend falls out for free, with no per-call-site network-error special-casing needed. This is what satisfies IDEAS.md's "used on all pages/tabs in case of backend is not here": every page's existing catch-and-display path already covers this, once it displays via `showError` instead of local state. ## Migration inventory Every current inline-error site, and what changes: | File | Before | After | |---|---|---| | `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error &&

{error}

}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. | | `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. | | `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. | | `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. | | `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. | | `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. | | `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `

` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. | | `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. | **Dead CSS removal:** once no `.tsx` file references them, delete the `.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from their respective CSS files. ## Data flow example (sync completion) 1. User clicks "Sync now" → `GarminConnection.sync()` calls `api.syncRun()`, sets `showSyncModal(true)`. 2. `SyncModal` polls `/api/sync/status` every 1.5s, rendering the spinner/progress bar per `progress.Phase` (unchanged from the existing implementation). 3. A poll response shows `in_progress: false` for the first time. `SyncModal` builds a message from `last_run.Status`/`ErrorMessage`/ `ActivitiesFetched` plus `activities_pending_details`/ `workouts_pending`, calls `showSuccess(message)` or `showError(message)` accordingly, then calls `onClose()`. 4. `GarminConnection` unmounts `SyncModal` (its normal `showSyncModal` toggle, unchanged). The banner, now living in the shared store independent of any unmounted component, continues to display and auto-dismiss on its own schedule. ## Testing No frontend test suite exists in this repo (established convention — verification is `tsc -b`/`vite build`, `oxlint`, and manual browser checks). Verification for this feature is: - `npm run build` / `npm run lint` clean. - Manual smoke test covering each severity: trigger a real API error (e.g. an invalid save), a network failure (stop the backend, attempt any action), a successful sync, a sync ending in error, a sync with pending activities/workouts remaining, and the login screen's `auth_error` redirect — confirming banners stack, auto-dismiss, and can be closed manually, and that `SyncModal` now closes itself the instant sync finishes rather than waiting for a manual Close click.