chore: onboarding/MFA-modal WIP, dev-workflow guidance, ideas updates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<BannerStack />
|
||||
<div className="login-gate">
|
||||
<h1>🧞♀️ geniusrun</h1>
|
||||
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
|
||||
Log in
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +98,8 @@ export function OnboardingWizard({ onCreated }: { onCreated: (displayName: strin
|
||||
|
||||
if (step === "name") {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
@@ -116,10 +119,13 @@ export function OnboardingWizard({ onCreated }: { onCreated: (displayName: strin
|
||||
{validationError && <p className="onboarding-wizard-error">{validationError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BannerStack />
|
||||
<div className="onboarding-wizard">
|
||||
<h1>🧞♀️ Connect your Garmin account</h1>
|
||||
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
|
||||
@@ -173,5 +179,6 @@ export function OnboardingWizard({ onCreated }: { onCreated: (displayName: strin
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
frontend/src/components/GarminMFAModal.tsx
Normal file
60
frontend/src/components/GarminMFAModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user