Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.7 KiB
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
(<p className="error">{error}</p> 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.
handleSessionLogouton 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:
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
<div className="banner banner-{severity}">per active banner: the message text (respecting embedded newlines) plus a×close button that callsdismiss(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<LoginGate />:createRoot(document.getElementById("root")!).render( <StrictMode> <BannerStack /> <LoginGate /> </StrictMode>, );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:
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 && <p className="error">{error}</p>} |
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 <p className="login-gate-error"> 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)
- User clicks "Sync now" →
GarminConnection.sync()callsapi.syncRun(), setsshowSyncModal(true). SyncModalpolls/api/sync/statusevery 1.5s, rendering the spinner/progress bar perprogress.Phase(unchanged from the existing implementation).- A poll response shows
in_progress: falsefor the first time.SyncModalbuilds a message fromlast_run.Status/ErrorMessage/ActivitiesFetchedplusactivities_pending_details/workouts_pending, callsshowSuccess(message)orshowError(message)accordingly, then callsonClose(). GarminConnectionunmountsSyncModal(its normalshowSyncModaltoggle, 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 lintclean.- 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_errorredirect — confirming banners stack, auto-dismiss, and can be closed manually, and thatSyncModalnow closes itself the instant sync finishes rather than waiting for a manual Close click.