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 { 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"];

View File

@@ -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<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: (
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) }),
deleteWorkoutKind: (id: number) => request<void>(`/api/workout-kinds/${id}`, { method: "DELETE" }),
reclassifyWorkoutKind: (id: number) =>
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
reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"),
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() {
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 [description, setDescription] = useState("");
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 [busyId, setBusyId] = useState<number | null>(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;
}
try {
if (editingId === "new") {
await api.createWorkoutKind({ name, description, rule });
} else if (typeof editingId === "number") {
await api.updateWorkoutKind(editingId, { name, description, rule });
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 {
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() {
<tr>
<th>Name</th>
<th>Description</th>
<th>Active</th>
<th>Pace range</th>
<th>HR zone</th>
<th></th>
</tr>
</thead>
@@ -106,24 +125,24 @@ export function WorkoutKinds() {
<tr key={k.ID}>
<td>{k.Name}</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">
<button onClick={() => startEdit(k)}>Edit</button>
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
Reclassify
</button>
<button disabled={busyId === k.ID} onClick={() => remove(k.ID)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{editingId === null ? (
<button onClick={startCreate}>+ New workout kind</button>
) : (
{editingId !== null && (
<div className="kind-editor">
<label>
Name
@@ -133,6 +152,30 @@ export function WorkoutKinds() {
Description
<input value={description} onChange={(e) => setDescription(e.target.value)} />
</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>
Rule (JSON condition tree)
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />

View File

@@ -57,6 +57,43 @@ export interface WorkoutKind {
IsActive: boolean;
CreatedAt: 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 {