diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd907ba..5467ca8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import "./App.css"; import { GarminConnection } from "./components/GarminConnection"; import { Dashboard } from "./pages/Dashboard"; +import { Profile } from "./pages/Profile"; import { ReviewQueue } from "./pages/ReviewQueue"; import { WorkoutKinds } from "./pages/WorkoutKinds"; @@ -9,6 +10,7 @@ const TABS = [ { key: "dashboard", label: "Progression", Component: Dashboard }, { key: "review", label: "Review Queue", Component: ReviewQueue }, { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, + { key: "profile", label: "Profile", Component: Profile }, ] as const; type TabKey = (typeof TABS)[number]["key"]; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9cc26f3..12d5a1f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,7 @@ import type { Activity, AuthResponse, KindAssignment, + Profile, ProgressionMetric, ProgressionPoint, ReviewQueueItem, @@ -50,19 +51,31 @@ export const api = { getActivity: (id: number) => request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`), - // Workout kinds + // Workout kinds -- fixed taxonomy, no create/delete listWorkoutKinds: (includeInactive = false) => request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), - createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) => - request("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }), updateWorkoutKind: ( id: number, - body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean }, + body: { + name: string; + description?: string; + color?: string; + rule: unknown; + priority?: number; + is_active?: boolean; + pace_min_sec_per_km?: number | null; + pace_max_sec_per_km?: number | null; + expected_hr_zone?: number | null; + }, ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), - deleteWorkoutKind: (id: number) => request(`/api/workout-kinds/${id}`, { method: "DELETE" }), reclassifyWorkoutKind: (id: number) => request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), + // Profile + getProfile: () => request("/api/profile"), + updateProfile: (profile: Profile) => + request("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), + // Review queue reviewQueue: () => request("/api/review-queue/"), resolveReview: (activityId: number, workoutKindId: number) => diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..ff6cb29 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { Profile as ProfileType } from "../types/api"; + +function NumberField({ + label, + value, + onChange, + step = 1, +}: { + label: string; + value: number; + onChange: (v: number) => void; + step?: number; +}) { + return ( + + ); +} + +function NullableNumberField({ + label, + value, + onChange, +}: { + label: string; + value: number | null; + onChange: (v: number | null) => void; +}) { + return ( + + ); +} + +export function Profile() { + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + api.getProfile().then(setProfile).catch((e) => setError(String(e))); + }, []); + + function set(key: K, value: ProfileType[K]) { + setProfile((p) => (p ? { ...p, [key]: value } : p)); + setSaved(false); + } + + async function save() { + if (!profile) return; + setError(null); + try { + const updated = await api.updateProfile(profile); + setProfile(updated); + setSaved(true); + } catch (e) { + setError(String(e)); + setSaved(false); + } + } + + if (!profile) { + return ( +
+

Profile

+ {error ?

{error}

:

Loading...

} +
+ ); + } + + return ( +
+

Profile

+ {error &&

{error}

} + {saved &&

Saved.

} + +
+ Garmin account + + +
+ +
+ Classification + set("RollingWindowDays", v)} + /> +
+ +
+ Heart rate + set("MaxHeartRate", v)} + /> + set("RestingHeartRate", v)} + /> + {([1, 2, 3, 4, 5] as const).map((zone) => ( +
+ set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)} + /> + set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)} + /> +
+ ))} +
+ +
+ Phase detection (warm-up / cool-down minutes) + {( + [ + ["Easy", "EasyWarmupMinutes", "EasyCooldownMinutes"], + ["Long", "LongWarmupMinutes", "LongCooldownMinutes"], + ["Tempo", "TempoWarmupMinutes", "TempoCooldownMinutes"], + ["Threshold 30'", "Threshold30WarmupMinutes", "Threshold30CooldownMinutes"], + ["Threshold 60'", "Threshold60WarmupMinutes", "Threshold60CooldownMinutes"], + ["MAS Test", "MASTestWarmupMinutes", "MASTestCooldownMinutes"], + ] as const + ).map(([label, warmupKey, cooldownKey]) => ( +
+ set(warmupKey, v)} + /> + set(cooldownKey, v)} + /> +
+ ))} +

+ Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings. +

+
+ + +
+ ); +} diff --git a/frontend/src/pages/WorkoutKinds.tsx b/frontend/src/pages/WorkoutKinds.tsx index c133d1b..dffbc1a 100644 --- a/frontend/src/pages/WorkoutKinds.tsx +++ b/frontend/src/pages/WorkoutKinds.tsx @@ -10,12 +10,31 @@ const EXAMPLE_RULE = `{ ] }`; +// Pace is entered/displayed as "m:ss" but sent to the API as whole seconds. +function parsePace(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + const match = /^(\d+):([0-5]?\d)$/.exec(trimmed); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +function formatPace(seconds: number | null): string { + if (seconds == null) return ""; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + export function WorkoutKinds() { const [kinds, setKinds] = useState([]); - const [editingId, setEditingId] = useState(null); + const [editingId, setEditingId] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [ruleText, setRuleText] = useState(EXAMPLE_RULE); + const [paceMinText, setPaceMinText] = useState(""); + const [paceMaxText, setPaceMaxText] = useState(""); + const [expectedZone, setExpectedZone] = useState(null); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); @@ -25,23 +44,20 @@ export function WorkoutKinds() { useEffect(reload, []); - function startCreate() { - setEditingId("new"); - setName(""); - setDescription(""); - setRuleText(EXAMPLE_RULE); - setError(null); - } - function startEdit(k: WorkoutKind) { setEditingId(k.ID); setName(k.Name); setDescription(k.Description); setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2)); + setPaceMinText(formatPace(k.pace_min_sec_per_km)); + setPaceMaxText(formatPace(k.pace_max_sec_per_km)); + setExpectedZone(k.expected_hr_zone); setError(null); } async function save() { + if (editingId === null) return; + let rule: unknown; try { rule = JSON.parse(ruleText); @@ -50,12 +66,26 @@ export function WorkoutKinds() { return; } + const paceMin = parsePace(paceMinText); + const paceMax = parsePace(paceMaxText); + if (paceMinText.trim() !== "" && paceMin === null) { + setError("Min pace must look like m:ss, e.g. 4:30"); + return; + } + if (paceMaxText.trim() !== "" && paceMax === null) { + setError("Max pace must look like m:ss, e.g. 4:30"); + return; + } + try { - if (editingId === "new") { - await api.createWorkoutKind({ name, description, rule }); - } else if (typeof editingId === "number") { - await api.updateWorkoutKind(editingId, { name, description, rule }); - } + await api.updateWorkoutKind(editingId, { + name, + description, + rule, + pace_min_sec_per_km: paceMin, + pace_max_sec_per_km: paceMax, + expected_hr_zone: expectedZone, + }); setEditingId(null); reload(); } catch (e) { @@ -63,18 +93,6 @@ export function WorkoutKinds() { } } - async function remove(id: number) { - setBusyId(id); - try { - await api.deleteWorkoutKind(id); - reload(); - } catch (e) { - setError(String(e)); - } finally { - setBusyId(null); - } - } - async function reclassify(id: number) { setBusyId(id); try { @@ -97,7 +115,8 @@ export function WorkoutKinds() { Name Description - Active + Pace range + HR zone @@ -106,24 +125,24 @@ export function WorkoutKinds() { {k.Name} {k.Description} - {k.IsActive ? "yes" : "no"} + + {k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null + ? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km` + : "—"} + + {k.expected_hr_zone ?? "—"} - ))} - {editingId === null ? ( - - ) : ( + {editingId !== null && (
+
+ + + +