Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
86
frontend/src/pages/Dashboard.tsx
Normal file
86
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ProgressionChart } from "../components/charts/ProgressionChart";
|
||||
import type { ProgressionMetric, ProgressionPoint, WorkoutKind } from "../types/api";
|
||||
|
||||
const METRICS: { key: ProgressionMetric; label: string }[] = [
|
||||
{ key: "pace", label: "Pace" },
|
||||
{ key: "hr", label: "Heart Rate" },
|
||||
{ key: "vo2max", label: "VO2max" },
|
||||
{ key: "aerobic_te", label: "Aerobic Training Effect" },
|
||||
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
|
||||
];
|
||||
|
||||
export function Dashboard() {
|
||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||
const [selectedKindId, setSelectedKindId] = useState<number | null>(null);
|
||||
const [metric, setMetric] = useState<ProgressionMetric>("pace");
|
||||
const [points, setPoints] = useState<ProgressionPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listWorkoutKinds()
|
||||
.then((k) => {
|
||||
setKinds(k);
|
||||
if (k.length > 0) setSelectedKindId(k[0].ID);
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedKindId == null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.progression(selectedKindId, metric)
|
||||
.then(setPoints)
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedKindId, metric]);
|
||||
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="controls">
|
||||
<label>
|
||||
Workout kind
|
||||
<select
|
||||
value={selectedKindId ?? ""}
|
||||
onChange={(e) => setSelectedKindId(Number(e.target.value))}
|
||||
>
|
||||
{kinds.map((k) => (
|
||||
<option key={k.ID} value={k.ID}>
|
||||
{k.Name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Metric
|
||||
<select value={metric} onChange={(e) => setMetric(e.target.value as ProgressionMetric)}>
|
||||
{METRICS.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
{loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/ReviewQueue.tsx
Normal file
89
frontend/src/pages/ReviewQueue.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
|
||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||
try {
|
||||
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function ReviewQueue() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
|
||||
.then(([reviewItems, workoutKinds]) => {
|
||||
setItems(reviewItems);
|
||||
setKinds(workoutKinds);
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
}
|
||||
|
||||
useEffect(reload, []);
|
||||
|
||||
async function resolve(activityId: number, kindId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.resolveReview(activityId, kindId);
|
||||
setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setResolvingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Review Queue</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="empty-state">Nothing needs review right now.</p>
|
||||
) : (
|
||||
<ul className="review-list">
|
||||
{items.map((item) => {
|
||||
const scored = candidates(item);
|
||||
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>
|
||||
<div className="review-item-stats">
|
||||
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||
<span>{Math.round(item.activity.DurationSeconds / 60)} min</span>
|
||||
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||
</div>
|
||||
|
||||
{scored.length > 0 && (
|
||||
<p className="candidates">
|
||||
Rule engine candidates: {scored.map((c) => `${c.name} (${c.score.toFixed(2)})`).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="review-item-actions">
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
key={k.ID}
|
||||
disabled={resolvingId === item.ActivityID}
|
||||
onClick={() => resolve(item.ActivityID, k.ID)}
|
||||
>
|
||||
{k.Name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/pages/WorkoutKinds.tsx
Normal file
148
frontend/src/pages/WorkoutKinds.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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 }
|
||||
]
|
||||
}`;
|
||||
|
||||
export function WorkoutKinds() {
|
||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||
const [editingId, setEditingId] = useState<number | "new" | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||
}
|
||||
|
||||
useEffect(reload, []);
|
||||
|
||||
function startCreate() {
|
||||
setEditingId("new");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setRuleText(EXAMPLE_RULE);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function startEdit(k: WorkoutKind) {
|
||||
setEditingId(k.ID);
|
||||
setName(k.Name);
|
||||
setDescription(k.Description);
|
||||
setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2));
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
let rule: unknown;
|
||||
try {
|
||||
rule = JSON.parse(ruleText);
|
||||
} catch {
|
||||
setError("Rule is not valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId === "new") {
|
||||
await api.createWorkoutKind({ name, description, rule });
|
||||
} else if (typeof editingId === "number") {
|
||||
await api.updateWorkoutKind(editingId, { name, description, rule });
|
||||
}
|
||||
setEditingId(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await api.deleteWorkoutKind(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function reclassify(id: number) {
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await api.reclassifyWorkoutKind(id);
|
||||
setError(`Reclassified ${res.reclassified} activities.`);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Workout Kinds</h2>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
<table className="kinds-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kinds.map((k) => (
|
||||
<tr key={k.ID}>
|
||||
<td>{k.Name}</td>
|
||||
<td>{k.Description}</td>
|
||||
<td>{k.IsActive ? "yes" : "no"}</td>
|
||||
<td className="kinds-table-actions">
|
||||
<button onClick={() => startEdit(k)}>Edit</button>
|
||||
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
|
||||
Reclassify
|
||||
</button>
|
||||
<button disabled={busyId === k.ID} onClick={() => remove(k.ID)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{editingId === null ? (
|
||||
<button onClick={startCreate}>+ New workout kind</button>
|
||||
) : (
|
||||
<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>
|
||||
<label>
|
||||
Rule (JSON condition tree)
|
||||
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />
|
||||
</label>
|
||||
<div className="kind-editor-actions">
|
||||
<button onClick={save}>Save</button>
|
||||
<button onClick={() => setEditingId(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user