etc.) throws a friendly, fixed message instead of the raw browser error
text:
```ts
let res: Response;
try {
res = await fetch(`${BASE_URL}${path}`, { ... });
} catch {
throw new Error("Can't reach the server — check your connection.");
}
```
A real HTTP response that just happens to be non-2xx (4xx/5xx) is
unaffected — that branch keeps its existing `${status} ${body}` message,
since that's a legitimate API-level error, not a "the backend isn't there"
condition.
This means every call site's existing pattern —
`.catch((e) => setError(String(e)))` — becomes
`.catch((e) => showError(String(e)))`, and the friendly message for a truly
unreachable backend falls out for free, with no per-call-site
network-error special-casing needed. This is what satisfies IDEAS.md's
"used on all pages/tabs in case of backend is not here": every page's
existing catch-and-display path already covers this, once it displays via
`showError` instead of local state.
## Migration inventory
Every current inline-error site, and what changes:
| File | Before | After |
|---|---|---|
| `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error && <p className="error">{error}</p>}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. |
| `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. |
| `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. |
| `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. |
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |