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:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -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>
);
}

View File

@@ -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))}

View File

@@ -0,0 +1,3 @@
export function Plan() {
return <div className="page" />;
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}