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:
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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})
|
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)
|
writeJSON(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,36 +146,6 @@ export function Profile() {
|
|||||||
))}
|
))}
|
||||||
</fieldset>
|
</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>
|
<button onClick={save}>Save</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
|
const UNSORTED = "__unsorted__";
|
||||||
|
|
||||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
|
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() {
|
export function ReviewQueue() {
|
||||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [resolvingId, setResolvingId] = useState<number | 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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Review Queue</h2>
|
<h2>Review Queue</h2>
|
||||||
{error && <p className="error">{error}</p>}
|
{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 ? (
|
{items.length === 0 ? (
|
||||||
<p className="empty-state">Nothing needs review right now.</p>
|
<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">
|
<ul className="review-list">
|
||||||
{items.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const scored = candidates(item);
|
const scored = candidates(item);
|
||||||
|
const pace = formatPace(item.activity.AvgSpeedMps);
|
||||||
return (
|
return (
|
||||||
<li key={item.ActivityID} className="review-item">
|
<li key={item.ActivityID} className="review-item">
|
||||||
<div className="review-item-header">
|
<div className="review-item-header">
|
||||||
@@ -59,6 +99,7 @@ export function ReviewQueue() {
|
|||||||
<div className="review-item-stats">
|
<div className="review-item-stats">
|
||||||
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||||
<span>{Math.round(item.activity.DurationSeconds / 60)} min</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>}
|
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user