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:
2026-07-19 11:11:50 +02:00
parent 8ff3d62b2f
commit 0610897541
15 changed files with 398 additions and 60 deletions

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

View File

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

View File

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