153 lines
3.8 KiB
TypeScript
153 lines
3.8 KiB
TypeScript
|
|
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>
|
||
|
|
|
||
|
|
<button onClick={save}>Save</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|