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
+ )}
{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 (
+
+ );
+}
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 (