feat: review queue polish (pace, sort, type filter), drop unused phase-detection UI

Review Queue: show pace alongside distance/duration/HR, sort by activity
date (most recent first) instead of classification timestamp, and add a
filter by workout kind with a special "Unsorted" option for runs where
the rule engine found zero candidates at all (distinct from ambiguous
multi-candidate runs).

Profile: remove the phase-detection warm-up/cool-down section from the
UI -- not used by anything yet (phase segmentation is future work) and
was adding noise. The underlying fields are untouched so no data is lost
and the settings screen still round-trips them on save.
This commit is contained in:
2026-07-18 08:39:23 +02:00
parent 75d8a8dd0d
commit 7f4877df57
3 changed files with 51 additions and 32 deletions

View File

@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
@@ -33,6 +34,13 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
items = append(items, item{KindAssignment: a, Activity: activity})
}
// Most recent run first, by when the activity actually happened (not
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(items, func(i, j int) bool {
return items[i].Activity.StartTimeUTC > items[j].Activity.StartTimeUTC
})
writeJSON(w, http.StatusOK, items)
}

View File

@@ -146,36 +146,6 @@ export function Profile() {
))}
</fieldset>
<fieldset className="kind-editor">
<legend>Phase detection (warm-up / cool-down minutes)</legend>
{(
[
["Easy", "EasyWarmupMinutes", "EasyCooldownMinutes"],
["Long", "LongWarmupMinutes", "LongCooldownMinutes"],
["Tempo", "TempoWarmupMinutes", "TempoCooldownMinutes"],
["Threshold 30'", "Threshold30WarmupMinutes", "Threshold30CooldownMinutes"],
["Threshold 60'", "Threshold60WarmupMinutes", "Threshold60CooldownMinutes"],
["MAS Test", "MASTestWarmupMinutes", "MASTestCooldownMinutes"],
] as const
).map(([label, warmupKey, cooldownKey]) => (
<div key={label} className="controls">
<NumberField
label={`${label} warm-up`}
value={profile[warmupKey]}
onChange={(v) => set(warmupKey, v)}
/>
<NumberField
label={`${label} cool-down`}
value={profile[cooldownKey]}
onChange={(v) => set(cooldownKey, v)}
/>
</div>
))}
<p className="empty-state">
Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings.
</p>
</fieldset>
<button onClick={save}>Save</button>
</div>
);

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "../api/client";
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
const UNSORTED = "__unsorted__";
function candidates(item: ReviewQueueItem): ScoredKind[] {
try {
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
@@ -10,9 +12,18 @@ 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`;
}
export function ReviewQueue() {
const [items, setItems] = useState<ReviewQueueItem[]>([]);
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
const [filterKindId, setFilterKindId] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [resolvingId, setResolvingId] = useState<number | null>(null);
@@ -39,17 +50,46 @@ export function ReviewQueue() {
}
}
const filteredItems = useMemo(() => {
if (filterKindId === "") return items;
if (filterKindId === UNSORTED) {
return items.filter((item) => candidates(item).length === 0);
}
const id = Number(filterKindId);
return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
}, [items, filterKindId]);
return (
<div className="page">
<h2>Review Queue</h2>
{error && <p className="error">{error}</p>}
{items.length > 0 && (
<div className="controls">
<label>
Filter
<select value={filterKindId} onChange={(e) => setFilterKindId(e.target.value)}>
<option value="">All ({items.length})</option>
<option value={UNSORTED}>Unsorted (no candidates)</option>
{kinds.map((k) => (
<option key={k.ID} value={k.ID}>
{k.Name}
</option>
))}
</select>
</label>
</div>
)}
{items.length === 0 ? (
<p className="empty-state">Nothing needs review right now.</p>
) : filteredItems.length === 0 ? (
<p className="empty-state">No runs match this filter.</p>
) : (
<ul className="review-list">
{items.map((item) => {
{filteredItems.map((item) => {
const scored = candidates(item);
const pace = formatPace(item.activity.AvgSpeedMps);
return (
<li key={item.ActivityID} className="review-item">
<div className="review-item-header">
@@ -59,6 +99,7 @@ export function ReviewQueue() {
<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>}
</div>