feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user