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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user