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

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>geniusrun</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 177 B

View File

@@ -10,7 +10,7 @@ body {
}
.app {
max-width: 960px;
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem 3rem;
}
@@ -42,18 +42,57 @@ body {
cursor: pointer;
}
.tab-icon {
margin-right: 0.4rem;
}
.tab.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
buttons: this opens account/profile settings, not an application view
alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab;
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.profile-name::before {
content: "⚙️";
font-size: 0.8rem;
}
.profile-name:hover:not(.active) {
border-color: #3b82f6;
color: #e6e6e6;
}
.profile-name.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.garmin-connection {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0;
border-bottom: 1px solid #2a2d35;
padding-top: 0.75rem;
border-top: 1px solid #2a2d35;
}
.garmin-connection-row {
@@ -62,6 +101,22 @@ body {
gap: 0.75rem;
}
.garmin-connection-main {
justify-content: space-between;
}
.garmin-connection-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.garmin-connection-status {
display: flex;
align-items: center;
gap: 0.75rem;
}
.garmin-connection-message {
margin: 0;
font-size: 0.8rem;
@@ -128,6 +183,13 @@ button {
cursor: pointer;
}
input[type="color"] {
padding: 0.2rem;
width: 3.5rem;
height: 2rem;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: #3b82f6;
}
@@ -151,11 +213,15 @@ button:disabled {
.filter-pills {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
}
.filter-pill {
flex-shrink: 0;
white-space: nowrap;
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
@@ -197,14 +263,30 @@ button:disabled {
.review-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 1rem;
}
.review-item-title {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.review-item-header span {
color: #9aa0ab;
font-size: 0.85rem;
}
.review-item-datetime {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.3;
}
.review-item-stats {
display: flex;
gap: 1rem;
@@ -219,11 +301,49 @@ button:disabled {
margin: 0.5rem 0;
}
.review-item-actions {
.classify-control {
position: relative;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
align-items: center;
gap: 0.4rem;
}
.classify-label {
font-size: 0.85rem;
}
.classify-lock {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
line-height: 1;
}
.classify-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 10;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.4rem;
background: #14161c;
border: 1px solid #2a2d35;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.classify-dropdown button {
text-align: left;
white-space: nowrap;
}
.classify-dropdown-unassign {
color: #9aa0ab;
padding-bottom: 0.4rem;
margin-bottom: 0.15rem;
border-bottom: 1px solid #2a2d35;
}
.expected-actual-charts {
@@ -239,14 +359,15 @@ button:disabled {
.mini-chart-label {
display: block;
text-align: center;
font-size: 0.75rem;
color: #9aa0ab;
margin-bottom: 0.15rem;
}
.phase-legend {
flex-basis: 100%;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
@@ -292,8 +413,8 @@ button:disabled {
background: #14161c;
border: 1px solid #2a2d35;
border-radius: 8px;
width: min(720px, 90vw);
max-height: 80vh;
width: min(1100px, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
}
@@ -307,42 +428,75 @@ button:disabled {
font-weight: 600;
}
.modal-header-actions {
display: flex;
gap: 0.5rem;
font-weight: 400;
}
.modal-header-actions button {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
}
.modal-json {
margin: 0;
padding: 1rem;
overflow: auto;
font-size: 0.8rem;
line-height: 1.4;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.json-row {
white-space: pre-wrap;
word-break: break-word;
}
.kinds-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.lock-badge {
.json-toggle {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.75rem;
width: 1rem;
padding: 0;
border: none;
background: none;
color: #9aa0ab;
border: 1px solid #2a2d35;
cursor: help;
cursor: pointer;
font-size: 0.7rem;
font-family: inherit;
}
.kinds-table th,
.kinds-table td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #2a2d35;
.json-toggle:hover {
color: #e6e6e6;
}
.kinds-table-actions {
display: flex;
gap: 0.5rem;
.json-key {
color: #9aa0ab;
}
.json-bracket {
color: #6b7280;
}
.json-summary {
color: #6b7280;
font-style: italic;
}
.json-string {
color: #6fcf97;
}
.json-number {
color: #6cb6ff;
}
.json-boolean {
color: #f5a742;
}
.json-null {
color: #6b7280;
font-style: italic;
}
.kind-editor {
@@ -352,7 +506,51 @@ button:disabled {
border: 1px solid #2a2d35;
border-radius: 8px;
padding: 1rem;
max-width: 480px;
max-width: 580px;
}
.profile-page {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.training-type-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.training-type-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 1px solid #2a2d35;
border-radius: 6px;
padding: 0.75rem;
}
.training-type-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.training-type-description {
margin: 0;
font-size: 0.85rem;
color: #9aa0ab;
white-space: pre-wrap;
}
.training-type-meta {
margin: 0;
font-size: 0.8rem;
color: #6b7280;
}
.kind-editor textarea {

View File

@@ -1,24 +1,31 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { api } from "./api/client";
import "./App.css";
import { GarminConnection } from "./components/GarminConnection";
import { Activities } from "./pages/Activities";
import { Dashboard } from "./pages/Dashboard";
import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue";
import { WorkoutKinds } from "./pages/WorkoutKinds";
const TABS = [
{ key: "dashboard", label: "Progression", Component: Dashboard },
{ key: "review", label: "Review Queue", Component: ReviewQueue },
{ key: "activities", label: "Activities", Component: Activities },
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
{ key: "profile", label: "Profile", Component: Profile },
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
{ key: "dashboard", label: "Progression", icon: "↗", Component: Dashboard },
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
] as const;
type TabKey = (typeof TABS)[number]["key"];
function App() {
const [tab, setTab] = useState<TabKey>("dashboard");
const [tab, setTab] = useState<TabKey>("activities");
// Profile isn't a tab: it's reached via the profile name in the top-right
// corner instead, since (for now, single-profile) it's account settings,
// not a content view alongside Activities/Progression/Training plan.
const [showProfile, setShowProfile] = useState(false);
const [profileName, setProfileName] = useState<string | null>(null);
useEffect(() => {
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
}, []);
const Active = TABS.find((t) => t.key === tab)!.Component;
return (
@@ -29,18 +36,26 @@ function App() {
{TABS.map((t) => (
<button
key={t.key}
className={t.key === tab ? "tab active" : "tab"}
onClick={() => setTab(t.key)}
className={!showProfile && t.key === tab ? "tab active" : "tab"}
onClick={() => {
setShowProfile(false);
setTab(t.key);
}}
>
<span className="tab-icon">{t.icon}</span>
{t.label}
</button>
))}
</nav>
<button
type="button"
className={showProfile ? "profile-name active" : "profile-name"}
onClick={() => setShowProfile(true)}
>
{profileName ?? "Profile"}
</button>
</header>
<GarminConnection />
<main>
<Active />
</main>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div>
);
}

View File

@@ -55,7 +55,7 @@ export const api = {
getActivity: (id: number) =>
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
// Workout kinds -- fixed taxonomy, no create/delete
// Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) =>
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
updateWorkoutKind: (
@@ -69,7 +69,8 @@ export const api = {
is_active?: boolean;
pace_min_sec_per_km?: number | null;
pace_max_sec_per_km?: number | null;
expected_hr_zone?: number | null;
hr_min_pct_hrr?: number | null;
hr_max_pct_hrr?: number | null;
},
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
@@ -85,11 +86,15 @@ export const api = {
// Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and
// per-second samples up front gets expensive once there are many, so the
// frontend loads it incrementally (infinite scroll) instead of all at once.
reviewQueue: (params?: { limit?: number; before?: string }) => {
// frontend loads it incrementally (infinite scroll) instead of all at once
// -- kindId/unclassified filter server-side so a filtered view stays
// paginated too, instead of having to load the whole matching backlog.
reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
const q = new URLSearchParams();
if (params?.limit) q.set("limit", String(params.limit));
if (params?.before) q.set("before", params.before);
if (params?.kindId != null) q.set("kind_id", String(params.kindId));
if (params?.unclassified) q.set("unclassified", "true");
const qs = q.toString();
return request<ReviewQueuePage>(`/api/review-queue/${qs ? `?${qs}` : ""}`);
},
@@ -98,6 +103,14 @@ export const api = {
method: "POST",
body: JSON.stringify({ workout_kind_id: workoutKindId }),
}),
// Reverts a manual assignment back to rule_engine sourcing (same kind),
// so a later reclassify pass is free to change it again.
unlockReview: (activityId: number) =>
request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
// Manually clears an activity's kind back to Unclassified (locks that
// decision, same as resolveReview locks a specific kind).
unassignReview: (activityId: number) =>
request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
// Progression
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {

View File

@@ -0,0 +1,16 @@
export function ColorField({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<label>
{label}
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
</label>
);
}

View File

@@ -20,6 +20,10 @@ export function GarminConnection() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
// There's no real Garmin logout -- the backend session just sits idle.
// "Disconnect" only hides that and shows Connect again locally; a real
// login (connect()) clears it.
const [disconnected, setDisconnected] = useState(false);
function refreshStatus() {
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
@@ -47,6 +51,7 @@ export function GarminConnection() {
setError(null);
try {
setAuth(await api.login());
setDisconnected(false);
} catch (e) {
setError(String(e));
} finally {
@@ -54,6 +59,10 @@ export function GarminConnection() {
}
}
function disconnect() {
setDisconnected(true);
}
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
@@ -90,7 +99,7 @@ export function GarminConnection() {
try {
await api.resetSync();
// Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Review Queue) would
// before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state.
let status = await api.syncStatus();
while (status.in_progress) {
@@ -104,45 +113,48 @@ export function GarminConnection() {
}
}
const status = auth?.status ?? "unknown";
const status = disconnected ? "unknown" : auth?.status ?? "unknown";
return (
<div className="garmin-connection">
<div className="garmin-connection-row">
<span className={`status-dot status-${status}`} />
<span className="status-label">
{status === "authenticated" && "Connected to Garmin"}
{status === "mfa_required" && "MFA code required"}
{status === "failed" && "Connection failed"}
{status === "unknown" && "Not connected to Garmin"}
</span>
<div className="garmin-connection-row garmin-connection-main">
<div className="garmin-connection-actions">
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
</button>
)}
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
{status === "authenticated" && (
<>
<button disabled={busy} onClick={sync}>
Sync now
</button>
<button disabled={busy} onClick={disconnect}>
Disconnect
</button>
</>
)}
{/* Reset all only wipes local DB state (see handleSyncReset) -- it
doesn't touch the Garmin session, so it stays available even
while disconnected/not-yet-connected. */}
<button className="button-danger" disabled={busy} onClick={resetAll}>
Reset all
</button>
)}
</div>
{status === "authenticated" && (
<>
<button disabled={busy} onClick={sync}>
Sync now
</button>
<button className="button-danger" disabled={busy} onClick={resetAll}>
Reset all
</button>
{syncStatus?.in_progress && (
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
)}
{syncStatus?.last_run && !syncStatus.in_progress && (
<span className="status-label">
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
{syncStatus.activities_pending_details > 0 &&
`${syncStatus.activities_pending_details} still need details, click Sync now again`}
</span>
)}
</>
)}
<div className="garmin-connection-status">
<span className={`status-dot status-${status}`} />
{/* Connected/not-connected is already conveyed by the
Connect/Disconnect button itself -- only MFA/failed need a
label, since no button distinguishes those from "not connected". */}
{(status === "mfa_required" || status === "failed") && (
<span className="status-label">
{status === "mfa_required" ? "MFA code required" : "Connection failed"}
</span>
)}
</div>
</div>
{status === "mfa_required" && (
@@ -159,7 +171,21 @@ export function GarminConnection() {
</div>
)}
{auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
{/* Full-width so a long "last sync"/pending-details message has room
to breathe, instead of being squeezed next to the status dot. */}
{status === "authenticated" && syncStatus?.in_progress && (
<p className="garmin-connection-message">{syncProgressLabel(syncStatus)}</p>
)}
{status === "authenticated" && syncStatus?.last_run && !syncStatus.in_progress && (
<p className="garmin-connection-message">
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
{syncStatus.activities_pending_details > 0 &&
`${syncStatus.activities_pending_details} still need details, click Sync now again`}
</p>
)}
{error && <p className="error">{error}</p>}
</div>
);

View File

@@ -0,0 +1,20 @@
export 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>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
// Pace is entered/displayed as "m:ss" but stored as whole seconds. An empty
// input means "not set" (null), not zero.
export 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]);
}
// Rounds the total seconds first, then splits into minutes/seconds -- doing
// it the other way around (floor the minutes, round the leftover seconds
// separately) can round e.g. 479.6s up to "7:60" instead of carrying over to
// "8:00".
export function formatMinutesSeconds(totalSeconds: number): string {
const total = Math.round(totalSeconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function formatPace(seconds: number | null): string {
if (seconds == null) return "";
return formatMinutesSeconds(seconds);
}
export function PaceField({
label,
value,
onChange,
}: {
label: string;
value: number | null;
onChange: (v: number | null) => void;
}) {
const [text, setText] = useState(formatPace(value));
useEffect(() => setText(formatPace(value)), [value]);
function commit(raw: string) {
if (raw.trim() === "") {
onChange(null);
return;
}
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>
);
}

View File

@@ -1,9 +1,13 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
// Fields whose value is itself a JSON string (Garmin's raw payloads, kept
// verbatim in the DB). Parsed back into objects so the modal shows one
// readable nested tree instead of an escaped string blob.
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON"]);
// verbatim in the DB). Parsed back into objects so the tree shows a real
// nested structure instead of an escaped string blob. Every other field is
// shown as-is, even ones that duplicate something inside these blobs --
// the backend's own duplication cleanup (2026-07) already removed every
// field that had no good reason to exist alongside RawJSON, so anything
// left here is worth seeing in full.
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON", "WorkoutRawJSON"]);
function parseEmbeddedJSON(value: unknown): unknown {
if (Array.isArray(value)) return value.map(parseEmbeddedJSON);
@@ -26,21 +30,147 @@ function parseEmbeddedJSON(value: unknown): unknown {
return value;
}
// Indentation per nesting level, in px. Array levels use a smaller step than
// object levels -- a numeric index carries far less structure than a named
// key, and Garmin payloads have long, deeply-repeated arrays (samples, laps,
// workout steps) where full per-level indentation quickly eats the width
// available for the values themselves.
const OBJECT_INDENT = 12;
const ARRAY_INDENT = 6;
type ValueKind = "object" | "array" | "string" | "number" | "boolean" | "null";
function kindOf(v: unknown): ValueKind {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
if (typeof v === "object") return "object";
if (typeof v === "number") return "number";
if (typeof v === "boolean") return "boolean";
return "string";
}
// Bumped by the Expand/Collapse all buttons to force every node's local
// fold state, overriding whatever the user clicked individually before.
type ExpandSignal = { gen: number; expand: boolean } | null;
function Primitive({ value }: { value: unknown }) {
const kind = kindOf(value);
if (kind === "string") return <span className="json-string">{`"${value as string}"`}</span>;
if (kind === "null") return <span className="json-null">null</span>;
return <span className={`json-${kind}`}>{String(value)}</span>;
}
function JsonNode({
name,
value,
depth,
indentPx,
signal,
}: {
name: string | null;
value: unknown;
depth: number;
indentPx: number;
signal: ExpandSignal;
}) {
const kind = kindOf(value);
const isContainer = kind === "object" || kind === "array";
const entries: Array<[string, unknown]> = !isContainer
? []
: kind === "array"
? (value as unknown[]).map((v, i) => [String(i), v])
: Object.entries(value as Record<string, unknown>);
// Deeply nested or very large containers (raw Garmin payloads, per-second
// sample arrays) start folded so opening the modal doesn't render
// thousands of rows up front; shallow, modestly sized ones start open.
const [expanded, setExpanded] = useState(depth < 2 && entries.length <= 50);
useEffect(() => {
if (signal) setExpanded(signal.expand);
}, [signal]);
const indent = { paddingLeft: indentPx };
const childIndentPx = indentPx + (kind === "array" ? ARRAY_INDENT : OBJECT_INDENT);
if (!isContainer) {
return (
<div className="json-row" style={indent}>
{name != null && <span className="json-key">{name}: </span>}
<Primitive value={value} />
</div>
);
}
const [open, close] = kind === "array" ? ["[", "]"] : ["{", "}"];
return (
<div className="json-row" style={indent}>
<button
type="button"
className="json-toggle"
onClick={() => setExpanded((e) => !e)}
aria-label={expanded ? "Collapse" : "Expand"}
>
{expanded ? "▾" : "▸"}
</button>
{name != null && <span className="json-key">{name}: </span>}
{expanded ? (
<>
<span className="json-bracket">{open}</span>
{entries.map(([k, v]) => (
<JsonNode key={k} name={k} value={v} depth={depth + 1} indentPx={childIndentPx} signal={signal} />
))}
<div className="json-bracket" style={indent}>
{close}
</div>
</>
) : (
<span className="json-summary">
{open}
{entries.length} {kind === "array" ? "items" : "keys"}
{close}
</span>
)}
</div>
);
}
export function RawDataModal({ data, onClose }: { data: unknown; onClose: () => void }) {
const [signal, setSignal] = useState<ExpandSignal>(null);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const parsed = parseEmbeddedJSON(data);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<span>Raw data</span>
<button onClick={onClose}>Close</button>
<div className="modal-header-actions">
<button
type="button"
onClick={() => setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: true }))}
>
Expand all
</button>
<button
type="button"
onClick={() => setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: false }))}
>
Collapse all
</button>
<button type="button" onClick={onClose}>
Close
</button>
</div>
</div>
<div className="modal-json">
<JsonNode name={null} value={parsed} depth={0} indentPx={0} signal={signal} />
</div>
<pre className="modal-json">{JSON.stringify(parseEmbeddedJSON(data), null, 2)}</pre>
</div>
</div>
);

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { NullableNumberField } from "./NullableNumberField";
import { PaceField, formatPace } from "./PaceField";
import type { WorkoutKind } from "../types/api";
// RuleJSON still exists on WorkoutKind and still drives the rule engine for
// kinds configured earlier, but it's not edited here: for now a training
// type is described in free text (used later to assist an AI-based
// auto-sort) plus a pace/HR range for reference, and whatever rule a kind
// already has is carried through untouched on save.
function paceRangeText(min: number | null, max: number | null): string | null {
if (min == null && max == null) return null;
if (min != null && max != null) return `${formatPace(min)}${formatPace(max)}/km`;
return min != null ? `from ${formatPace(min)}/km` : `up to ${formatPace(max)}/km`;
}
function hrRangeText(min: number | null, max: number | null): string | null {
if (min == null && max == null) return null;
if (min != null && max != null) return `${min}${max}% HRR`;
return min != null ? `from ${min}% HRR` : `up to ${max}% HRR`;
}
export function TrainingTypesCard() {
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
const [editing, setEditing] = useState<WorkoutKind | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [paceMin, setPaceMin] = useState<number | null>(null);
const [paceMax, setPaceMax] = useState<number | null>(null);
const [hrMin, setHrMin] = useState<number | null>(null);
const [hrMax, setHrMax] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
function reload() {
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
}
useEffect(reload, []);
function startEdit(k: WorkoutKind) {
setEditing(k);
setName(k.Name);
setDescription(k.Description);
setPaceMin(k.pace_min_sec_per_km);
setPaceMax(k.pace_max_sec_per_km);
setHrMin(k.hr_min_pct_hrr);
setHrMax(k.hr_max_pct_hrr);
setError(null);
}
async function save() {
if (!editing) return;
setError(null);
try {
await api.updateWorkoutKind(editing.ID, {
name,
description,
rule: JSON.parse(editing.RuleJSON || "{}"),
pace_min_sec_per_km: paceMin,
pace_max_sec_per_km: paceMax,
hr_min_pct_hrr: hrMin,
hr_max_pct_hrr: hrMax,
});
setEditing(null);
reload();
} catch (e) {
setError(String(e));
}
}
return (
<fieldset className="kind-editor">
<legend>Training types</legend>
{error && <p className="error">{error}</p>}
<ul className="training-type-list">
{kinds.map((k) => (
<li key={k.ID} className="training-type-item">
{editing?.ID === k.ID ? (
<>
<label>
Name
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
Description
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<div className="controls">
<PaceField label="Min pace (m:ss/km)" value={paceMin} onChange={setPaceMin} />
<PaceField label="Max pace (m:ss/km)" value={paceMax} onChange={setPaceMax} />
</div>
<div className="controls">
<NullableNumberField label="Min heart rate (% HRR)" value={hrMin} onChange={setHrMin} />
<NullableNumberField label="Max heart rate (% HRR)" value={hrMax} onChange={setHrMax} />
</div>
<div className="kind-editor-actions">
<button onClick={save}>Save</button>
<button onClick={() => setEditing(null)}>Cancel</button>
</div>
</>
) : (
<>
<div className="training-type-header">
<strong>{k.Name}</strong>
<button onClick={() => startEdit(k)}>Edit</button>
</div>
<p className="training-type-description">{k.Description || "No description yet."}</p>
<p className="training-type-meta">
{[
paceRangeText(k.pace_min_sec_per_km, k.pace_max_sec_per_km),
hrRangeText(k.hr_min_pct_hrr, k.hr_max_pct_hrr),
]
.filter(Boolean)
.join(" · ") || "No target pace or heart rate set."}
</p>
</>
)}
</li>
))}
</ul>
</fieldset>
);
}

View File

@@ -1,4 +1,5 @@
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatMinutesSeconds } from "../PaceField";
import type { Lap, Sample } from "../../types/api";
function paceSecPerKm(mps: number | null): number | null {
@@ -43,17 +44,6 @@ function filterPaceArtifacts(
return result;
}
// Rounds the total seconds first, then splits into minutes/seconds -- doing
// it the other way around (floor the minutes, round the leftover seconds
// separately) can round 59.6s up to "60", displaying e.g. "6:60" instead of
// carrying over to "7:00".
function formatMinutesSeconds(totalSeconds: number): string {
const total = Math.round(totalSeconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function formatPaceShort(secPerKm: number): string {
return formatMinutesSeconds(secPerKm);
}
@@ -122,27 +112,48 @@ function hrTickStep(domain: [number, number]): number {
return 5;
}
// Pace and HR each keep one dedicated accent color across every chart, so
// the two metrics stay visually distinct from each other and from the phase
// bands below.
const PACE_COLOR = "#3b82f6"; // blue
const HR_COLOR = "#ef4444"; // red
// Color policy (all 6 colors configurable in Profile > Chart colors):
// - Each chart's main line (the actual pace/HR trace and its dashed phase
// average) is always its metric's own color -- blue for pace, red for HR
// -- regardless of effort kind.
// - The 4 effort-kind colors (warm-up/effort/recovery/cool-down) drive two
// derived fills: the area *under* the line is the effort color with a
// subtle tint of the main line color mixed in (mixColor); the background
// *above* the line (the full-height band behind everything) is the same
// effort color, darkened, with no main-line tint (darken).
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace("#", "");
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
const n = parseInt(full, 16) || 0;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]: [number, number, number]): string {
const toHex = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Mixes `weight` (0-1) of `tint` into `base` -- e.g. mixColor(effortColor,
// paceColor, 0.2) reads as "effort color with a subtle taint of pace blue".
function mixColor(base: string, tint: string, weight: number): string {
const b = hexToRgb(base);
const t = hexToRgb(tint);
return rgbToHex([0, 1, 2].map((i) => b[i] + (t[i] - b[i]) * weight) as [number, number, number]);
}
// Darkens `hex` by mixing in `amount` (0-1) of black -- no main-line tint,
// just a deeper shade of the same effort color.
function darken(hex: string, amount: number): string {
return mixColor(hex, "#000000", amount);
}
// Brightens `hex` by mixing in `amount` (0-1) of white -- the color cue that
// a chart has a structured-workout target range to show, replacing a text
// label (it's otherwise not visually obvious a target exists).
function brighten(hex: string, amount: number): string {
return mixColor(hex, "#ffffff", amount);
}
// Phase a lap represents, by Garmin's own per-lap IntensityType tagging.
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
// the pace/HR trace so a run's structure reads at a glance -- deliberately
// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
// or auto-detected effort segments, but they're the same concept here.
const PHASE_COLORS: Record<string, string> = {
WARMUP: "#f59e0b", // amber
ACTIVE: "#ec4899", // pink
INTERVAL: "#ec4899", // pink
REST: "#14b8a6", // teal
RECOVERY: "#14b8a6", // teal
COOLDOWN: "#8b5cf6", // violet
};
const PHASE_LABELS: Record<string, string> = {
WARMUP: "Warm-up",
ACTIVE: "Effort",
@@ -173,7 +184,7 @@ interface Point {
phaseAvgHR: number | null;
}
function buildLapWindows(laps: Lap[]): LapWindow[] {
function buildLapWindows(laps: Lap[], phaseColors: Record<string, string>): LapWindow[] {
const windows: LapWindow[] = [];
let elapsedMin = 0;
laps.forEach((l, i) => {
@@ -203,7 +214,7 @@ function buildLapWindows(laps: Lap[]): LapWindow[] {
targetHRRange: !isContinuation && l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
avgPace: paceSecPerKm(l.AvgSpeedMps),
avgHR: l.AvgHR,
color: PHASE_COLORS[l.IntensityType],
color: phaseColors[l.IntensityType],
});
});
return windows;
@@ -297,19 +308,56 @@ export function ExpectedVsActualChart({
samples,
minRepresentativePaceSecPerKm,
minRepresentativeTimeSeconds,
paceColor,
heartRateColor,
warmupColor,
effortColor,
recoveryColor,
cooldownColor,
mainLineTintPct,
backgroundDarkenPct,
targetBrightenPct,
}: {
laps: Lap[];
samples: Sample[];
minRepresentativePaceSecPerKm: number;
minRepresentativeTimeSeconds: number;
paceColor: string;
heartRateColor: string;
warmupColor: string;
effortColor: string;
recoveryColor: string;
cooldownColor: string;
mainLineTintPct: number;
backgroundDarkenPct: number;
targetBrightenPct: number;
}) {
if (laps.length === 0) return null;
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
// or auto-detected effort segments, but they're the same concept here.
// Likewise REST/RECOVERY are both "recovery".
const phaseColors: Record<string, string> = {
WARMUP: warmupColor,
ACTIVE: effortColor,
INTERVAL: effortColor,
REST: recoveryColor,
RECOVERY: recoveryColor,
COOLDOWN: cooldownColor,
};
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
// Brightened main line color is the cue that this chart has a target to
// show, replacing a "vs target" text label -- it's otherwise not visually
// obvious a target range is present versus just an unusually flat effort.
const brightenWeight = Math.max(0, Math.min(100, targetBrightenPct)) / 100;
const paceMainColor = hasPaceTarget ? brighten(paceColor, brightenWeight) : paceColor;
const hrMainColor = hasHRTarget ? brighten(heartRateColor, brightenWeight) : heartRateColor;
const singleEffortType = new Set(laps.map((l) => l.IntensityType)).size === 1;
const lapWindows = buildLapWindows(laps);
const lapWindows = buildLapWindows(laps, phaseColors);
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
const phaseSegments = buildPhaseSegments(lapWindows);
@@ -372,11 +420,38 @@ export function ExpectedVsActualChart({
}
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
// A light vertical line at every point the effort type changes (warm-up
// -> effort -> recovery -> ...), regardless of whether that type has a
// background color, so the workout's structure reads at a glance.
const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start);
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => phaseColors[t]))];
// A light vertical line at every lap boundary -- not just where the effort
// kind changes (warm-up -> effort -> recovery -> ...), but also between two
// consecutive laps of the *same* kind, e.g. the workout's own cool-down lap
// followed by an extra cool-down lap logged after the recording continued
// past the prescribed target (see buildLapWindows' isContinuation). Those
// two laps get merged into one shaded phase segment (same color, same
// fill), so without this line they'd otherwise read as a single lap.
const phaseBoundaries = lapWindows.slice(1).map((w) => w.start);
// One Area per phase segment instead of one for the whole chart, so the
// fill under the line can vary by effort kind (each segment gets its own
// color) while the stroke stays the metric's single main color throughout.
// The last segment is extended to elapsedMin (not just its own end) so a
// sample slightly past the laps' own total duration is still covered --
// otherwise the last sliver of the trace would be left unfilled.
const tintWeight = Math.max(0, Math.min(100, mainLineTintPct)) / 100;
const darkenWeight = Math.max(0, Math.min(100, backgroundDarkenPct)) / 100;
function segmentsWithPoints(mainColor: string) {
return phaseSegments
.map((seg, i) => {
const color = phaseColors[seg.intensityType];
if (!color) return null;
const isLast = i === phaseSegments.length - 1;
const segPoints = points.filter((p) => p.t >= seg.start && (isLast || p.t <= seg.end));
if (segPoints.length === 0) return null;
return { key: `${seg.intensityType}-${seg.start}`, points: segPoints, fill: mixColor(color, mainColor, tintWeight) };
})
.filter((s): s is { key: string; points: Point[]; fill: string } => s != null);
}
const paceSegments = segmentsWithPoints(paceMainColor);
const hrSegments = segmentsWithPoints(hrMainColor);
const paceDomain = robustDomain(
points.map((p) => p.actualPace),
@@ -396,22 +471,12 @@ export function ExpectedVsActualChart({
return (
<div className="expected-actual-charts">
{phasesPresent.length > 0 && (
<div className="phase-legend">
{phasesPresent.map((phase) => (
<span key={phase} className="phase-legend-item">
<span className="phase-legend-swatch" style={{ background: PHASE_COLORS[phase] }} />
{PHASE_LABELS[phase] ?? phase}
</span>
))}
</div>
)}
<div className="mini-chart">
<span className="mini-chart-label">Pace{hasPaceTarget ? " vs target" : ""}</span>
<span className="mini-chart-label">Pace</span>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{phaseBands.map((b, i) => (
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={darken(b.color, darkenWeight)} fillOpacity={0.55} strokeOpacity={0} />
))}
{phaseBoundaries.map((x, i) => (
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
@@ -422,17 +487,39 @@ export function ExpectedVsActualChart({
{hasPaceTarget && (
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
)}
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
<Line type="linear" dataKey="phaseAvgPace" stroke={PACE_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
{paceSegments.map((seg) => (
<Area
key={seg.key}
type="monotone"
data={seg.points}
dataKey="actualPace"
// The pace axis is reversed (faster/lower values drawn higher),
// which flips Recharts' default fill-to-dataMin behavior --
// without this, the fill paints above the line instead of
// below it, and the phase background ends up showing through
// underneath instead of above. dataMax is whatever renders at
// the bottom of a reversed axis, restoring "fill below the line".
baseValue="dataMax"
stroke={paceMainColor}
strokeWidth={2}
fill={seg.fill}
fillOpacity={0.85}
dot={false}
isAnimationActive={false}
name="Actual"
connectNulls
/>
))}
<Line type="linear" dataKey="phaseAvgPace" stroke={paceMainColor} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mini-chart">
<span className="mini-chart-label">HR{hasHRTarget ? " vs target" : ""}</span>
<span className="mini-chart-label">Heart Rate</span>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{phaseBands.map((b, i) => (
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={darken(b.color, darkenWeight)} fillOpacity={0.55} strokeOpacity={0} />
))}
{phaseBoundaries.map((x, i) => (
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
@@ -443,11 +530,36 @@ export function ExpectedVsActualChart({
{hasHRTarget && (
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
)}
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
<Line type="linear" dataKey="phaseAvgHR" stroke={HR_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
{hrSegments.map((seg) => (
<Area
key={seg.key}
type="monotone"
data={seg.points}
dataKey="actualHR"
stroke={hrMainColor}
strokeWidth={2}
fill={seg.fill}
fillOpacity={0.85}
dot={false}
isAnimationActive={false}
name="Actual"
connectNulls
/>
))}
<Line type="linear" dataKey="phaseAvgHR" stroke={hrMainColor} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
</div>
{phasesPresent.length > 0 && (
<div className="phase-legend">
{phasesPresent.map((phase) => (
<span key={phase} className="phase-legend-item">
<span className="phase-legend-swatch" style={{ background: phaseColors[phase] }} />
{PHASE_LABELS[phase] ?? phase}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -1,20 +1,68 @@
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatMinutesSeconds } from "../PaceField";
import type { ProgressionPoint } from "../../types/api";
const METRIC_LABELS: Record<string, string> = {
pace: "Pace (sec/km)",
pace: "Pace (m:ss/km)",
hr: "Avg HR (bpm)",
vo2max: "VO2max",
aerobic_te: "Aerobic Training Effect",
anaerobic_te: "Anaerobic Training Effect",
// Speed per heartbeat, scaled by 1000 for readability (the raw m/s-per-bpm
// ratio is a tiny fraction like 0.02, too small to compare at a glance).
efficiency_factor: "Efficiency Factor (×1000)",
};
function formatPaceShort(secPerKm: number): string {
return formatMinutesSeconds(secPerKm);
}
function formatMetricValue(metric: string, value: number): string {
if (metric === "pace") return `${formatPaceShort(value)}/km`;
// Efficiency factor only really matters at whole-number granularity (11
// vs. 14) -- a fractional value like 12.34 implies precision the ×1000
// scaling doesn't actually carry.
if (metric === "efficiency_factor") return String(Math.round(value));
return value.toFixed(1);
}
// Axis ticks specifically (not the tooltip): with a tight, non-round domain
// (see computeDomain) Recharts interpolates tick values directly between the
// domain's exact bounds instead of snapping to round numbers, so unrounded
// ticks come out as e.g. "15.138246239532403" -- technically correct but
// unreadable, and easy to misread as a much bigger number than it is.
function formatAxisTick(metric: string, value: number): string {
if (metric === "pace") return formatPaceShort(value);
if (metric === "efficiency_factor") return String(Math.round(value));
return value.toFixed(1);
}
// Recharts' YAxis defaults to a [0, auto] domain, which for a metric like HR
// or pace makes every real activity's variation collapse into a sliver near
// the top of the chart (starting a heart-rate axis at 0bpm, for example).
// Padding tightly around the actual data range instead makes real
// progression visible. Progression series are small (one point per
// activity/week), so plain min/max is enough -- no need for the percentile
// trimming the per-second chart in ExpectedVsActualChart.tsx uses.
function computeDomain(values: number[]): [number, number] {
if (values.length === 0) return [0, 1];
const lo = Math.min(...values);
const hi = Math.max(...values);
if (lo === hi) {
const pad = Math.abs(lo) * 0.1 || 1;
return [lo - pad, hi + pad];
}
const pad = (hi - lo) * 0.15;
return [lo - pad, hi + pad];
}
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
if (points.length === 0) {
return <p className="empty-state">No data yet for this metric.</p>;
}
const data = points.map((p) => ({ date: p.date.slice(0, 10), value: p.value }));
const domain = computeDomain(data.map((d) => d.value));
return (
<ResponsiveContainer width="100%" height={320}>
@@ -23,11 +71,17 @@ export function ProgressionChart({ points, metric }: { points: ProgressionPoint[
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis
tick={{ fontSize: 12 }}
// Reversed so a *faster* (numerically lower) pace still reads as
// "better, plots higher" -- the same up-is-better convention every
// other metric here already has for free (higher HR/VO2max/etc.
// just happens to mean "more", not "worse", but pace is inverted).
reversed={metric === "pace"}
domain={domain}
tickFormatter={(v) => formatAxisTick(metric, Number(v))}
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [Number(value).toFixed(1), METRIC_LABELS[metric] ?? metric]}
formatter={(value) => [formatMetricValue(metric, Number(value)), METRIC_LABELS[metric] ?? metric]}
contentStyle={{ background: "#1a1d24", border: "1px solid #2a2d35", borderRadius: 6 }}
labelStyle={{ color: "#9aa0ab" }}
itemStyle={{ color: "#e6e6e6" }}

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

View File

@@ -5,33 +5,33 @@
export interface Activity {
ID: number;
GarminActivityID: number;
// ActivityName/ActivityType aren't stored columns -- the backend decodes
// them from RawJSON at response time (see internal/api/display_fields.go).
ActivityName: string;
ActivityType: string;
EventTypeKey: string;
WorkoutID: number | null;
StartTimeUTC: string;
BeginTimestampMs: number;
DurationSeconds: number;
DistanceMeters: number;
AvgHR: number | null;
MaxHR: number | null;
AvgSpeedMps: number | null;
MaxSpeedMps: number | null;
ElevationGainM: number | null;
ElevationLossM: number | null;
Calories: number | null;
LapCount: number;
AerobicTrainingEffect: number | null;
AnaerobicTrainingEffect: number | null;
TrainingEffectLabel: string;
VO2MaxValue: number | null;
// Raw JSON strings from Garmin, kept for fields not modeled above. Only
// needed by the Review Queue's raw-data viewer, so left as strings here
// needed by the Activities page's raw-data viewer, so left as strings here
// rather than typed -- the viewer parses them for display.
RawJSON: string;
DetailsFetchedAt: string | null;
DetailsRawJSON: string | null;
SplitsFetchedAt: string | null;
// Genuine raw get_workout_by_id() response for this activity's structured
// workout. Null when the activity has no WorkoutID, or was synced before
// this column existed.
WorkoutRawJSON: string | null;
CreatedAt: string;
UpdatedAt: string;
}
@@ -40,15 +40,11 @@ export interface Lap {
ID: number;
ActivityID: number;
LapIndex: number;
StartTimeUTC: string;
// DurationSeconds/AvgHR aren't stored columns -- the backend decodes them
// from RawJSON at response time (see internal/api/display_fields.go).
DurationSeconds: number;
DistanceMeters: number;
AvgHR: number | null;
MaxHR: number | null;
AvgSpeedMps: number | null;
MaxSpeedMps: number | null;
ElevationGainM: number | null;
ElevationLossM: number | null;
IntensityType: string;
HRDriftBpmPerMin: number | null;
HRRecoveryBpmPerMin: number | null;
@@ -91,10 +87,12 @@ export interface WorkoutKind {
UpdatedAt: string;
pace_min_sec_per_km: number | null;
pace_max_sec_per_km: number | null;
expected_hr_zone: number | null;
hr_min_pct_hrr: number | null;
hr_max_pct_hrr: number | null;
}
export interface Profile {
Name: string;
GarminEmail: string;
GarminPassword: string;
RollingWindowDays: number;
@@ -115,6 +113,25 @@ export interface Profile {
CooldownMinutes: number;
MinRepresentativePaceSecPerKm: number;
MinRepresentativeTimeSeconds: number;
// Chart colors: PaceColor/HeartRateColor are each chart's "main line"
// color; Warmup/Effort/Recovery/CooldownColor are the "effort kind"
// colors used to derive the under-the-line fill and phase background
// (see ExpectedVsActualChart).
PaceColor: string;
HeartRateColor: string;
WarmupColor: string;
EffortColor: string;
RecoveryColor: string;
CooldownColor: string;
// How strongly (0-100) the main line color mixes into the effort-kind
// fill under the line. Never affects the phase background above the line.
MainLineTintPct: number;
// How strongly (0-100) the effort-kind color is darkened for the phase
// background above the line. Never mixed with the main line color.
BackgroundDarkenPct: number;
// How strongly (0-100) a chart's main line color is brightened when that
// chart has a structured-workout target range to show.
TargetBrightenPct: number;
CreatedAt: string;
UpdatedAt: string;
}
@@ -181,4 +198,4 @@ export interface SyncStatus {
last_run?: SyncRun;
}
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te";
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor";