diff --git a/CLAUDE.md b/CLAUDE.md index 35dbd62..5ff6c20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/IDEAS.md b/docs/IDEAS.md index 0062e00..7dce2f8 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index ba97f9d..35699bd 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e7bb67..2f5fb36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }) { +
{showProfile ? setProfileName(p.Name)} /> : }
); diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index 944a6eb..6fb44fa 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -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
Loading…
; + return ( + <> + +
Loading…
+ + ); } if (status === "unauthenticated") { return ( -
-

🧞‍♀️ geniusrun

- - Log in - -
+ <> + +
+

🧞‍♀️ geniusrun

+ + Log in + +
+ ); } diff --git a/frontend/src/OnboardingWizard.tsx b/frontend/src/OnboardingWizard.tsx index c4b74af..9ef571d 100644 --- a/frontend/src/OnboardingWizard.tsx +++ b/frontend/src/OnboardingWizard.tsx @@ -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 ( -
-

🧞‍♀️ Welcome to geniusrun

-

Let's set up your profile. You'll connect your Garmin account next.

-
-
- setDisplayName(e.target.value)} - autoFocus - /> - -
- {validationError &&

{validationError}

} -
-
+ <> + +
+

🧞‍♀️ Welcome to geniusrun

+

Let's set up your profile. You'll connect your Garmin account next.

+
+
+ setDisplayName(e.target.value)} + autoFocus + /> + +
+ {validationError &&

{validationError}

} +
+
+ ); } return ( -
-

🧞‍♀️ Connect your Garmin account

-

geniusrun needs your Garmin credentials to sync your activities.

+ <> + +
+

🧞‍♀️ Connect your Garmin account

+

geniusrun needs your Garmin credentials to sync your activities.

- {auth?.status === "authenticated" ? ( - <> -

Connected to Garmin — finishing setup…

- {completeFailed && ( - + )} + + ) : auth?.status === "mfa_required" ? ( +
+ setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submitMFA()} + disabled={busy} + autoFocus + /> + {auth.message &&

{auth.message}

} + - )} - - ) : auth?.status === "mfa_required" ? ( -
- setCode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitMFA()} - disabled={busy} - autoFocus - /> - {auth.message &&

{auth.message}

} - -
- ) : ( -
- setEmail(e.target.value)} - disabled={busy} - autoFocus - /> - setPassword(e.target.value)} - disabled={busy} - /> - {auth?.message &&

{auth.message}

} - {validationError &&

{validationError}

} - -
- )} -
+
+ ) : ( +
+ setEmail(e.target.value)} + disabled={busy} + autoFocus + /> + setPassword(e.target.value)} + disabled={busy} + /> + {auth?.message &&

{auth.message}

} + {validationError &&

{validationError}

} + +
+ )} +
+ ); } diff --git a/frontend/src/components/GarminConnection.tsx b/frontend/src/components/GarminConnection.tsx index 0ed3eb0..02d68e1 100644 --- a/frontend/src/components/GarminConnection.tsx +++ b/frontend/src/components/GarminConnection.tsx @@ -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 }) { const [auth, setAuth] = useState(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?: () =>
{status !== "authenticated" && status !== "mfa_required" && ( )} @@ -134,20 +137,12 @@ export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () =>
{status === "mfa_required" && ( -
- setCode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitMFA()} - /> - -
+ )} - {!disconnected && auth?.message &&

{auth.message}

} + {!disconnected && auth?.message && status !== "authenticated" && ( +

{auth.message}

+ )} {showSyncModal && setShowSyncModal(false)} />} diff --git a/frontend/src/components/GarminMFAModal.tsx b/frontend/src/components/GarminMFAModal.tsx new file mode 100644 index 0000000..b4d25ff --- /dev/null +++ b/frontend/src/components/GarminMFAModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+ Garmin verification code +
+
+ {message &&

{message}

} + setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSubmit(code)} + disabled={busy} + autoFocus + /> +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 74cd678..8df11e5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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' +// 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( - , ) diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx index 6c3c2f4..9aa847b 100644 --- a/frontend/src/pages/Analysis.tsx +++ b/frontend/src/pages/Analysis.tsx @@ -44,8 +44,7 @@ export function Analysis() {
{kinds.length === 0 ? (

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

) : ( <> diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 7ffcb6e..88f891c 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -89,7 +89,6 @@ const AUTO_SAVE_DELAY_MS = 600; export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { const [profile, setProfile] = useState(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 (
- {saved &&

Saved.

}
Profile