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>
2028 lines
55 KiB
Markdown
2028 lines
55 KiB
Markdown
# 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`) 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."
|
||
- `<BannerStack>` is mounted **exactly once**, in `frontend/src/main.tsx`, as a sibling above
|
||
`<LoginGate />`, 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
|
||
`<p className="error">`-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 <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`:
|
||
|
||
```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:
|
||
|
||
```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
|
||
`<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**
|
||
|
||
```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<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:
|
||
|
||
```ts
|
||
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**
|
||
|
||
```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 && <SyncModal onClose={...} />}`).
|
||
|
||
- [ ] **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<string | null>(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 && <p className="garmin-connection-message">{auth.message}</p>}
|
||
|
||
{error && <p className="error">{error}</p>}
|
||
|
||
{showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
{!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**
|
||
|
||
```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<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**
|
||
|
||
```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<ProfileType | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [saved, setSaved] = useState(false);
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
const [profile, setProfile] = useState<ProfileType | null>(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 (
|
||
<div className="page">
|
||
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
} 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:
|
||
|
||
```tsx
|
||
<div className="page profile-page">
|
||
{error && <p className="error">{error}</p>}
|
||
{saved && <p className="garmin-connection-message">Saved.</p>}
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
<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**
|
||
|
||
```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<string>("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
const [filterKindId, setFilterKindId] = useState<string>("");
|
||
const [resolvingId, setResolvingId] = useState<number | null>(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
|
||
<div className="page">
|
||
{error && <p className="error">{error}</p>}
|
||
|
||
{grandTotal > 0 && (
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
<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**
|
||
|
||
```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<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:
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```tsx
|
||
{error && <p className="error">{error}</p>}
|
||
{loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
{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**
|
||
|
||
```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<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:
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```tsx
|
||
<legend>Training types</legend>
|
||
|
||
{error && <p className="error">{error}</p>}
|
||
|
||
<ul className="training-type-list">
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
<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**
|
||
|
||
```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 <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:
|
||
|
||
```tsx
|
||
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:
|
||
|
||
```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 `<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**
|
||
|
||
```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<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:
|
||
|
||
```tsx
|
||
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):
|
||
|
||
```tsx
|
||
{error && <p className="onboarding-wizard-error">{error}</p>}
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
with:
|
||
|
||
```tsx
|
||
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Replace (the "authenticated, finishing setup" branch):
|
||
|
||
```tsx
|
||
<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:
|
||
|
||
```tsx
|
||
<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):
|
||
|
||
```tsx
|
||
{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:
|
||
|
||
```tsx
|
||
{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`):
|
||
|
||
```tsx
|
||
{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:
|
||
|
||
```tsx
|
||
{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**
|
||
|
||
```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).
|