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