Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields (activity name/type, lap duration/HR, structured workout raw JSON) from RawJSON at API-response time instead of storing redundant columns - Add a fully configurable chart color system (pace/HR main-line colors, 4 effort-kind colors, tint/darken/brighten intensity knobs) under Profile > Chart colors - Rename training types and fix their display order (Easy, Long, 60'/30' Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed - Add an Efficiency Factor progression metric; fix Progression chart axes to use tight non-zero-based domains, m:ss/km pace formatting, and rounded ticks instead of raw floating-point labels - Expose the raw get_workout_by_id() payload in the raw-data viewer alongside activity/lap/detail JSON; enlarge the modal and shrink array indentation for readability - Fix "last sync" reporting a meaningless activity count: record one combined sync run per manual "Sync now" and count genuinely new activities instead of re-listing whatever Garmin returned for the queried window - Let a Review Queue activity be manually cleared back to Unclassified, and make "Reset all" available even while disconnected from Garmin Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { ActivityListItem } from "../types/api";
|
||||
|
||||
function formatPace(avgSpeedMps: number | null): string | null {
|
||||
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
|
||||
const secPerKm = 1000 / avgSpeedMps;
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}/km`;
|
||||
}
|
||||
|
||||
function lockReason(item: ActivityListItem): string {
|
||||
if (item.assignment_source === "manual") return "Manually assigned -- kept as-is by reclassify";
|
||||
if (item.workout_kind_name === "Race") return "Race, from Garmin metadata -- kept as-is by reclassify";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function Activities() {
|
||||
const [items, setItems] = useState<ActivityListItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listActivities().then(setItems).catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Activities</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="empty-state">No activities synced yet.</p>
|
||||
) : (
|
||||
<table className="kinds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Name</th>
|
||||
<th>Distance</th>
|
||||
<th>Pace</th>
|
||||
<th>Kind</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const pace = formatPace(item.AvgSpeedMps);
|
||||
return (
|
||||
<tr key={item.ID}>
|
||||
<td>{item.StartTimeUTC}</td>
|
||||
<td>{item.ActivityName || item.ActivityType}</td>
|
||||
<td>{(item.DistanceMeters / 1000).toFixed(2)} km</td>
|
||||
<td>{pace ?? "—"}</td>
|
||||
<td>{item.workout_kind_name ?? "—"}</td>
|
||||
<td>{item.assignment_source ?? "—"}</td>
|
||||
<td>
|
||||
{item.locked && (
|
||||
<span className="lock-badge" title={lockReason(item)}>
|
||||
Locked
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
|
||||
{ key: "vo2max", label: "VO2max" },
|
||||
{ key: "aerobic_te", label: "Aerobic Training Effect" },
|
||||
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
|
||||
{ key: "efficiency_factor", label: "Efficiency Factor" },
|
||||
];
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -42,17 +43,16 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Progression</h2>
|
||||
|
||||
{kinds.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
No workout kinds defined yet. Create one on the Workout Kinds tab to start seeing progression here.
|
||||
No training types defined yet. See the Training types card on the Profile page to start seeing progression
|
||||
here.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="controls">
|
||||
<label>
|
||||
Workout kind
|
||||
Training types
|
||||
<select
|
||||
value={selectedKindId ?? ""}
|
||||
onChange={(e) => setSelectedKindId(Number(e.target.value))}
|
||||
|
||||
3
frontend/src/pages/Plan.tsx
Normal file
3
frontend/src/pages/Plan.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function Plan() {
|
||||
return <div className="page" />;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ColorField } from "../components/ColorField";
|
||||
import { GarminConnection } from "../components/GarminConnection";
|
||||
import { NullableNumberField } from "../components/NullableNumberField";
|
||||
import { PaceField } from "../components/PaceField";
|
||||
import { TrainingTypesCard } from "../components/TrainingTypesCard";
|
||||
import type { Profile as ProfileType } from "../types/api";
|
||||
|
||||
function NumberField({
|
||||
@@ -26,121 +31,86 @@ function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
// Pace is entered/displayed as "m:ss" but stored 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]);
|
||||
}
|
||||
// How long to wait after the last edit before actually saving, so typing a
|
||||
// name or a multi-digit number doesn't fire one request per keystroke --
|
||||
// only the last edit in a burst triggers a save, covering every field
|
||||
// touched during that burst (not just the one that triggered the timer).
|
||||
const AUTO_SAVE_DELAY_MS = 600;
|
||||
|
||||
function formatPace(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function PaceField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const [text, setText] = useState(formatPace(value));
|
||||
useEffect(() => setText(formatPace(value)), [value]);
|
||||
|
||||
function commit(raw: string) {
|
||||
const parsed = parsePace(raw);
|
||||
if (parsed != null) {
|
||||
onChange(parsed);
|
||||
} else {
|
||||
setText(formatPace(value)); // invalid input -- revert to the last valid value
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label>
|
||||
{label}
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
placeholder="12:00"
|
||||
/>
|
||||
</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() {
|
||||
export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
||||
const [profile, setProfile] = useState<ProfileType | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
// Mirrors `profile` synchronously (state updates don't apply until the
|
||||
// next render), so set() always debounces from the latest edit rather
|
||||
// than a stale snapshot from this render's closure.
|
||||
const profileRef = useRef<ProfileType | null>(null);
|
||||
profileRef.current = profile;
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
// Flush a still-pending debounced save on unmount (e.g. the user edits a
|
||||
// field then immediately switches away from the Profile view) rather than
|
||||
// silently dropping it -- there's no state left to update for, so errors
|
||||
// are swallowed.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
if (profileRef.current) api.updateProfile(profileRef.current).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
if (!profile) return;
|
||||
setError(null);
|
||||
async function persist(next: ProfileType) {
|
||||
try {
|
||||
const updated = await api.updateProfile(profile);
|
||||
const updated = await api.updateProfile(next);
|
||||
profileRef.current = updated;
|
||||
setProfile(updated);
|
||||
setError(null);
|
||||
setSaved(true);
|
||||
onSaved?.(updated);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setSaved(false);
|
||||
}
|
||||
}
|
||||
|
||||
function set<K extends keyof ProfileType>(key: K, value: ProfileType[K]) {
|
||||
if (!profileRef.current) return;
|
||||
const next = { ...profileRef.current, [key]: value };
|
||||
profileRef.current = next;
|
||||
setProfile(next);
|
||||
setSaved(false);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => persist(next), AUTO_SAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="page profile-page">
|
||||
{error && <p className="error">{error}</p>}
|
||||
{saved && <p className="garmin-connection-message">Saved.</p>}
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Garmin account</legend>
|
||||
<legend>Profile</legend>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" value={profile.Name} onChange={(e) => set("Name", e.target.value)} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Garmin</legend>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
@@ -157,28 +127,43 @@ export function Profile() {
|
||||
onChange={(e) => set("GarminPassword", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<NumberField
|
||||
label="Past sync (days)"
|
||||
value={profile.BackfillHorizonDays}
|
||||
onChange={(v) => set("BackfillHorizonDays", v)}
|
||||
/>
|
||||
<GarminConnection />
|
||||
</fieldset>
|
||||
|
||||
{/* TODO: these settings currently have no explanation in the UI --
|
||||
add tooltips once we settle on a pattern for that. */}
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Classification</legend>
|
||||
<legend>Activity analysis</legend>
|
||||
<NumberField
|
||||
label="Rolling window (days)"
|
||||
value={profile.RollingWindowDays}
|
||||
onChange={(v) => set("RollingWindowDays", v)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Sync</legend>
|
||||
<NumberField
|
||||
label="Backfill horizon (days)"
|
||||
value={profile.BackfillHorizonDays}
|
||||
onChange={(v) => set("BackfillHorizonDays", v)}
|
||||
label="Warm-up (minutes)"
|
||||
value={profile.WarmupMinutes}
|
||||
onChange={(v) => set("WarmupMinutes", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Cool-down (minutes)"
|
||||
value={profile.CooldownMinutes}
|
||||
onChange={(v) => set("CooldownMinutes", v)}
|
||||
/>
|
||||
<PaceField
|
||||
label="Minimum representative pace (m:ss/km)"
|
||||
value={profile.MinRepresentativePaceSecPerKm}
|
||||
onChange={(v) => set("MinRepresentativePaceSecPerKm", v ?? 0)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum representative time (seconds)"
|
||||
value={profile.MinRepresentativeTimeSeconds}
|
||||
onChange={(v) => set("MinRepresentativeTimeSeconds", v)}
|
||||
/>
|
||||
<p className="empty-state">
|
||||
How far back "Sync now" reaches when walking backward from today. Not the same as the classification rolling
|
||||
window above.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
@@ -210,46 +195,39 @@ export function Profile() {
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Phase detection</legend>
|
||||
<legend>Chart colors</legend>
|
||||
<div className="controls">
|
||||
<NumberField
|
||||
label="Warm-up (minutes)"
|
||||
value={profile.WarmupMinutes}
|
||||
onChange={(v) => set("WarmupMinutes", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Cool-down (minutes)"
|
||||
value={profile.CooldownMinutes}
|
||||
onChange={(v) => set("CooldownMinutes", v)}
|
||||
<ColorField label="Pace (main line)" value={profile.PaceColor} onChange={(v) => set("PaceColor", v)} />
|
||||
<ColorField
|
||||
label="Heart rate (main line)"
|
||||
value={profile.HeartRateColor}
|
||||
onChange={(v) => set("HeartRateColor", v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="empty-state">
|
||||
Applies to every workout type. Interval workouts detect warm-up/cool-down from lap data directly and don't use this setting.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Pace chart artifacts</legend>
|
||||
<div className="controls">
|
||||
<PaceField
|
||||
label="Minimum representative pace (m:ss/km)"
|
||||
value={profile.MinRepresentativePaceSecPerKm}
|
||||
onChange={(v) => set("MinRepresentativePaceSecPerKm", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum representative time (seconds)"
|
||||
value={profile.MinRepresentativeTimeSeconds}
|
||||
onChange={(v) => set("MinRepresentativeTimeSeconds", v)}
|
||||
/>
|
||||
<ColorField label="Warm-up" value={profile.WarmupColor} onChange={(v) => set("WarmupColor", v)} />
|
||||
<ColorField label="Effort" value={profile.EffortColor} onChange={(v) => set("EffortColor", v)} />
|
||||
<ColorField label="Recovery" value={profile.RecoveryColor} onChange={(v) => set("RecoveryColor", v)} />
|
||||
<ColorField label="Cool-down" value={profile.CooldownColor} onChange={(v) => set("CooldownColor", v)} />
|
||||
</div>
|
||||
<p className="empty-state">
|
||||
A stretch of samples slower than this pace is hidden from the Review Queue's pace chart (and doesn't stretch
|
||||
its scale) unless it lasts at least this long -- e.g. a brief GPS blip right as recording starts gets
|
||||
dropped, but a real walk break or stop is kept.
|
||||
</p>
|
||||
<NumberField
|
||||
label="Main line tint on fill (%)"
|
||||
value={profile.MainLineTintPct}
|
||||
onChange={(v) => set("MainLineTintPct", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Background darkening (%)"
|
||||
value={profile.BackgroundDarkenPct}
|
||||
onChange={(v) => set("BackgroundDarkenPct", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Brightening when a target is present (%)"
|
||||
value={profile.TargetBrightenPct}
|
||||
onChange={(v) => set("TargetBrightenPct", v)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<button onClick={save}>Save</button>
|
||||
<TrainingTypesCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||
import { formatMinutesSeconds } from "../components/PaceField";
|
||||
import { RawDataModal } from "../components/RawDataModal";
|
||||
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
|
||||
const UNSORTED = "__unsorted__";
|
||||
const UNCLASSIFIED = "__unclassified__";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||
@@ -18,14 +19,145 @@ function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||
function formatPace(avgSpeedMps: number | null): string | null {
|
||||
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
|
||||
const secPerKm = 1000 / avgSpeedMps;
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}/km`;
|
||||
return `${formatMinutesSeconds(secPerKm)}/km`;
|
||||
}
|
||||
|
||||
// StartTimeUTC is "YYYY-MM-DD HH:MM:SS" in UTC with no offset marker, so it
|
||||
// must be parsed as UTC explicitly (a bare space-separated string like this
|
||||
// is otherwise ambiguous/inconsistent across browsers) before converting to
|
||||
// the viewer's own timezone for display.
|
||||
function formatActivityDateTime(startTimeUTC: string): { date: string; time: string } {
|
||||
const d = new Date(startTimeUTC.replace(" ", "T") + "Z");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
// Forced to English regardless of the viewer's own locale, to match the
|
||||
// rest of this app's English-only UI (an auto-locale abbreviation like
|
||||
// French "juil." would look inconsistent here).
|
||||
const month = d.toLocaleDateString("en-US", { month: "short" });
|
||||
const date = `${day}-${month}-${d.getFullYear()}`;
|
||||
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return { date, time };
|
||||
}
|
||||
|
||||
// Grey for unclassified, otherwise grouped by training-load family rather
|
||||
// than an exact name match, so "60' Threshold"/"30' Threshold" both read as
|
||||
// green, etc. (Threshold uses includes(), not startsWith(), since the
|
||||
// numeral now comes first.) Falls back to grey for any kind name outside
|
||||
// this taxonomy.
|
||||
function kindColor(name: string | undefined): string {
|
||||
if (!name) return "#6b7280"; // grey -- unclassified
|
||||
if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue
|
||||
if (name.includes("Threshold")) return "#22c55e"; // green
|
||||
if (name.startsWith("Tempo")) return "#eab308"; // yellow
|
||||
if (name.startsWith("Interval")) return "#f97316"; // orange
|
||||
if (name.startsWith("MAS")) return "#ef4444"; // red
|
||||
if (name.startsWith("Race")) return "#a855f7"; // purple
|
||||
return "#6b7280";
|
||||
}
|
||||
|
||||
// One combined control per activity: the label shows the current kind (or
|
||||
// "Unclassified") and opens a picker when clicked, unless locked -- either
|
||||
// because the user picked it by hand, or because it's Race, a hard fact from
|
||||
// Garmin's own metadata rather than a retunable rule. The small lock icon is
|
||||
// the only way back to unlocked, which is what lets a later "Reclassify all"
|
||||
// touch this activity again -- it's only actionable while locked, since
|
||||
// there's nothing to lock/unlock about an activity that's still unclassified.
|
||||
function ClassifyControl({
|
||||
item,
|
||||
kinds,
|
||||
busy,
|
||||
onAssign,
|
||||
onUnassign,
|
||||
onUnlock,
|
||||
}: {
|
||||
item: ReviewQueueItem;
|
||||
kinds: WorkoutKind[];
|
||||
busy: boolean;
|
||||
onAssign: (activityId: number, kindId: number) => void;
|
||||
onUnassign: (activityId: number) => void;
|
||||
onUnlock: (activityId: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const kindName = item.WorkoutKindID != null ? kinds.find((k) => k.ID === item.WorkoutKindID)?.Name : undefined;
|
||||
const isRace = kindName === "Race";
|
||||
const locked = item.AssignmentSource === "manual" || isRace;
|
||||
const assignableKinds = kinds.filter((k) => k.Name !== "Race");
|
||||
const color = kindColor(kindName);
|
||||
|
||||
return (
|
||||
<div className="classify-control">
|
||||
<button
|
||||
type="button"
|
||||
className="classify-label"
|
||||
style={{ borderColor: color, color }}
|
||||
disabled={locked || busy}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{kindName ?? "Unclassified"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="classify-lock"
|
||||
disabled={!locked || busy}
|
||||
title={
|
||||
locked
|
||||
? "Locked to this kind -- click to let auto-sort change it again"
|
||||
: "Unlocked -- auto-sort may still change this"
|
||||
}
|
||||
onClick={() => onUnlock(item.ActivityID)}
|
||||
>
|
||||
{locked ? "🔒" : "🔓"}
|
||||
</button>
|
||||
{open && !locked && (
|
||||
<div className="classify-dropdown">
|
||||
{kindName != null && (
|
||||
<button
|
||||
type="button"
|
||||
className="classify-dropdown-unassign"
|
||||
onClick={() => {
|
||||
onUnassign(item.ActivityID);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Unclassified
|
||||
</button>
|
||||
)}
|
||||
{assignableKinds.map((k) => (
|
||||
<button
|
||||
key={k.ID}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAssign(item.ActivityID, k.ID);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{k.Name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Builds the kind_id/unclassified query params for a filter pill value, so
|
||||
// filtering happens server-side -- a filtered view stays paginated (one page
|
||||
// of laps/samples/charts at a time) instead of needing the whole matching
|
||||
// backlog loaded and rendered up front just to check membership client-side.
|
||||
function filterParams(filter: string): { kindId?: number; unclassified?: boolean } {
|
||||
if (filter === UNCLASSIFIED) return { unclassified: true };
|
||||
if (filter !== "") return { kindId: Number(filter) };
|
||||
return {};
|
||||
}
|
||||
|
||||
function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
|
||||
if (filter === "") return true;
|
||||
if (filter === UNCLASSIFIED) return item.WorkoutKindID == null;
|
||||
return item.WorkoutKindID === Number(filter);
|
||||
}
|
||||
|
||||
export function ReviewQueue() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@@ -41,33 +173,45 @@ export function ReviewQueue() {
|
||||
// scroll) instead of all at once -- see the paginated GET /api/review-queue/.
|
||||
const allLoaded = !initialLoading && nextCursor === null;
|
||||
// A single in-flight request is shared by every concurrent caller (the
|
||||
// scroll observer, a filter switch's loadAll, ...): each gets the same
|
||||
// promise back and genuinely awaits its completion, rather than a boolean
|
||||
// guard that would let a second caller's loop spin against an
|
||||
// already-in-progress fetch with nothing to await.
|
||||
// scroll observer, a filter switch, ...): each gets the same promise back
|
||||
// and genuinely awaits its completion, rather than a boolean guard that
|
||||
// would let a second caller spin against an already-in-progress fetch with
|
||||
// nothing to await.
|
||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||
// The authoritative cursor for control flow, updated synchronously the
|
||||
// instant a response arrives -- NOT derived from the nextCursor state in
|
||||
// the render body. A ref written that way only picks up a new value once
|
||||
// React actually re-renders, which isn't guaranteed to happen between two
|
||||
// iterations of loadAll's tight loop; it was reading a stale cursor and
|
||||
// re-fetching the same page twice. nextCursor (state) still exists
|
||||
// separately, purely to drive rendering (e.g. allLoaded).
|
||||
// the render body, since a ref written that way only picks up a new value
|
||||
// once React actually re-renders.
|
||||
const nextCursorRef = useRef<string | null>(null);
|
||||
// Bumped on every filter switch so a response from a since-superseded
|
||||
// filter (e.g. the user clicked two pills in quick succession) is detected
|
||||
// and discarded instead of clobbering the current filter's results.
|
||||
const generationRef = useRef(0);
|
||||
// loadMore reads the *current* filter via this ref rather than closing
|
||||
// over filterKindId, so the memoized callback identity (and therefore the
|
||||
// IntersectionObserver effect below) doesn't need to be recreated on every
|
||||
// filter change.
|
||||
const filterRef = useRef(filterKindId);
|
||||
filterRef.current = filterKindId;
|
||||
|
||||
const loadMore = useCallback((): Promise<void> => {
|
||||
if (inFlightRef.current) return inFlightRef.current;
|
||||
if (nextCursorRef.current === null) return Promise.resolve();
|
||||
const gen = generationRef.current;
|
||||
setLoadingMore(true);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const page = await api.reviewQueue({ limit: PAGE_SIZE, before: nextCursorRef.current ?? undefined });
|
||||
const page = await api.reviewQueue({
|
||||
limit: PAGE_SIZE,
|
||||
before: nextCursorRef.current ?? undefined,
|
||||
...filterParams(filterRef.current),
|
||||
});
|
||||
if (gen !== generationRef.current) return; // a filter switch superseded this request
|
||||
nextCursorRef.current = page.next_cursor;
|
||||
setItems((prev) => [...prev, ...page.items]);
|
||||
setNextCursor(page.next_cursor);
|
||||
setTotal(page.total);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
if (gen === generationRef.current) setError(String(e));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
inFlightRef.current = null;
|
||||
@@ -77,40 +221,46 @@ export function ReviewQueue() {
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
// Loads every remaining page in sequence. Used when a specific-kind filter
|
||||
// is selected: filtering only the items loaded so far would hide matches
|
||||
// that simply haven't scrolled into view yet, so switching to a filter
|
||||
// (other than "All") loads the whole backlog once, up front.
|
||||
const loadAll = useCallback(async () => {
|
||||
while (nextCursorRef.current !== null) {
|
||||
await loadMore();
|
||||
}
|
||||
}, [loadMore]);
|
||||
|
||||
function reload() {
|
||||
// Resets the list and loads the first page for `filter`. Used both for the
|
||||
// initial mount (filter "") and every filter-pill switch.
|
||||
function loadFirstPage(filter: string) {
|
||||
generationRef.current += 1;
|
||||
const gen = generationRef.current;
|
||||
inFlightRef.current = null; // release loadMore's guard for any stale in-flight request
|
||||
nextCursorRef.current = null;
|
||||
setItems([]);
|
||||
setInitialLoading(true);
|
||||
Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds(), api.getProfile()])
|
||||
.then(([page, workoutKinds, userProfile]) => {
|
||||
api
|
||||
.reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||
.then((page) => {
|
||||
if (gen !== generationRef.current) return;
|
||||
nextCursorRef.current = page.next_cursor;
|
||||
setItems(page.items);
|
||||
setNextCursor(page.next_cursor);
|
||||
setTotal(page.total);
|
||||
setKinds(workoutKinds);
|
||||
setProfile(userProfile);
|
||||
if (filter === "") setGrandTotal(page.total);
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setInitialLoading(false));
|
||||
.catch((e) => {
|
||||
if (gen === generationRef.current) setError(String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen === generationRef.current) setInitialLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(reload, []);
|
||||
useEffect(() => {
|
||||
loadFirstPage("");
|
||||
api.listWorkoutKinds().then(setKinds).catch((e) => setError(String(e)));
|
||||
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Infinite scroll: load the next page once the sentinel at the bottom of
|
||||
// the list becomes visible.
|
||||
// the list becomes visible. Works the same whether a filter is active or
|
||||
// not, since filtering now happens server-side.
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node || filterKindId !== "") return;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) loadMore();
|
||||
@@ -119,14 +269,25 @@ export function ReviewQueue() {
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, filterKindId, items.length]);
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
async function resolve(activityId: number, kindId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.resolveReview(activityId, kindId);
|
||||
setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
|
||||
setTotal((prev) => Math.max(0, prev - 1));
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
i.ActivityID === activityId
|
||||
? { ...i, WorkoutKindID: kindId, AssignmentSource: "manual" as const, Status: "assigned" as const }
|
||||
: i,
|
||||
)
|
||||
// If a filter (kind or Unclassified) is active and this reassignment
|
||||
// moved the activity out of it, drop it from the visible list --
|
||||
// otherwise e.g. reassigning an item away from "Easy" while filtered
|
||||
// to Easy would leave it sitting there under the wrong filter.
|
||||
.filter((i) => matchesFilter(i, filterKindId)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -134,45 +295,69 @@ export function ReviewQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (filterKindId === "") return items;
|
||||
if (filterKindId === UNSORTED) {
|
||||
return items.filter((item) => candidates(item).length === 0);
|
||||
async function unassign(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unassignReview(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
i.ActivityID === activityId
|
||||
? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
|
||||
: i,
|
||||
)
|
||||
// Same as resolve() -- an active kind filter no longer matches an
|
||||
// activity just cleared back to Unclassified, so drop it.
|
||||
.filter((i) => matchesFilter(i, filterKindId)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setResolvingId(null);
|
||||
}
|
||||
const id = Number(filterKindId);
|
||||
return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
|
||||
}, [items, filterKindId]);
|
||||
}
|
||||
|
||||
async function unlock(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unlockReview(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i))
|
||||
.filter((i) => matchesFilter(i, filterKindId)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setResolvingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFilter(id: string) {
|
||||
const next = filterKindId === id ? "" : id;
|
||||
setFilterKindId(next);
|
||||
if (next !== "") loadAll();
|
||||
loadFirstPage(next);
|
||||
}
|
||||
|
||||
// Race is assigned automatically from Garmin metadata (eventType.typeKey
|
||||
// == "race"), never by hand -- not offered as a manual-assign option.
|
||||
const manuallyAssignableKinds = kinds.filter((k) => k.Name !== "Race");
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Review Queue</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{total > 0 && (
|
||||
{grandTotal > 0 && (
|
||||
<div className="filter-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
|
||||
onClick={() => toggleFilter("")}
|
||||
>
|
||||
All ({total})
|
||||
All ({grandTotal})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-pill${filterKindId === UNSORTED ? " active" : ""}`}
|
||||
onClick={() => toggleFilter(UNSORTED)}
|
||||
className={`filter-pill${filterKindId === UNCLASSIFIED ? " active" : ""}`}
|
||||
onClick={() => toggleFilter(UNCLASSIFIED)}
|
||||
>
|
||||
Unsorted (no candidates)
|
||||
Unclassified
|
||||
</button>
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
@@ -189,28 +374,40 @@ export function ReviewQueue() {
|
||||
|
||||
{initialLoading ? (
|
||||
<p className="empty-state">Loading…</p>
|
||||
) : total === 0 ? (
|
||||
<p className="empty-state">Nothing needs review right now.</p>
|
||||
) : filterKindId !== "" && !allLoaded ? (
|
||||
<p className="empty-state">Loading the rest of the queue to filter accurately…</p>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="empty-state">No runs match this filter.</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="empty-state">
|
||||
{filterKindId === "" ? "No activities synced yet." : "No runs match this filter."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="review-list">
|
||||
{filteredItems.map((item) => {
|
||||
{items.map((item) => {
|
||||
const scored = candidates(item);
|
||||
const pace = formatPace(item.activity.AvgSpeedMps);
|
||||
const { date, time } = formatActivityDateTime(item.activity.StartTimeUTC);
|
||||
return (
|
||||
<li key={item.ActivityID} className="review-item">
|
||||
<div className="review-item-header">
|
||||
<strong>{item.activity.ActivityName || item.activity.ActivityType}</strong>
|
||||
<span>{item.activity.StartTimeUTC}</span>
|
||||
<div className="review-item-title">
|
||||
<strong>{item.activity.ActivityName || item.activity.ActivityType}</strong>
|
||||
<ClassifyControl
|
||||
item={item}
|
||||
kinds={kinds}
|
||||
busy={resolvingId === item.ActivityID}
|
||||
onAssign={resolve}
|
||||
onUnassign={unassign}
|
||||
onUnlock={unlock}
|
||||
/>
|
||||
</div>
|
||||
<div className="review-item-datetime">
|
||||
<span>{date}</span>
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-item-stats">
|
||||
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||
<span>{Math.round(item.activity.DurationSeconds / 60)} min</span>
|
||||
{pace && <span>{pace}</span>}
|
||||
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||
<span>📏 {(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||
<span>⏱️ {Math.round(item.activity.DurationSeconds / 60)} min</span>
|
||||
{pace && <span>⚡ {pace}</span>}
|
||||
{item.activity.AvgHR != null && <span>❤️ {Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||
</div>
|
||||
|
||||
{scored.length > 0 && (
|
||||
@@ -224,20 +421,17 @@ export function ReviewQueue() {
|
||||
samples={item.samples}
|
||||
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
|
||||
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
|
||||
paceColor={profile?.PaceColor ?? "#3b82f6"}
|
||||
heartRateColor={profile?.HeartRateColor ?? "#ef4444"}
|
||||
warmupColor={profile?.WarmupColor ?? "#c2410c"}
|
||||
effortColor={profile?.EffortColor ?? "#7c3aed"}
|
||||
recoveryColor={profile?.RecoveryColor ?? "#15803d"}
|
||||
cooldownColor={profile?.CooldownColor ?? "#fb923c"}
|
||||
mainLineTintPct={profile?.MainLineTintPct ?? 20}
|
||||
backgroundDarkenPct={profile?.BackgroundDarkenPct ?? 35}
|
||||
targetBrightenPct={profile?.TargetBrightenPct ?? 20}
|
||||
/>
|
||||
|
||||
<div className="review-item-actions">
|
||||
{manuallyAssignableKinds.map((k) => (
|
||||
<button
|
||||
key={k.ID}
|
||||
disabled={resolvingId === item.ActivityID}
|
||||
onClick={() => resolve(item.ActivityID, k.ID)}
|
||||
>
|
||||
{k.Name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="review-item-footer">
|
||||
<button type="button" className="raw-data-button" onClick={() => setRawDataItem(item)}>
|
||||
Raw data
|
||||
@@ -249,7 +443,7 @@ export function ReviewQueue() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{filterKindId === "" && !allLoaded && (
|
||||
{!allLoaded && (
|
||||
<div ref={sentinelRef} className="review-list-sentinel">
|
||||
{loadingMore && <p className="empty-state">Loading more…</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { WorkoutKind } from "../types/api";
|
||||
|
||||
const EXAMPLE_RULE = `{
|
||||
"match": "all",
|
||||
"conditions": [
|
||||
{ "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] },
|
||||
{ "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 }
|
||||
]
|
||||
}`;
|
||||
|
||||
// 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 | 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 [reclassifying, setReclassifying] = useState(false);
|
||||
|
||||
function reload() {
|
||||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||
}
|
||||
|
||||
useEffect(reload, []);
|
||||
|
||||
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);
|
||||
} catch {
|
||||
setError("Rule is not valid JSON");
|
||||
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 {
|
||||
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) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function reclassifyAll() {
|
||||
setReclassifying(true);
|
||||
try {
|
||||
const res = await api.reclassifyAll();
|
||||
setError(`Reclassified ${res.reclassified} activities.`);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setReclassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Workout Kinds</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<div className="controls">
|
||||
<button disabled={reclassifying} onClick={reclassifyAll}>
|
||||
{reclassifying ? "Reclassifying…" : "Reclassify all"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="empty-state">
|
||||
Re-runs the rule engine on every activity, except manual assignments (the user's definitive word) and Race
|
||||
assignments (a fact from Garmin, not a retunable rule).
|
||||
</p>
|
||||
|
||||
<table className="kinds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Pace range</th>
|
||||
<th>HR zone</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kinds.map((k) => (
|
||||
<tr key={k.ID}>
|
||||
<td>{k.Name}</td>
|
||||
<td>{k.Description}</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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{editingId !== null && (
|
||||
<div className="kind-editor">
|
||||
<label>
|
||||
Name
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
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)} />
|
||||
</label>
|
||||
<div className="kind-editor-actions">
|
||||
<button onClick={save}>Save</button>
|
||||
<button onClick={() => setEditingId(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user