Replace per-kind reclassify with a single global reclassify that respects manual and Race locks
Manual assignments are the user's definitive word and Race assignments come from a hard Garmin fact (eventType.typeKey), not a retunable rule -- neither is ever touched by reclassify again, and Race can no longer be set by hand via the review queue. Adds an Activities page so this locked/unlocked status is visible per workout, since Review Queue only ever showed unresolved items.
This commit is contained in:
@@ -216,6 +216,16 @@ button:disabled {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.lock-badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9aa0ab;
|
||||
border: 1px solid #2a2d35;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.kinds-table th,
|
||||
.kinds-table td {
|
||||
text-align: left;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import { GarminConnection } from "./components/GarminConnection";
|
||||
import { Activities } from "./pages/Activities";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Profile } from "./pages/Profile";
|
||||
import { ReviewQueue } from "./pages/ReviewQueue";
|
||||
@@ -9,6 +10,7 @@ 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 },
|
||||
] as const;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Activity,
|
||||
ActivityListItem,
|
||||
AuthResponse,
|
||||
KindAssignment,
|
||||
Profile,
|
||||
@@ -46,7 +47,7 @@ export const api = {
|
||||
if (params?.to) q.set("to", params.to);
|
||||
if (params?.limit) q.set("limit", String(params.limit));
|
||||
const qs = q.toString();
|
||||
return request<Activity[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
return request<ActivityListItem[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getActivity: (id: number) =>
|
||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||
@@ -68,8 +69,10 @@ export const api = {
|
||||
expected_hr_zone?: number | null;
|
||||
},
|
||||
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||
reclassifyWorkoutKind: (id: number) =>
|
||||
request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }),
|
||||
|
||||
// Reclassifies every activity that isn't locked (manual assignments and
|
||||
// Race assignments are never touched -- see handleReclassifyAll).
|
||||
reclassifyAll: () => request<{ reclassified: number }>("/api/reclassify", { method: "POST" }),
|
||||
|
||||
// Profile
|
||||
getProfile: () => request<Profile>("/api/profile"),
|
||||
|
||||
73
frontend/src/pages/Activities.tsx
Normal file
73
frontend/src/pages/Activities.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,10 @@ export function ReviewQueue() {
|
||||
setFilterKindId((prev) => (prev === id ? "" : id));
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -126,7 +130,7 @@ export function ReviewQueue() {
|
||||
)}
|
||||
|
||||
<div className="review-item-actions">
|
||||
{kinds.map((k) => (
|
||||
{manuallyAssignableKinds.map((k) => (
|
||||
<button
|
||||
key={k.ID}
|
||||
disabled={resolvingId === item.ActivityID}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WorkoutKinds() {
|
||||
const [paceMaxText, setPaceMaxText] = useState("");
|
||||
const [expectedZone, setExpectedZone] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [reclassifying, setReclassifying] = useState(false);
|
||||
|
||||
function reload() {
|
||||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||
@@ -93,15 +93,15 @@ export function WorkoutKinds() {
|
||||
}
|
||||
}
|
||||
|
||||
async function reclassify(id: number) {
|
||||
setBusyId(id);
|
||||
async function reclassifyAll() {
|
||||
setReclassifying(true);
|
||||
try {
|
||||
const res = await api.reclassifyWorkoutKind(id);
|
||||
const res = await api.reclassifyAll();
|
||||
setError(`Reclassified ${res.reclassified} activities.`);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
setReclassifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ export function WorkoutKinds() {
|
||||
<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>
|
||||
@@ -133,9 +143,6 @@ export function WorkoutKinds() {
|
||||
<td>{k.expected_hr_zone ?? "—"}</td>
|
||||
<td className="kinds-table-actions">
|
||||
<button onClick={() => startEdit(k)}>Edit</button>
|
||||
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
|
||||
Reclassify
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -47,6 +47,14 @@ export interface Lap {
|
||||
HRRecoveryBpmPerMin: number | null;
|
||||
}
|
||||
|
||||
export interface ActivityListItem extends Activity {
|
||||
workout_kind_id: number | null;
|
||||
workout_kind_name: string | null;
|
||||
assignment_source: "rule_engine" | "manual" | null;
|
||||
assignment_status: "assigned" | "needs_review" | null;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface WorkoutKind {
|
||||
ID: number;
|
||||
Name: string;
|
||||
|
||||
Reference in New Issue
Block a user