feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing
This commit is contained in:
@@ -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"];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
182
frontend/src/pages/Profile.tsx
Normal file
182
frontend/src/pages/Profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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() {
|
||||
<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)} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user