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).
|