# Modern Error Displays Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace every page/component's ad-hoc inline `error`-state paragraph with a shared, ephemeral, color-coded banner system (red/error, orange/warning, blue/notice, green/success), per `docs/superpowers/specs/2026-07-27-modern-error-displays-design.md`. **Architecture:** A plain module `frontend/src/banner.ts` holds a module-level singleton list of active banners plus a tiny pub-sub (`showError`/`showWarning`/`showNotice`/`showSuccess`, `dismiss`, `subscribe`/`getSnapshot`). A single `` component (mounted once, in `main.tsx`, above ``) renders the current list via `useSyncExternalStore`, in normal document flow so it pushes down whatever's currently showing. Every existing inline error site across the frontend switches to calling `showError` (or `showSuccess`/`showNotice` where relevant) instead of local state. **Tech Stack:** React/TypeScript (frontend only -- no backend changes). No frontend test suite -- verified via `tsc -b`/`vite build`, `oxlint`, and manual browser smoke tests, per this repo's established convention. ## Global Constraints - Frontend: `npm run build` (`tsc -b && vite build`) and `npm run lint` (oxlint) must be clean before every commit in this plan. - Auto-dismiss duration is 8000ms, defined once in `banner.ts` -- no per-call-site duration overrides. - Exactly four severities, with these exact exported function names and CSS class names: `showError`/`banner-error` (red), `showWarning`/`banner-warning` (orange), `showNotice`/`banner-notice` (blue), `showSuccess`/`banner-success` (green). `showWarning` and `showNotice` have no caller yet in this plan (nothing in the current codebase needs them) -- they exist because the design calls for all four severities to be available, not because this plan invents a use for them. Do not remove them for being "unused." - `` is mounted **exactly once**, in `frontend/src/main.tsx`, as a sibling above ``, rendered in normal document flow (not `position: fixed`) -- never mounted a second time inside `App.tsx`/`LoginGate.tsx`/`OnboardingWizard.tsx`. - This is a full replacement, not an addition alongside the old pattern: once a file's migration task is complete, it must have zero remaining `useState` for an `error`/local-message string that exists solely to render an inline failure paragraph, and zero remaining `

`-style inline error paragraphs -- except `OnboardingWizard.tsx`'s field-validation messages ("Please enter a display name.", "Please enter your Garmin email and password."), which stay exactly as they are today (local state, inline paragraph) since they're about fixing a specific input, not a transient system event. - No backend changes anywhere in this plan. No new "logout failed" error path is invented -- `handleSessionLogout` has no failure mode today and none is added. --- ### Task 1: Banner store, `BannerStack` component, mount in `main.tsx` **Files:** - Create: `frontend/src/banner.ts` - Create: `frontend/src/components/BannerStack.tsx` - Create: `frontend/src/components/BannerStack.css` - Modify: `frontend/src/main.tsx` **Interfaces:** - Consumes: nothing new (React's `useSyncExternalStore`, already available -- this repo is on React 19). - Produces: `showError(message: string): void`, `showWarning(message: string): void`, `showNotice(message: string): void`, `showSuccess(message: string): void` (all from `frontend/src/banner.ts`) -- every later task in this plan imports one or more of these. - [ ] **Step 1: Create the banner store** Create `frontend/src/banner.ts`: ```ts export type BannerSeverity = "error" | "warning" | "notice" | "success"; export interface BannerEntry { id: number; severity: BannerSeverity; message: string; } const DISMISS_AFTER_MS = 8000; let banners: BannerEntry[] = []; let nextId = 1; const listeners = new Set<() => void>(); function notify() { for (const listener of listeners) listener(); } function show(severity: BannerSeverity, message: string): void { const id = nextId++; banners = [{ id, severity, message }, ...banners]; notify(); setTimeout(() => dismiss(id), DISMISS_AFTER_MS); } export function showError(message: string): void { show("error", message); } export function showWarning(message: string): void { show("warning", message); } export function showNotice(message: string): void { show("notice", message); } export function showSuccess(message: string): void { show("success", message); } export function dismiss(id: number): void { banners = banners.filter((b) => b.id !== id); notify(); } export function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); } export function getSnapshot(): BannerEntry[] { return banners; } ``` - [ ] **Step 2: Create the CSS** Create `frontend/src/components/BannerStack.css`: ```css .banner-stack { display: flex; flex-direction: column; } .banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem 1.5rem; white-space: pre-line; } .banner-message { flex: 1; } .banner-close { background: none; border: none; color: inherit; font-size: 1.1rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; } .banner-error { background: #7f1d1d; border-bottom: 1px solid #f87171; color: #fff; } .banner-warning { background: #7c2d12; border-bottom: 1px solid #fb923c; color: #fff; } .banner-notice { background: #1e3a5f; border-bottom: 1px solid #3b82f6; color: #fff; } .banner-success { background: #14532d; border-bottom: 1px solid #22c55e; color: #fff; } ``` - [ ] **Step 3: Create the renderer component** Create `frontend/src/components/BannerStack.tsx`: ```tsx import { useSyncExternalStore } from "react"; import "./BannerStack.css"; import { dismiss, getSnapshot, subscribe } from "../banner"; // Mounted exactly once, in main.tsx, above -- see // docs/superpowers/specs/2026-07-27-modern-error-displays-design.md. Renders // in normal document flow (not position: fixed) so it pushes down whatever's // currently showing (login screen, onboarding wizard, or the full app), // without needing a separate mount point in each of those three layouts. export function BannerStack() { const banners = useSyncExternalStore(subscribe, getSnapshot); if (banners.length === 0) return null; return (

{banners.map((b) => (
{b.message}
))}
); } ``` - [ ] **Step 4: Mount it in `main.tsx`** Replace the full contents of `frontend/src/main.tsx`: ```tsx import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BannerStack } from './components/BannerStack.tsx' import { LoginGate } from './LoginGate.tsx' createRoot(document.getElementById('root')!).render( , ) ``` - [ ] **Step 5: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` Expected: clean build, no errors. Run: `cd frontend && npm run lint 2>&1 | tail -30` Expected: only the pre-existing, unrelated `PaceField.tsx` fast-refresh warnings. - [ ] **Step 6: Manual smoke test of the store in isolation** Nothing calls `showError`/etc. yet, so verify the plumbing itself works before wiring any real caller into it: Start the dev server (`cd frontend && npm run dev`), open the app in a browser, open the browser console, and run: ```js const m = await import("/src/banner.ts"); m.showError("Test error banner"); m.showSuccess("Test success banner"); ``` (This dynamic import resolves to the same singleton module instance Vite is already serving to ``, since Vite's dev server caches ES modules by URL -- it is not a separate, disconnected instance.) Expected: two banners appear, stacked (success on top since it was shown second, newest-on-top), red error on the bottom, green success above it, pushing the login screen down slightly. Click the × on one -- it disappears immediately. Wait ~8s -- the other disappears on its own. - [ ] **Step 7: Commit** ```bash git add frontend/src/banner.ts frontend/src/components/BannerStack.tsx frontend/src/components/BannerStack.css frontend/src/main.tsx git commit -m "$(cat <<'EOF' feat(frontend): add shared banner system (error/warning/notice/success) A plain module-level singleton store (banner.ts) plus a single renderer (BannerStack, mounted once above LoginGate in main.tsx) -- no React Context, since this codebase has none today and a plain exported function is callable from anywhere, including api/client.ts (a plain module, not a component) in a later task. Nothing calls it yet; that's each of this plan's remaining tasks. EOF )" ``` --- ### Task 2: Friendlier network-unreachable message in `client.ts` **Files:** - Modify: `frontend/src/api/client.ts:18-38` (`request`) **Interfaces:** - Consumes: nothing new. - Produces: no signature change to `request`/`api.*` -- callers are unaffected. The only observable change is the *text* of the `Error` thrown when `fetch()` itself fails (network down, connection refused, offline), which every later task's `showError(String(e))` call site picks up automatically. - [ ] **Step 1: Wrap the `fetch()` call** In `frontend/src/api/client.ts`, replace: ```ts async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE_URL}${path}`, { headers: { "Content-Type": "application/json" }, credentials: "include", ...init, }); if (res.status === 401 && path !== "/api/session/me") { ``` with: ```ts async function request(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(`${BASE_URL}${path}`, { headers: { "Content-Type": "application/json" }, credentials: "include", ...init, }); } catch { // fetch() itself rejecting (not a non-2xx response, which is handled // below) means the backend genuinely isn't reachable -- offline, // connection refused, CORS, etc. The raw browser error text here // (e.g. "TypeError: Failed to fetch") is not something to show a user; // every caller's catch-and-showError already displays whatever this // throws, so the friendly text only needs to be written once, here. throw new Error("Can't reach the server — check your connection."); } if (res.status === 401 && path !== "/api/session/me") { ``` (the rest of `request` -- the 401 branch, the `!res.ok` branch, the 204/json return -- is unchanged; only the `fetch()` call itself moves inside a `try`/`catch`.) - [ ] **Step 2: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 3: Manual verification** Stop the backend (if running), start the frontend dev server, load the app, and confirm any action that hits the API (e.g. the initial session check) eventually surfaces "Can't reach the server — check your connection." rather than a raw `TypeError` -- note this won't render as a banner yet (no caller uses `showError` until Task 3+), so check via the browser console/network tab that the thrown error's `.message` is the friendly text, or via a temporary `console.log` you remove before committing. - [ ] **Step 4: Commit** ```bash git add frontend/src/api/client.ts git commit -m "$(cat <<'EOF' fix(frontend): show a friendly message when the backend is unreachable request()'s fetch() call throwing (network down, connection refused, offline) previously propagated the raw browser error text (e.g. "TypeError: Failed to fetch"). Centralizing the friendly message here, rather than in each call site, means every existing catch((e) => showError(String(e))) pattern gets it for free. EOF )" ``` --- ### Task 3: Migrate `GarminConnection.tsx` **Files:** - Modify: `frontend/src/components/GarminConnection.tsx` **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new -- `SyncModal` (Task 4) is unaffected by this task, still rendered the same way (`{showSyncModal && }`). - [ ] **Step 1: Add the import** In `frontend/src/components/GarminConnection.tsx`, replace: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import type { AuthResponse } from "../types/api"; import { SyncModal } from "./SyncModal"; ``` with: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { showError } from "../banner"; import type { AuthResponse } from "../types/api"; import { SyncModal } from "./SyncModal"; ``` - [ ] **Step 2: Remove the local `error` state** Replace: ```tsx const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // There's no real Garmin logout -- the backend session just sits idle. ``` with: ```tsx const [busy, setBusy] = useState(false); // There's no real Garmin logout -- the backend session just sits idle. ``` - [ ] **Step 3: Swap every `setError` call site for `showError`, remove the now-unneeded resets** Replace the mount effect: ```tsx useEffect(() => { api.authStatus().then(setAuth).catch((e) => setError(String(e))); const interval = setInterval(() => { ``` with: ```tsx useEffect(() => { api.authStatus().then(setAuth).catch((e) => showError(String(e))); const interval = setInterval(() => { ``` Replace `connect()`: ```tsx async function connect() { setBusy(true); setError(null); try { await onBeforeConnect?.(); setAuth(await api.login()); setDisconnected(false); } catch (e) { setError(String(e)); } finally { setBusy(false); } } ``` with: ```tsx async function connect() { setBusy(true); try { await onBeforeConnect?.(); setAuth(await api.login()); setDisconnected(false); } catch (e) { showError(String(e)); } finally { setBusy(false); } } ``` Replace `submitMFA()`: ```tsx async function submitMFA() { if (!code.trim()) return; setBusy(true); setError(null); try { setAuth(await api.submitMFA(code.trim())); setCode(""); } catch (e) { setError(String(e)); } finally { setBusy(false); } } ``` with: ```tsx async function submitMFA() { if (!code.trim()) return; setBusy(true); try { setAuth(await api.submitMFA(code.trim())); setCode(""); } catch (e) { showError(String(e)); } finally { setBusy(false); } } ``` Replace `sync()`: ```tsx async function sync() { setBusy(true); setError(null); try { await api.syncRun(); setShowSyncModal(true); } catch (e) { setError(String(e)); } finally { setBusy(false); } } ``` with: ```tsx async function sync() { setBusy(true); try { await api.syncRun(); setShowSyncModal(true); } catch (e) { showError(String(e)); } finally { setBusy(false); } } ``` Replace `resetAll()`'s body from `setBusy(true)` through its catch block: ```tsx setBusy(true); setError(null); try { await api.resetSync(); // Reset runs as a background sync job; wait for it to actually finish // before reloading, otherwise other pages (e.g. Activities) would // still show the just-deleted activities from their own stale state. let status = await api.syncStatus(); while (status.in_progress) { await new Promise((resolve) => setTimeout(resolve, 300)); status = await api.syncStatus(); } window.location.reload(); } catch (e) { setError(String(e)); setBusy(false); } ``` with: ```tsx setBusy(true); try { await api.resetSync(); // Reset runs as a background sync job; wait for it to actually finish // before reloading, otherwise other pages (e.g. Activities) would // still show the just-deleted activities from their own stale state. let status = await api.syncStatus(); while (status.in_progress) { await new Promise((resolve) => setTimeout(resolve, 300)); status = await api.syncStatus(); } window.location.reload(); } catch (e) { showError(String(e)); setBusy(false); } ``` - [ ] **Step 4: Remove the inline error paragraph** Replace: ```tsx {!disconnected && auth?.message &&

{auth.message}

} {error &&

{error}

} {showSyncModal && setShowSyncModal(false)} />} ``` with: ```tsx {!disconnected && auth?.message &&

{auth.message}

} {showSyncModal && setShowSyncModal(false)} />} ``` - [ ] **Step 5: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 6: Manual smoke test** With the backend stopped, load the Profile page and click "Connect to Garmin" -- confirm a red error banner appears (the friendly "Can't reach the server" message from Task 2) instead of nothing/a silent failure. - [ ] **Step 7: Commit** ```bash git add frontend/src/components/GarminConnection.tsx git commit -m "$(cat <<'EOF' refactor(frontend): migrate GarminConnection.tsx to the banner system Removes the local error state and its inline paragraph; every failure (connect, MFA, sync trigger, reset-all) now calls showError directly. EOF )" ``` --- ### Task 4: Migrate `SyncModal.tsx` (auto-close on completion, banner for the result) **Files:** - Modify: `frontend/src/components/SyncModal.tsx` **Interfaces:** - Consumes: `showError`/`showSuccess` (Task 1). - Produces: `SyncModal({ onClose })`'s prop signature is unchanged -- `GarminConnection.tsx` (Task 3) needs no further change here. Behavior changes: the modal now closes itself (calling `onClose`) the instant a poll reports `in_progress: false`, instead of rendering a final-result block and waiting for a manual Close click. - [ ] **Step 1: Replace the whole file** The modal keeps its `Close` button as a manual escape hatch (e.g. if a user wants to dismiss it before it auto-closes, or if polling gets stuck) -- it's unconditionally rendered today and stays that way; only the "final result" block is removed, since that's now handled by the banner. Replace the full contents of `frontend/src/components/SyncModal.tsx`: ```tsx import { useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { showError, showSuccess } from "../banner"; import type { SyncStatus } from "../types/api"; function phaseDisplay(status: SyncStatus): { label: string; bar: { done: number; total: number } | null } { const { Phase, Done, Total } = status.progress; switch (Phase) { case "discovering": return { label: "Discovering activities…", bar: null }; case "activities": return { label: `Activities: ${Done}/${Total}`, bar: { done: Done, total: Total } }; case "workouts": return { label: `Workouts: ${Done}/${Total}`, bar: { done: Done, total: Total } }; default: return { label: "Starting…", bar: null }; } } // Builds the single banner message covering both the sync outcome and any // pending-activities/pending-workouts nudge, so completion produces exactly // one banner (with the nudges as extra lines) rather than several separate // ones landing at once. function completionMessage(status: SyncStatus): string { const lines: string[] = []; if (status.last_run) { lines.push( status.last_run.Status === "error" ? `Sync failed: ${status.last_run.ErrorMessage}` : `Sync complete: ${status.last_run.ActivitiesFetched} new activities`, ); } if (status.activities_pending_details > 0) { lines.push(`${status.activities_pending_details} more activities pending — click Sync now again`); } if (status.workouts_pending > 0) { lines.push(`${status.workouts_pending} more workouts pending — click Sync now again`); } return lines.join("\n"); } // Blocking overlay shown while "Sync now" is running -- purely the progress // bar/spinner (see docs/superpowers/specs/2026-07-27-modern-error-displays-design.md). // The instant a poll reports the sync finished, it reports the result via a // banner (success/error, folded together with any pending nudge) and closes // itself -- there's no manual Close step for the *result* anymore. The Close // button stays as a manual escape hatch for a still-running (or stuck) sync. export function SyncModal({ onClose }: { onClose: () => void }) { const [status, setStatus] = useState(null); const stoppedRef = useRef(false); // Captures the latest onClose without making it an effect dependency -- // GarminConnection passes a fresh inline arrow function on every one of // its own re-renders (e.g. its 6s auth-status poll), and this effect must // not restart because of that (it would reset stoppedRef/the in-flight // timeout and silently double the poll cadence). const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { stoppedRef.current = false; let timeout: ReturnType; const tick = () => { api .syncStatus() .then((s) => { setStatus(s); if (!s.in_progress) { if (s.last_run?.Status === "error") { showError(completionMessage(s)); } else { showSuccess(completionMessage(s)); } onCloseRef.current(); return; } if (!stoppedRef.current) timeout = setTimeout(tick, 1500); }) .catch((e) => { showError(String(e)); if (!stoppedRef.current) timeout = setTimeout(tick, 1500); }); }; tick(); return () => { stoppedRef.current = true; clearTimeout(timeout); }; }, []); const { label, bar } = status ? phaseDisplay(status) : { label: "Starting…", bar: null }; return (
Synchronizing
{bar ? ( <>

{label}

) : ( <>

{label}

)}
); } ``` - [ ] **Step 2: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 3: Manual smoke test** Against `cmd/seedsample` data or a real Garmin-connected profile, click "Sync now" on the Profile page and confirm: - The modal appears immediately (spinner + "Discovering activities…"), same as before. - It transitions through "Activities: N/M" / "Workouts: N/M" progress bars, same as before. - The instant sync finishes, the modal disappears on its own (no manual click needed) and a banner appears with the result (green "Sync complete: N new activities", or red "Sync failed: ..."), including any pending-activities/pending-workouts line folded into the same banner. - Clicking "Close" while a sync is still in progress dismisses the modal early without producing a banner (there's no result to report yet). - [ ] **Step 4: Commit** ```bash git add frontend/src/components/SyncModal.tsx git commit -m "$(cat <<'EOF' refactor(frontend): SyncModal reports its result via a banner, auto-closes The modal is now purely the progress bar/spinner. The instant a poll reports the sync finished, it builds one banner message (the success/error line plus any pending-activities/pending-workouts nudge as extra lines) and closes itself -- no more manual Close click to dismiss a result the user already saw. The Close button stays as a manual escape hatch for a still-running sync. EOF )" ``` --- ### Task 5: Migrate `Profile.tsx` **Files:** - Modify: `frontend/src/pages/Profile.tsx` **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. - [ ] **Step 1: Add the import** Replace: ```tsx import { useEffect, useRef, useState } from "react"; import { api, BASE_URL } from "../api/client"; import { ColorField } from "../components/ColorField"; ``` with: ```tsx import { useEffect, useRef, useState } from "react"; import { api, BASE_URL } from "../api/client"; import { showError } from "../banner"; import { ColorField } from "../components/ColorField"; ``` - [ ] **Step 2: Replace the local `error` state with a `profileLoadFailed` boolean** The page can't render its fields at all without `profile` loaded, so unlike the other pages in this plan, its "nothing loaded yet" fallback needs *some* way to distinguish "still loading" from "failed to load" -- the actual error text now lives only in the (transient) banner, so a boolean drives which fallback text shows. Replace: ```tsx const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); ``` with: ```tsx const [profile, setProfile] = useState(null); const [profileLoadFailed, setProfileLoadFailed] = useState(false); const [saved, setSaved] = useState(false); ``` - [ ] **Step 3: Update the mount effect** Replace: ```tsx useEffect(() => { api.getProfile().then(setProfile).catch((e) => setError(String(e))); }, []); ``` with: ```tsx useEffect(() => { api.getProfile().then(setProfile).catch((e) => { showError(String(e)); setProfileLoadFailed(true); }); }, []); ``` - [ ] **Step 4: Update `persist()`** Replace: ```tsx async function persist(next: ProfileType) { try { const updated = await api.updateProfile(next); profileRef.current = updated; setProfile(updated); setError(null); setSaved(true); onSaved?.(updated); } catch (e) { setError(String(e)); } } ``` with: ```tsx async function persist(next: ProfileType) { try { const updated = await api.updateProfile(next); profileRef.current = updated; setProfile(updated); setSaved(true); onSaved?.(updated); } catch (e) { showError(String(e)); } } ``` - [ ] **Step 5: Update `deleteAccount()`** Replace: ```tsx async function deleteAccount() { setDeleting(true); setError(null); try { ``` with: ```tsx async function deleteAccount() { setDeleting(true); try { ``` Replace: ```tsx } catch (e) { setError(String(e)); setDeleting(false); } } if (!profile) { return (
{error ?

{error}

:

Loading...

}
); } ``` with: ```tsx } catch (e) { showError(String(e)); setDeleting(false); } } if (!profile) { return (
{profileLoadFailed ?

Couldn't load profile.

:

Loading...

}
); } ``` - [ ] **Step 6: Remove the inline error paragraph in the main render** Replace: ```tsx
{error &&

{error}

} {saved &&

Saved.

} ``` with: ```tsx
{saved &&

Saved.

} ``` - [ ] **Step 7: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 8: Manual smoke test** With the backend running, load the Profile page and edit a field -- confirm autosave still works with no visual regression. Stop the backend, edit a field again -- confirm a red error banner appears (autosave failure) rather than nothing. - [ ] **Step 9: Commit** ```bash git add frontend/src/pages/Profile.tsx git commit -m "$(cat <<'EOF' refactor(frontend): migrate Profile.tsx to the banner system Load/save/delete failures now call showError instead of setting local error state. The profile-not-loaded-yet fallback gains a profileLoadFailed boolean so it can still distinguish "loading" from "failed to load" without the error text itself, which now lives only in the (transient) banner. EOF )" ``` --- ### Task 6: Migrate `Activities.tsx` **Files:** - Modify: `frontend/src/pages/Activities.tsx` **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. - [ ] **Step 1: Add the import** Replace: ```tsx import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; ``` with: ```tsx import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "../api/client"; import { showError } from "../banner"; import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; ``` - [ ] **Step 2: Remove the local `error` state** Replace: ```tsx const [filterKindId, setFilterKindId] = useState(""); const [error, setError] = useState(null); const [resolvingId, setResolvingId] = useState(null); ``` with: ```tsx const [filterKindId, setFilterKindId] = useState(""); const [resolvingId, setResolvingId] = useState(null); ``` - [ ] **Step 3: Update `loadMore`'s catch** Replace: ```tsx } catch (e) { if (gen === generationRef.current) setError(String(e)); } finally { setLoadingMore(false); ``` with: ```tsx } catch (e) { if (gen === generationRef.current) showError(String(e)); } finally { setLoadingMore(false); ``` - [ ] **Step 4: Update `loadFirstPage`'s catch** Replace: ```tsx .catch((e) => { if (gen === generationRef.current) setError(String(e)); }) .finally(() => { if (gen === generationRef.current) setInitialLoading(false); }); ``` with: ```tsx .catch((e) => { if (gen === generationRef.current) showError(String(e)); }) .finally(() => { if (gen === generationRef.current) setInitialLoading(false); }); ``` - [ ] **Step 5: Update the mount effect** Replace: ```tsx useEffect(() => { loadFirstPage(""); api.listWorkoutKinds().then(setKinds).catch((e) => setError(String(e))); api.getProfile().then(setProfile).catch((e) => setError(String(e))); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); ``` with: ```tsx useEffect(() => { loadFirstPage(""); api.listWorkoutKinds().then(setKinds).catch((e) => showError(String(e))); api.getProfile().then(setProfile).catch((e) => showError(String(e))); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); ``` - [ ] **Step 6: Update `resolve`, `unassign`, `unlock`** Replace (in `resolve`): ```tsx } catch (e) { setError(String(e)); } finally { setResolvingId(null); } } async function unassign(activityId: number) { ``` with: ```tsx } catch (e) { showError(String(e)); } finally { setResolvingId(null); } } async function unassign(activityId: number) { ``` Replace (in `unassign`): ```tsx } catch (e) { setError(String(e)); } finally { setResolvingId(null); } } async function unlock(activityId: number) { ``` with: ```tsx } catch (e) { showError(String(e)); } finally { setResolvingId(null); } } async function unlock(activityId: number) { ``` Replace (in `unlock`): ```tsx } catch (e) { setError(String(e)); } finally { setResolvingId(null); } } function toggleFilter(id: string) { ``` with: ```tsx } catch (e) { showError(String(e)); } finally { setResolvingId(null); } } function toggleFilter(id: string) { ``` - [ ] **Step 7: Remove the inline error paragraph** Replace: ```tsx
{error &&

{error}

} {grandTotal > 0 && ( ``` with: ```tsx
{grandTotal > 0 && ( ``` - [ ] **Step 8: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 9: Manual smoke test** With the backend running, load the Activities tab -- confirm the list still loads and classify/unlock actions still work with no visual regression. Stop the backend and reload -- confirm a red error banner appears instead of a silent failure. - [ ] **Step 10: Commit** ```bash git add frontend/src/pages/Activities.tsx git commit -m "$(cat <<'EOF' refactor(frontend): migrate Activities.tsx to the banner system Every load/classify/unlock failure now calls showError instead of setting local error state; the inline error paragraph is removed. EOF )" ``` --- ### Task 7: Migrate `Analysis.tsx` **Files:** - Modify: `frontend/src/pages/Analysis.tsx` **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. - [ ] **Step 1: Add the import** Replace: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { ProgressionChart } from "../components/charts/ProgressionChart"; ``` with: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { showError } from "../banner"; import { ProgressionChart } from "../components/charts/ProgressionChart"; ``` - [ ] **Step 2: Remove the local `error` state and update both effects** Replace: ```tsx const [points, setPoints] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { api .listWorkoutKinds() .then((k) => { setKinds(k); if (k.length > 0) setSelectedKindId(k[0].ID); }) .catch((e) => setError(String(e))); }, []); useEffect(() => { if (selectedKindId == null) return; setLoading(true); setError(null); api .progression(selectedKindId, metric) .then(setPoints) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); }, [selectedKindId, metric]); ``` with: ```tsx const [points, setPoints] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { api .listWorkoutKinds() .then((k) => { setKinds(k); if (k.length > 0) setSelectedKindId(k[0].ID); }) .catch((e) => showError(String(e))); }, []); useEffect(() => { if (selectedKindId == null) return; setLoading(true); api .progression(selectedKindId, metric) .then(setPoints) .catch((e) => showError(String(e))) .finally(() => setLoading(false)); }, [selectedKindId, metric]); ``` - [ ] **Step 3: Remove the inline error paragraph** Replace: ```tsx {error &&

{error}

} {loading ?

Loading...

: } ``` with: ```tsx {loading ?

Loading...

: } ``` - [ ] **Step 4: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 5: Manual smoke test** With the backend running, load the Analysis tab and switch training types/metrics -- confirm no visual regression. Stop the backend and switch again -- confirm a red error banner appears. - [ ] **Step 6: Commit** ```bash git add frontend/src/pages/Analysis.tsx git commit -m "$(cat <<'EOF' refactor(frontend): migrate Analysis.tsx to the banner system EOF )" ``` --- ### Task 8: Migrate `TrainingTypesCard.tsx` **Files:** - Modify: `frontend/src/components/TrainingTypesCard.tsx` **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. - [ ] **Step 1: Add the import** Replace: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { NullableNumberField } from "./NullableNumberField"; ``` with: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import { showError } from "../banner"; import { NullableNumberField } from "./NullableNumberField"; ``` - [ ] **Step 2: Remove the local `error` state and its resets** Replace: ```tsx const [hrMax, setHrMax] = useState(null); const [error, setError] = useState(null); function reload() { api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e))); } useEffect(reload, []); function startEdit(k: WorkoutKind) { setEditing(k); setName(k.Name); setDescription(k.Description); setPaceMin(k.pace_min_sec_per_km); setPaceMax(k.pace_max_sec_per_km); setHrMin(k.hr_min_pct_hrr); setHrMax(k.hr_max_pct_hrr); setError(null); } async function save() { if (!editing) return; setError(null); try { await api.updateWorkoutKind(editing.ID, { name, description, rule: JSON.parse(editing.RuleJSON || "{}"), pace_min_sec_per_km: paceMin, pace_max_sec_per_km: paceMax, hr_min_pct_hrr: hrMin, hr_max_pct_hrr: hrMax, }); setEditing(null); reload(); } catch (e) { setError(String(e)); } } ``` with: ```tsx const [hrMax, setHrMax] = useState(null); function reload() { api.listWorkoutKinds(true).then(setKinds).catch((e) => showError(String(e))); } useEffect(reload, []); function startEdit(k: WorkoutKind) { setEditing(k); setName(k.Name); setDescription(k.Description); setPaceMin(k.pace_min_sec_per_km); setPaceMax(k.pace_max_sec_per_km); setHrMin(k.hr_min_pct_hrr); setHrMax(k.hr_max_pct_hrr); } async function save() { if (!editing) return; try { await api.updateWorkoutKind(editing.ID, { name, description, rule: JSON.parse(editing.RuleJSON || "{}"), pace_min_sec_per_km: paceMin, pace_max_sec_per_km: paceMax, hr_min_pct_hrr: hrMin, hr_max_pct_hrr: hrMax, }); setEditing(null); reload(); } catch (e) { showError(String(e)); } } ``` - [ ] **Step 3: Remove the inline error paragraph** Replace: ```tsx Training types {error &&

{error}

}
    ``` with: ```tsx Training types
      ``` - [ ] **Step 4: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 5: Manual smoke test** With the backend running, edit and save a training type on the Profile page -- confirm no visual regression. Stop the backend and try saving again -- confirm a red error banner appears. - [ ] **Step 6: Commit** ```bash git add frontend/src/components/TrainingTypesCard.tsx git commit -m "$(cat <<'EOF' refactor(frontend): migrate TrainingTypesCard.tsx to the banner system EOF )" ``` --- ### Task 9: Migrate `LoginGate.tsx` **Files:** - Modify: `frontend/src/LoginGate.tsx` - Modify: `frontend/src/LoginGate.css:14-16` (remove `.login-gate-error`) **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. - [ ] **Step 1: Move the `auth_error` handling into an effect** In `frontend/src/LoginGate.tsx`, replace: ```tsx import { useEffect, useState } from "react"; import { api, BASE_URL } from "./api/client"; import "./LoginGate.css"; import App from "./App"; ``` with: ```tsx import { useEffect, useState } from "react"; import { api, BASE_URL } from "./api/client"; import { showError } from "./banner"; import "./LoginGate.css"; import App from "./App"; ``` Replace: ```tsx useEffect(() => { api .getSessionInfo() .then((s) => { setSession(s); setStatus("authenticated"); }) .catch(() => setStatus("unauthenticated")); }, []); if (status === "loading") { return
      Loading…
      ; } if (status === "unauthenticated") { const authError = new URLSearchParams(window.location.search).get("auth_error"); return (

      🧞‍♀️ geniusrun

      {authError &&

      {AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}

      } Log in
      ); } ``` with: ```tsx useEffect(() => { api .getSessionInfo() .then((s) => { setSession(s); setStatus("authenticated"); }) .catch(() => setStatus("unauthenticated")); }, []); // Keycloak redirects back here with ?auth_error= when the // login-gate's own role check (not Keycloak's own authentication) rejects // a session -- surfaced once, as a banner, the moment this screen is // reached. useEffect(() => { if (status !== "unauthenticated") return; const authError = new URLSearchParams(window.location.search).get("auth_error"); if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."); }, [status]); if (status === "loading") { return
      Loading…
      ; } if (status === "unauthenticated") { return (

      🧞‍♀️ geniusrun

      Log in
      ); } ``` - [ ] **Step 2: Remove the now-dead CSS rule** In `frontend/src/LoginGate.css`, remove: ```css .login-gate-error { color: #f87171; } ``` (the blank line after it goes too, so the file doesn't end up with a doubled blank line between the remaining rules). - [ ] **Step 3: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 4: Manual smoke test** Load `/?auth_error=forbidden` while logged out -- confirm the login screen shows a red error banner reading "Your account isn't authorized for geniusrun." instead of the old inline text, and the "Log in" link/button still works normally. - [ ] **Step 5: Commit** ```bash git add frontend/src/LoginGate.tsx frontend/src/LoginGate.css git commit -m "$(cat <<'EOF' refactor(frontend): migrate LoginGate.tsx's auth_error to the banner system EOF )" ``` --- ### Task 10: Migrate `OnboardingWizard.tsx` (splitting validation from real failures) **Files:** - Modify: `frontend/src/OnboardingWizard.tsx` - `frontend/src/OnboardingWizard.css` is NOT modified in this task -- `.onboarding-wizard-error` stays in active use (see Step 4). **Interfaces:** - Consumes: `showError` (Task 1). - Produces: nothing new. `onCreated` prop signature unchanged. - [ ] **Step 1: Add the import, split the state** Field-validation messages ("Please enter a display name.", "Please enter your Garmin email and password.") stay local and inline -- they're about fixing a specific input, not a transient system event. Real failures (Garmin login rejected, MFA rejected, `complete()` failing) call `showError` instead. The `complete()`-failure Retry button's visibility switches from `error &&` to a new `completeFailed` boolean, since the error text itself now lives only in the (transient) banner. Replace: ```tsx import { useEffect, useRef, useState } from "react"; import { api } from "./api/client"; import "./OnboardingWizard.css"; import type { AuthResponse } from "./types/api"; ``` with: ```tsx import { useEffect, useRef, useState } from "react"; import { api } from "./api/client"; import { showError } from "./banner"; import "./OnboardingWizard.css"; import type { AuthResponse } from "./types/api"; ``` Replace: ```tsx const [auth, setAuth] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const completeTriggered = useRef(false); function submitName(e: React.FormEvent) { e.preventDefault(); if (!displayName.trim()) { setError("Please enter a display name."); return; } setError(null); setStep("garmin"); } async function submitGarmin(e: React.FormEvent) { e.preventDefault(); if (!email.trim() || !password) { setError("Please enter your Garmin email and password."); return; } setBusy(true); setError(null); try { setAuth(await api.setupGarminLogin(email.trim(), password)); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong, please try again."); } finally { setBusy(false); } } async function submitMFA() { if (!code.trim()) return; setBusy(true); setError(null); try { setAuth(await api.setupGarminMFA(code.trim())); setCode(""); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong, please try again."); } finally { setBusy(false); } } async function complete() { setBusy(true); setError(null); try { const result = await api.setupComplete(displayName.trim()); onCreated(result.display_name); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong, please try again."); setBusy(false); } } ``` with: ```tsx const [auth, setAuth] = useState(null); const [busy, setBusy] = useState(false); // Field-validation messages only ("please fill this in") -- real // API/Garmin failures go through the shared banner system instead (see // completeFailed below for why the Retry button no longer keys off this). const [validationError, setValidationError] = useState(null); const [completeFailed, setCompleteFailed] = useState(false); const completeTriggered = useRef(false); function submitName(e: React.FormEvent) { e.preventDefault(); if (!displayName.trim()) { setValidationError("Please enter a display name."); return; } setValidationError(null); setStep("garmin"); } async function submitGarmin(e: React.FormEvent) { e.preventDefault(); if (!email.trim() || !password) { setValidationError("Please enter your Garmin email and password."); return; } setBusy(true); setValidationError(null); try { setAuth(await api.setupGarminLogin(email.trim(), password)); } catch (err) { showError(err instanceof Error ? err.message : "Something went wrong, please try again."); } finally { setBusy(false); } } async function submitMFA() { if (!code.trim()) return; setBusy(true); try { setAuth(await api.setupGarminMFA(code.trim())); setCode(""); } catch (err) { showError(err instanceof Error ? err.message : "Something went wrong, please try again."); } finally { setBusy(false); } } async function complete() { setBusy(true); setCompleteFailed(false); try { const result = await api.setupComplete(displayName.trim()); onCreated(result.display_name); } catch (err) { showError(err instanceof Error ? err.message : "Something went wrong, please try again."); setCompleteFailed(true); setBusy(false); } } ``` - [ ] **Step 2: Update the three JSX render sites** Replace (the "name" step): ```tsx {error &&

      {error}

      }
); } ``` with: ```tsx {validationError &&

{validationError}

}
); } ``` Replace (the "authenticated, finishing setup" branch): ```tsx

Connected to Garmin — finishing setup…

{error && ( <>

{error}

)} ``` with: ```tsx

Connected to Garmin — finishing setup…

{completeFailed && ( )} ``` Replace (the "mfa_required" branch): ```tsx {auth.message &&

{auth.message}

} {error &&

{error}

}
) : ( ``` with: ```tsx {auth.message &&

{auth.message}

}
) : ( ``` Replace (the Garmin-login form branch, the last remaining use of `error`): ```tsx {auth?.message &&

{auth.message}

} {error &&

{error}

} )} ``` with: ```tsx {auth?.message &&

{auth.message}

} {validationError &&

{validationError}

} )} ``` - [ ] **Step 3: Verify build and lint are clean** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 4: Manual smoke test** Walk through onboarding as a brand-new account: leave the display name blank and submit -- confirm the inline "Please enter a display name." message still appears (not a banner). Enter a name, continue, then enter a wrong Garmin password -- confirm a red error banner appears instead of the old inline message, and the form is still usable to retry. Note: `frontend/src/OnboardingWizard.css`'s `.onboarding-wizard-error` rule is **not** touched in this task -- it's still in active use by `validationError`'s two remaining `

` sites, just no longer for real-failure messages. - [ ] **Step 5: Commit** ```bash git add frontend/src/OnboardingWizard.tsx git commit -m "$(cat <<'EOF' refactor(frontend): split OnboardingWizard's validation vs real failures Field-validation messages ("please fill this in") stay local and inline. Real failures (Garmin login rejected, MFA rejected, complete() failing) now call showError. The complete()-failure Retry button's visibility switches from the error text's presence to a new completeFailed boolean, since the error text itself now lives only in the (transient) banner. EOF )" ``` --- ### Task 11: Remove the now-dead `.error` CSS class, final verification **Files:** - Modify: `frontend/src/App.css:277-279` (remove `.error`) **Interfaces:** - Consumes: nothing. - Produces: nothing -- this is cleanup plus the plan's final verification pass. - [ ] **Step 1: Confirm nothing references `.error` anymore** Run: `grep -rn 'className="error"' frontend/src` Expected: no output. (Tasks 3, 5, 6, 7, 8 removed every use; `OnboardingWizard.css`'s `.onboarding-wizard-error` and `LoginGate.css`'s -- already removed in Task 9 -- are separate, differently-named classes, not `.error` itself.) - [ ] **Step 2: Remove the dead rule** In `frontend/src/App.css`, remove: ```css .error { color: #f87171; } ``` - [ ] **Step 3: Full verification** Run: `cd frontend && npm run build 2>&1 | tail -30` -- clean. Run: `cd frontend && npm run lint 2>&1 | tail -30` -- only the pre-existing `PaceField.tsx` warnings. - [ ] **Step 4: End-to-end manual smoke test** With both the backend and frontend dev servers running: - Trigger a real API error (e.g. an invalid save somewhere) -- confirm a red banner. - Stop the backend and trigger any action -- confirm a red banner reading "Can't reach the server — check your connection." - Restart the backend, run a full sync to completion -- confirm the modal auto-closes and a green success banner appears. - Trigger a sync that leaves pending activities/workouts (e.g. interrupt a large backfill, or use a small `detailFillLimit`) -- confirm the same banner's second line shows the pending nudge. - Load `?auth_error=failed` while logged out -- confirm a red banner on the login screen. - Trigger two errors in quick succession -- confirm they stack (newest on top) and each auto-dismisses independently after ~8s, and that clicking a banner's × dismisses it immediately. - [ ] **Step 5: Commit** ```bash git add frontend/src/App.css git commit -m "$(cat <<'EOF' chore(frontend): remove the now-dead .error CSS class Every inline error paragraph that used it has been migrated to the banner system across the preceding tasks. EOF )" ``` --- ## Final verification - [ ] Run the full frontend build/lint one more time: `cd frontend && npm run build && npm run lint` - [ ] Re-run the end-to-end manual smoke test from Task 11, Step 4, once more, fresh. - [ ] Use superpowers:finishing-a-development-branch to wrap up (tests green -> present the merge/PR/keep-as-is menu).