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 "./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"];
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
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() {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const paceMin = parsePace(paceMinText);
|
||||||
if (editingId === "new") {
|
const paceMax = parsePace(paceMaxText);
|
||||||
await api.createWorkoutKind({ name, description, rule });
|
if (paceMinText.trim() !== "" && paceMin === null) {
|
||||||
} else if (typeof editingId === "number") {
|
setError("Min pace must look like m:ss, e.g. 4:30");
|
||||||
await api.updateWorkoutKind(editingId, { name, description, rule });
|
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);
|
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)} />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user