chore: onboarding/MFA-modal WIP, dev-workflow guidance, ideas updates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:04:03 +02:00
parent 27af77418e
commit 8508e89ad7
11 changed files with 205 additions and 105 deletions

View File

@@ -21,6 +21,7 @@ Backend (from `backend/`):
- Single test: `go test ./internal/store/... -run TestProfile -v`
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
- **This app is still under active development, not yet in production.** A breaking schema change (new non-nullable column, renamed/removed column, changed constraint, etc.) is expected to require deleting the existing local DB file and letting `Open()` recreate it fresh from the current `schema.sql` -- this is the normal, accepted remedy during this phase, not a workaround to avoid. Do not add `ALTER TABLE` migration logic, backfill scripts, or any other backward-compatibility shim for an existing DB file to accommodate a schema change. Real migration tooling (Flyway) is planned once the first version ships to production; until then, every schema change is free to be breaking.
Frontend (from `frontend/`):
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
@@ -116,6 +117,7 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
- **Verify UI/frontend changes with a live click-through against the actual running dev servers (real backend + `npm run dev`), never by mocking network requests (e.g. Playwright route interception) to fake a logged-in session.** This repo's OIDC login gate makes a fully-mocked session tempting, but it's fragile in exactly the way that matters: a real backend is often already running (e.g. started from an IDE) on the same port a mocked test assumes is free, so any request the mock doesn't cover falls through to that real, live backend instead of erroring cleanly -- discovered when an incomplete route-mock's unmocked calls 401'd against a real GoLand-launched `geniusrund` and triggered `client.ts`'s 401-redirect-reload loop. If a live click-through isn't possible in the current environment (no real credentials, no browser tool available), say so explicitly rather than substituting a mocked simulation.
## Testing conventions

View File

@@ -8,7 +8,7 @@ for that history) and remove it from here once a spec exists.
## Backlog
- new workout kinds
- add "Recovery", "Quick", and "Sprint" workout types
- add "Recovery", "Quick", and "Sprint" workout kinds
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
- adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
- first time setup page

View File

@@ -211,6 +211,11 @@ input[type="color"] {
cursor: pointer;
}
.field-hint {
font-size: 0.75rem;
color: #6b7280;
}
button:hover:not(:disabled) {
border-color: #3b82f6;
}
@@ -526,6 +531,29 @@ button:disabled {
border-top: 1px solid #2a2d35;
}
.garmin-mfa-modal-content {
width: min(420px, 90vw);
}
.garmin-mfa-modal-body {
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.garmin-mfa-modal-message {
margin: 0;
color: #9aa0ab;
font-size: 0.9rem;
}
.garmin-mfa-modal-actions {
justify-content: flex-end;
padding: 0.75rem 1rem;
border-top: 1px solid #2a2d35;
}
.json-toggle {
display: inline-block;
width: 1rem;

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./App.css";
import { BannerStack } from "./components/BannerStack";
import { Activities } from "./pages/Activities";
import { Analysis } from "./pages/Analysis";
import { Plan } from "./pages/Plan";
@@ -63,6 +64,7 @@ function App({ session }: { session: SessionInfo }) {
</form>
</div>
</header>
<BannerStack />
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api, BASE_URL, NetworkError } from "./api/client";
import { showError } from "./banner";
import { BannerStack } from "./components/BannerStack";
import "./LoginGate.css";
import App from "./App";
import { OnboardingWizard } from "./OnboardingWizard";
@@ -58,17 +59,25 @@ export function LoginGate() {
}, [status]);
if (status === "loading") {
return <div className="login-gate-loading">Loading</div>;
return (
<>
<BannerStack />
<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>
<>
<BannerStack />
<div className="login-gate">
<h1>🧞 geniusrun</h1>
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in
</a>
</div>
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { api } from "./api/client";
import { showError } from "./banner";
import { BannerStack } from "./components/BannerStack";
import "./OnboardingWizard.css";
import type { AuthResponse } from "./types/api";
@@ -97,81 +98,87 @@ export function OnboardingWizard({ onCreated }: { onCreated: (displayName: strin
if (step === "name") {
return (
<div className="onboarding-wizard">
<h1>🧞 Welcome to geniusrun</h1>
<p>Let's set up your profile. You'll connect your Garmin account next.</p>
<form onSubmit={submitName}>
<div className="onboarding-wizard-name-row">
<input
type="text"
placeholder="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoFocus
/>
<button type="submit" className="onboarding-wizard-icon-button" title="Continue" aria-label="Continue">
</button>
</div>
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
</form>
</div>
<>
<BannerStack />
<div className="onboarding-wizard">
<h1>🧞 Welcome to geniusrun</h1>
<p>Let's set up your profile. You'll connect your Garmin account next.</p>
<form onSubmit={submitName}>
<div className="onboarding-wizard-name-row">
<input
type="text"
placeholder="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoFocus
/>
<button type="submit" className="onboarding-wizard-icon-button" title="Continue" aria-label="Continue">
</button>
</div>
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
</form>
</div>
</>
);
}
return (
<div className="onboarding-wizard">
<h1>🧞 Connect your Garmin account</h1>
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
<>
<BannerStack />
<div className="onboarding-wizard">
<h1>🧞 Connect your Garmin account</h1>
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
{auth?.status === "authenticated" ? (
<>
<p className="onboarding-wizard-message">Connected to Garmin finishing setup</p>
{completeFailed && (
<button type="button" disabled={busy} onClick={complete}>
{busy ? "Finishing…" : "Retry"}
{auth?.status === "authenticated" ? (
<>
<p className="onboarding-wizard-message">Connected to Garmin finishing setup</p>
{completeFailed && (
<button type="button" disabled={busy} onClick={complete}>
{busy ? "Finishing…" : "Retry"}
</button>
)}
</>
) : auth?.status === "mfa_required" ? (
<div className="onboarding-wizard-mfa">
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
disabled={busy}
autoFocus
/>
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
)}
</>
) : auth?.status === "mfa_required" ? (
<div className="onboarding-wizard-mfa">
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
disabled={busy}
autoFocus
/>
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
) : (
<form onSubmit={submitGarmin}>
<input
type="text"
placeholder="Garmin email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
autoFocus
/>
<input
type="password"
placeholder="Garmin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
/>
{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>
)}
</div>
</div>
) : (
<form onSubmit={submitGarmin}>
<input
type="text"
placeholder="Garmin email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
autoFocus
/>
<input
type="password"
placeholder="Garmin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
/>
{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>
)}
</div>
</>
);
}

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { showError } from "../banner";
import { showError, showSuccess } from "../banner";
import type { AuthResponse } from "../types/api";
import { GarminMFAModal } from "./GarminMFAModal";
import { SyncModal } from "./SyncModal";
export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () => Promise<void> }) {
const [auth, setAuth] = useState<AuthResponse | null>(null);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
// There's no real Garmin logout -- the backend session just sits idle.
// "Disconnect" only hides that and shows Connect again locally; a real
@@ -29,8 +29,10 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
setBusy(true);
try {
await onBeforeConnect?.();
setAuth(await api.login());
const result = await api.login();
setAuth(result);
setDisconnected(false);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
} catch (e) {
showError(String(e));
} finally {
@@ -42,12 +44,13 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
setDisconnected(true);
}
async function submitMFA() {
async function submitMFA(code: string) {
if (!code.trim()) return;
setBusy(true);
try {
setAuth(await api.submitMFA(code.trim()));
setCode("");
const result = await api.submitMFA(code.trim());
setAuth(result);
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
} catch (e) {
showError(String(e));
} finally {
@@ -97,7 +100,7 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
<div className="garmin-connection-actions">
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
Connect
</button>
)}
@@ -134,20 +137,12 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
</div>
{status === "mfa_required" && (
<div className="garmin-connection-row">
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
/>
<button disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
<GarminMFAModal message={auth?.message} busy={busy} onSubmit={submitMFA} onCancel={disconnect} />
)}
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{!disconnected && auth?.message && status !== "authenticated" && (
<p className="garmin-connection-message">{auth.message}</p>
)}
{showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}
</div>

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
// Shown in place of the old inline MFA row whenever GarminConnection reports
// mfa_required -- MFA is a required step to finish authenticating, not a
// minor detail, so it gets the same focused-dialog treatment as SyncModal/
// RawDataModal rather than a cramped row squeezed between the connect
// buttons. Cancelling (Escape, backdrop click, or the Cancel button) calls
// onCancel, which GarminConnection wires to its existing local-only
// disconnect() -- there's no real way to abort a pending Garmin MFA
// challenge server-side, so this only hides the prompt and lets the user
// start over with a fresh "Connect" click, same as "Disconnect" already
// does for an established session.
export function GarminMFAModal({
message,
busy,
onSubmit,
onCancel,
}: {
message?: string;
busy: boolean;
onSubmit: (code: string) => void;
onCancel: () => void;
}) {
const [code, setCode] = useState("");
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onCancel]);
return (
<div className="modal-backdrop" onClick={onCancel}>
<div className="modal-content garmin-mfa-modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<span>Garmin verification code</span>
</div>
<div className="garmin-mfa-modal-body">
{message && <p className="garmin-mfa-modal-message">{message}</p>}
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSubmit(code)}
disabled={busy}
autoFocus
/>
</div>
<div className="modal-header-actions garmin-mfa-modal-actions">
<button type="button" disabled={busy} onClick={onCancel}>
Cancel
</button>
<button type="button" disabled={busy || !code.trim()} onClick={() => onSubmit(code)}>
{busy ? "Submitting…" : "Submit code"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BannerStack } from './components/BannerStack.tsx'
import { LoginGate } from './LoginGate.tsx'
// <BannerStack /> is rendered by each top-level screen individually (App.tsx,
// LoginGate.tsx, OnboardingWizard.tsx), right after that screen's own
// header/heading -- not mounted here above everything -- so a banner
// appearing/disappearing never shifts App's header+tabs.
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BannerStack />
<LoginGate />
</StrictMode>,
)

View File

@@ -44,8 +44,7 @@ export function Analysis() {
<div className="page">
{kinds.length === 0 ? (
<p className="empty-state">
No training types defined yet. See the Training types card on the Profile page to start seeing progression
here.
No activities classified per training types defined yet.
</p>
) : (
<>

View File

@@ -89,7 +89,6 @@ const AUTO_SAVE_DELAY_MS = 600;
export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [profileLoadFailed, setProfileLoadFailed] = useState(false);
const [saved, setSaved] = useState(false);
// Mirrors `profile` synchronously (state updates don't apply until the
// next render), so set() always debounces from the latest edit rather
// than a stale snapshot from this render's closure.
@@ -125,7 +124,6 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const updated = await api.updateProfile(next);
profileRef.current = updated;
setProfile(updated);
setSaved(true);
onSaved?.(updated);
} catch (e) {
showError(String(e));
@@ -137,7 +135,6 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const next = { ...profileRef.current, [key]: value };
profileRef.current = next;
setProfile(next);
setSaved(false);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(() => persist(next), AUTO_SAVE_DELAY_MS);
}
@@ -183,7 +180,6 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
return (
<div className="page profile-page">
{saved && <p className="garmin-connection-message">Saved.</p>}
<fieldset className="kind-editor">
<legend>Profile</legend>