feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing

This commit is contained in:
2026-07-17 19:22:25 +02:00
parent 9cfa3e138e
commit 17ebe3c665
5 changed files with 316 additions and 39 deletions

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import "./App.css"; import "./App.css";
import { GarminConnection } from "./components/GarminConnection"; import { GarminConnection } from "./components/GarminConnection";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue"; import { ReviewQueue } from "./pages/ReviewQueue";
import { WorkoutKinds } from "./pages/WorkoutKinds"; import { WorkoutKinds } from "./pages/WorkoutKinds";
@@ -9,6 +10,7 @@ const TABS = [
{ key: "dashboard", label: "Progression", Component: Dashboard }, { key: "dashboard", label: "Progression", Component: Dashboard },
{ key: "review", label: "Review Queue", Component: ReviewQueue }, { key: "review", label: "Review Queue", Component: ReviewQueue },
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
{ key: "profile", label: "Profile", Component: Profile },
] as const; ] as const;
type TabKey = (typeof TABS)[number]["key"]; type TabKey = (typeof TABS)[number]["key"];

View File

@@ -2,6 +2,7 @@ import type {
Activity, Activity,
AuthResponse, AuthResponse,
KindAssignment, KindAssignment,
Profile,
ProgressionMetric, ProgressionMetric,
ProgressionPoint, ProgressionPoint,
ReviewQueueItem, ReviewQueueItem,
@@ -50,19 +51,31 @@ export const api = {
getActivity: (id: number) => getActivity: (id: number) =>
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`), request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
// Workout kinds // Workout kinds -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) => listWorkoutKinds: (includeInactive = false) =>
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) =>
request<WorkoutKind>("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }),
updateWorkoutKind: ( updateWorkoutKind: (
id: number, 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<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), ) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
deleteWorkoutKind: (id: number) => request<void>(`/api/workout-kinds/${id}`, { method: "DELETE" }),
reclassifyWorkoutKind: (id: number) => reclassifyWorkoutKind: (id: number) =>
request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }),
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Review queue // Review queue
reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"), reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"),
resolveReview: (activityId: number, workoutKindId: number) => resolveReview: (activityId: number, workoutKindId: number) =>

View File

@@ -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 (
<label>
{label}
<input
type="number"
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
function NullableNumberField({
label,
value,
onChange,
}: {
label: string;
value: number | null;
onChange: (v: number | null) => void;
}) {
return (
<label>
{label}
<input
type="number"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
/>
</label>
);
}
export function Profile() {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
}, []);
function set<K extends keyof ProfileType>(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 (
<div className="page">
<h2>Profile</h2>
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
</div>
);
}
return (
<div className="page">
<h2>Profile</h2>
{error && <p className="error">{error}</p>}
{saved && <p className="garmin-connection-message">Saved.</p>}
<fieldset className="kind-editor">
<legend>Garmin account</legend>
<label>
Email
<input
type="text"
value={profile.GarminEmail}
onChange={(e) => set("GarminEmail", e.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={profile.GarminPassword}
onChange={(e) => set("GarminPassword", e.target.value)}
/>
</label>
</fieldset>
<fieldset className="kind-editor">
<legend>Classification</legend>
<NumberField
label="Rolling window (days)"
value={profile.RollingWindowDays}
onChange={(v) => set("RollingWindowDays", v)}
/>
</fieldset>
<fieldset className="kind-editor">
<legend>Heart rate</legend>
<NullableNumberField
label="Max heart rate (bpm)"
value={profile.MaxHeartRate}
onChange={(v) => set("MaxHeartRate", v)}
/>
<NullableNumberField
label="Resting heart rate (bpm)"
value={profile.RestingHeartRate}
onChange={(v) => set("RestingHeartRate", v)}
/>
{([1, 2, 3, 4, 5] as const).map((zone) => (
<div key={zone} className="controls">
<NumberField
label={`Zone ${zone} min %HRR`}
value={profile[`HRZone${zone}MinPct` as keyof ProfileType] as number}
onChange={(v) => set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)}
/>
<NumberField
label={`Zone ${zone} max %HRR`}
value={profile[`HRZone${zone}MaxPct` as keyof ProfileType] as number}
onChange={(v) => set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)}
/>
</div>
))}
</fieldset>
<fieldset className="kind-editor">
<legend>Phase detection (warm-up / cool-down minutes)</legend>
{(
[
["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]) => (
<div key={label} className="controls">
<NumberField
label={`${label} warm-up`}
value={profile[warmupKey]}
onChange={(v) => set(warmupKey, v)}
/>
<NumberField
label={`${label} cool-down`}
value={profile[cooldownKey]}
onChange={(v) => set(cooldownKey, v)}
/>
</div>
))}
<p className="empty-state">
Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings.
</p>
</fieldset>
<button onClick={save}>Save</button>
</div>
);
}

View File

@@ -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() { export function WorkoutKinds() {
const [kinds, setKinds] = useState<WorkoutKind[]>([]); const [kinds, setKinds] = useState<WorkoutKind[]>([]);
const [editingId, setEditingId] = useState<number | "new" | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [ruleText, setRuleText] = useState(EXAMPLE_RULE); const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
const [paceMinText, setPaceMinText] = useState("");
const [paceMaxText, setPaceMaxText] = useState("");
const [expectedZone, setExpectedZone] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
@@ -25,23 +44,20 @@ export function WorkoutKinds() {
useEffect(reload, []); useEffect(reload, []);
function startCreate() {
setEditingId("new");
setName("");
setDescription("");
setRuleText(EXAMPLE_RULE);
setError(null);
}
function startEdit(k: WorkoutKind) { function startEdit(k: WorkoutKind) {
setEditingId(k.ID); setEditingId(k.ID);
setName(k.Name); setName(k.Name);
setDescription(k.Description); setDescription(k.Description);
setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2)); 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); setError(null);
} }
async function save() { async function save() {
if (editingId === null) return;
let rule: unknown; let rule: unknown;
try { try {
rule = JSON.parse(ruleText); rule = JSON.parse(ruleText);
@@ -50,12 +66,26 @@ export function WorkoutKinds() {
return; 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 { try {
if (editingId === "new") { await api.updateWorkoutKind(editingId, {
await api.createWorkoutKind({ name, description, rule }); name,
} else if (typeof editingId === "number") { description,
await api.updateWorkoutKind(editingId, { name, description, rule }); rule,
} pace_min_sec_per_km: paceMin,
pace_max_sec_per_km: paceMax,
expected_hr_zone: expectedZone,
});
setEditingId(null); setEditingId(null);
reload(); reload();
} catch (e) { } 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) { async function reclassify(id: number) {
setBusyId(id); setBusyId(id);
try { try {
@@ -97,7 +115,8 @@ export function WorkoutKinds() {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Description</th> <th>Description</th>
<th>Active</th> <th>Pace range</th>
<th>HR zone</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -106,24 +125,24 @@ export function WorkoutKinds() {
<tr key={k.ID}> <tr key={k.ID}>
<td>{k.Name}</td> <td>{k.Name}</td>
<td>{k.Description}</td> <td>{k.Description}</td>
<td>{k.IsActive ? "yes" : "no"}</td> <td>
{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`
: "—"}
</td>
<td>{k.expected_hr_zone ?? "—"}</td>
<td className="kinds-table-actions"> <td className="kinds-table-actions">
<button onClick={() => startEdit(k)}>Edit</button> <button onClick={() => startEdit(k)}>Edit</button>
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}> <button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
Reclassify Reclassify
</button> </button>
<button disabled={busyId === k.ID} onClick={() => remove(k.ID)}>
Delete
</button>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{editingId === null ? ( {editingId !== null && (
<button onClick={startCreate}>+ New workout kind</button>
) : (
<div className="kind-editor"> <div className="kind-editor">
<label> <label>
Name Name
@@ -133,6 +152,30 @@ export function WorkoutKinds() {
Description Description
<input value={description} onChange={(e) => setDescription(e.target.value)} /> <input value={description} onChange={(e) => setDescription(e.target.value)} />
</label> </label>
<div className="controls">
<label>
Min pace (m:ss/km)
<input value={paceMinText} onChange={(e) => setPaceMinText(e.target.value)} placeholder="4:30" />
</label>
<label>
Max pace (m:ss/km)
<input value={paceMaxText} onChange={(e) => setPaceMaxText(e.target.value)} placeholder="4:45" />
</label>
<label>
Expected HR zone
<select
value={expectedZone ?? ""}
onChange={(e) => setExpectedZone(e.target.value === "" ? null : Number(e.target.value))}
>
<option value=""></option>
{[1, 2, 3, 4, 5].map((z) => (
<option key={z} value={z}>
Zone {z}
</option>
))}
</select>
</label>
</div>
<label> <label>
Rule (JSON condition tree) Rule (JSON condition tree)
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} /> <textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />

View File

@@ -57,6 +57,43 @@ export interface WorkoutKind {
IsActive: boolean; IsActive: boolean;
CreatedAt: string; CreatedAt: string;
UpdatedAt: string; UpdatedAt: string;
pace_min_sec_per_km: number | null;
pace_max_sec_per_km: number | null;
expected_hr_zone: number | null;
}
export interface Profile {
GarminEmail: string;
GarminPassword: string;
RollingWindowDays: number;
MaxHeartRate: number | null;
RestingHeartRate: number | null;
HRZone1MinPct: number;
HRZone1MaxPct: number;
HRZone2MinPct: number;
HRZone2MaxPct: number;
HRZone3MinPct: number;
HRZone3MaxPct: number;
HRZone4MinPct: number;
HRZone4MaxPct: number;
HRZone5MinPct: number;
HRZone5MaxPct: number;
EasyWarmupMinutes: number;
EasyCooldownMinutes: number;
LongWarmupMinutes: number;
LongCooldownMinutes: number;
TempoWarmupMinutes: number;
TempoCooldownMinutes: number;
Threshold30WarmupMinutes: number;
Threshold30CooldownMinutes: number;
Threshold60WarmupMinutes: number;
Threshold60CooldownMinutes: number;
MASTestWarmupMinutes: number;
MASTestCooldownMinutes: number;
IntervalWarmupMinutes: number;
IntervalCooldownMinutes: number;
CreatedAt: string;
UpdatedAt: string;
} }
export interface ScoredKind { export interface ScoredKind {