11 tasks: the shared banner store + renderer, a friendlier network-unreachable message in client.ts, then migrating every existing inline error site (GarminConnection, SyncModal, Profile, Activities, Analysis, TrainingTypesCard, LoginGate, OnboardingWizard) to it, finishing with dead-CSS cleanup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
55 KiB
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 <BannerStack> component (mounted once, in
main.tsx, above <LoginGate />) 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) andnpm 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).showWarningandshowNoticehave 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." <BannerStack>is mounted exactly once, infrontend/src/main.tsx, as a sibling above<LoginGate />, rendered in normal document flow (notposition: fixed) -- never mounted a second time insideApp.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
useStatefor anerror/local-message string that exists solely to render an inline failure paragraph, and zero remaining<p className="error">-style inline error paragraphs -- exceptOnboardingWizard.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 --
handleSessionLogouthas 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 fromfrontend/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:
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:
.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:
import { useSyncExternalStore } from "react";
import "./BannerStack.css";
import { dismiss, getSnapshot, subscribe } from "../banner";
// Mounted exactly once, in main.tsx, above <LoginGate /> -- 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 (
<div className="banner-stack">
{banners.map((b) => (
<div key={b.id} className={`banner banner-${b.severity}`}>
<span className="banner-message">{b.message}</span>
<button type="button" className="banner-close" aria-label="Dismiss" onClick={() => dismiss(b.id)}>
×
</button>
</div>
))}
</div>
);
}
- Step 4: Mount it in
main.tsx
Replace the full contents of frontend/src/main.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(
<StrictMode>
<BannerStack />
<LoginGate />
</StrictMode>,
)
- 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:
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
<BannerStack>, 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
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 theErrorthrown whenfetch()itself fails (network down, connection refused, offline), which every later task'sshowError(String(e))call site picks up automatically. -
Step 1: Wrap the
fetch()call
In frontend/src/api/client.ts, replace:
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { "Content-Type": "application/json" },
credentials: "include",
...init,
});
if (res.status === 401 && path !== "/api/session/me") {
with:
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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
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 && <SyncModal onClose={...} />}). -
Step 1: Add the import
In frontend/src/components/GarminConnection.tsx, replace:
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { AuthResponse } from "../types/api";
import { SyncModal } from "./SyncModal";
with:
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
errorstate
Replace:
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// There's no real Garmin logout -- the backend session just sits idle.
with:
const [busy, setBusy] = useState(false);
// There's no real Garmin logout -- the backend session just sits idle.
- Step 3: Swap every
setErrorcall site forshowError, remove the now-unneeded resets
Replace the mount effect:
useEffect(() => {
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
const interval = setInterval(() => {
with:
useEffect(() => {
api.authStatus().then(setAuth).catch((e) => showError(String(e)));
const interval = setInterval(() => {
Replace connect():
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:
async function connect() {
setBusy(true);
try {
await onBeforeConnect?.();
setAuth(await api.login());
setDisconnected(false);
} catch (e) {
showError(String(e));
} finally {
setBusy(false);
}
}
Replace submitMFA():
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:
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():
async function sync() {
setBusy(true);
setError(null);
try {
await api.syncRun();
setShowSyncModal(true);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
with:
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:
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:
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:
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{error && <p className="error">{error}</p>}
{showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}
with:
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{showSyncModal && <SyncModal onClose={() => 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
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 (callingonClose) the instant a poll reportsin_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:
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<SyncStatus | null>(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<typeof setTimeout>;
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 (
<div className="modal-backdrop">
<div className="modal-content sync-modal-content">
<div className="modal-header">
<span>Synchronizing</span>
</div>
<div className="sync-modal-body">
{bar ? (
<>
<progress className="sync-modal-progress" value={bar.done} max={Math.max(bar.total, 1)} />
<p className="sync-modal-label">{label}</p>
</>
) : (
<>
<div className="sync-modal-spinner" />
<p className="sync-modal-label">{label}</p>
</>
)}
</div>
<div className="modal-header-actions sync-modal-actions">
<button type="button" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}
- 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
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:
import { useEffect, useRef, useState } from "react";
import { api, BASE_URL } from "../api/client";
import { ColorField } from "../components/ColorField";
with:
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
errorstate with aprofileLoadFailedboolean
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:
const [profile, setProfile] = useState<ProfileType | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
with:
const [profile, setProfile] = useState<ProfileType | null>(null);
const [profileLoadFailed, setProfileLoadFailed] = useState(false);
const [saved, setSaved] = useState(false);
- Step 3: Update the mount effect
Replace:
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
}, []);
with:
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => {
showError(String(e));
setProfileLoadFailed(true);
});
}, []);
- Step 4: Update
persist()
Replace:
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:
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:
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
with:
async function deleteAccount() {
setDeleting(true);
try {
Replace:
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
if (!profile) {
return (
<div className="page">
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
</div>
);
}
with:
} catch (e) {
showError(String(e));
setDeleting(false);
}
}
if (!profile) {
return (
<div className="page">
{profileLoadFailed ? <p className="empty-state">Couldn't load profile.</p> : <p>Loading...</p>}
</div>
);
}
- Step 6: Remove the inline error paragraph in the main render
Replace:
<div className="page profile-page">
{error && <p className="error">{error}</p>}
{saved && <p className="garmin-connection-message">Saved.</p>}
with:
<div className="page profile-page">
{saved && <p className="garmin-connection-message">Saved.</p>}
- 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
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:
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
with:
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
errorstate
Replace:
const [filterKindId, setFilterKindId] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [resolvingId, setResolvingId] = useState<number | null>(null);
with:
const [filterKindId, setFilterKindId] = useState<string>("");
const [resolvingId, setResolvingId] = useState<number | null>(null);
- Step 3: Update
loadMore's catch
Replace:
} catch (e) {
if (gen === generationRef.current) setError(String(e));
} finally {
setLoadingMore(false);
with:
} catch (e) {
if (gen === generationRef.current) showError(String(e));
} finally {
setLoadingMore(false);
- Step 4: Update
loadFirstPage's catch
Replace:
.catch((e) => {
if (gen === generationRef.current) setError(String(e));
})
.finally(() => {
if (gen === generationRef.current) setInitialLoading(false);
});
with:
.catch((e) => {
if (gen === generationRef.current) showError(String(e));
})
.finally(() => {
if (gen === generationRef.current) setInitialLoading(false);
});
- Step 5: Update the mount effect
Replace:
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:
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):
} catch (e) {
setError(String(e));
} finally {
setResolvingId(null);
}
}
async function unassign(activityId: number) {
with:
} catch (e) {
showError(String(e));
} finally {
setResolvingId(null);
}
}
async function unassign(activityId: number) {
Replace (in unassign):
} catch (e) {
setError(String(e));
} finally {
setResolvingId(null);
}
}
async function unlock(activityId: number) {
with:
} catch (e) {
showError(String(e));
} finally {
setResolvingId(null);
}
}
async function unlock(activityId: number) {
Replace (in unlock):
} catch (e) {
setError(String(e));
} finally {
setResolvingId(null);
}
}
function toggleFilter(id: string) {
with:
} catch (e) {
showError(String(e));
} finally {
setResolvingId(null);
}
}
function toggleFilter(id: string) {
- Step 7: Remove the inline error paragraph
Replace:
<div className="page">
{error && <p className="error">{error}</p>}
{grandTotal > 0 && (
with:
<div className="page">
{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
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:
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { ProgressionChart } from "../components/charts/ProgressionChart";
with:
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
errorstate and update both effects
Replace:
const [points, setPoints] = useState<ProgressionPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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:
const [points, setPoints] = useState<ProgressionPoint[]>([]);
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:
{error && <p className="error">{error}</p>}
{loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
with:
{loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
- 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
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:
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { NullableNumberField } from "./NullableNumberField";
with:
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { showError } from "../banner";
import { NullableNumberField } from "./NullableNumberField";
- Step 2: Remove the local
errorstate and its resets
Replace:
const [hrMax, setHrMax] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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:
const [hrMax, setHrMax] = useState<number | null>(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:
<legend>Training types</legend>
{error && <p className="error">{error}</p>}
<ul className="training-type-list">
with:
<legend>Training types</legend>
<ul className="training-type-list">
- 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
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_errorhandling into an effect
In frontend/src/LoginGate.tsx, replace:
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
with:
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import { showError } from "./banner";
import "./LoginGate.css";
import App from "./App";
Replace:
useEffect(() => {
api
.getSessionInfo()
.then((s) => {
setSession(s);
setStatus("authenticated");
})
.catch(() => setStatus("unauthenticated"));
}, []);
if (status === "loading") {
return <div className="login-gate-loading">Loading…</div>;
}
if (status === "unauthenticated") {
const authError = new URLSearchParams(window.location.search).get("auth_error");
return (
<div className="login-gate">
<h1>🧞♀️ geniusrun</h1>
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>
);
}
with:
useEffect(() => {
api
.getSessionInfo()
.then((s) => {
setSession(s);
setStatus("authenticated");
})
.catch(() => setStatus("unauthenticated"));
}, []);
// Keycloak redirects back here with ?auth_error=<code> 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 <div className="login-gate-loading">Loading…</div>;
}
if (status === "unauthenticated") {
return (
<div className="login-gate">
<h1>🧞♀️ geniusrun</h1>
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>
);
}
- Step 2: Remove the now-dead CSS rule
In frontend/src/LoginGate.css, remove:
.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 <frontend-url>/?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
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.cssis NOT modified in this task --.onboarding-wizard-errorstays in active use (see Step 4).
Interfaces:
-
Consumes:
showError(Task 1). -
Produces: nothing new.
onCreatedprop 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:
import { useEffect, useRef, useState } from "react";
import { api } from "./api/client";
import "./OnboardingWizard.css";
import type { AuthResponse } from "./types/api";
with:
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:
const [auth, setAuth] = useState<AuthResponse | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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:
const [auth, setAuth] = useState<AuthResponse | null>(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<string | null>(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):
{error && <p className="onboarding-wizard-error">{error}</p>}
</form>
</div>
);
}
with:
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
</form>
</div>
);
}
Replace (the "authenticated, finishing setup" branch):
<p className="onboarding-wizard-message">Connected to Garmin — finishing setup…</p>
{error && (
<>
<p className="onboarding-wizard-error">{error}</p>
<button type="button" disabled={busy} onClick={complete}>
{busy ? "Finishing…" : "Retry"}
</button>
</>
)}
with:
<p className="onboarding-wizard-message">Connected to Garmin — finishing setup…</p>
{completeFailed && (
<button type="button" disabled={busy} onClick={complete}>
{busy ? "Finishing…" : "Retry"}
</button>
)}
Replace (the "mfa_required" branch):
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
{error && <p className="onboarding-wizard-error">{error}</p>}
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
) : (
with:
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
) : (
Replace (the Garmin-login form branch, the last remaining use of error):
{auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
{error && <p className="onboarding-wizard-error">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Connecting…" : "Login"}
</button>
</form>
)}
with:
{auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
<button type="submit" disabled={busy}>
{busy ? "Connecting…" : "Login"}
</button>
</form>
)}
- 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
<p className="onboarding-wizard-error"> sites, just no longer for real-failure messages.
- Step 5: Commit
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
.erroranymore
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:
.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=failedwhile 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
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).