diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go index 28347ca..a054cb2 100644 --- a/backend/internal/api/review.go +++ b/backend/internal/api/review.go @@ -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) } diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index ff6cb29..0826f74 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -146,36 +146,6 @@ export function Profile() { ))} -
- Phase detection (warm-up / cool-down minutes) - {( - [ - ["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]) => ( -
- set(warmupKey, v)} - /> - set(cooldownKey, v)} - /> -
- ))} -

- Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings. -

-
- ); diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 6c54fe4..a4fc823 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -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([]); const [kinds, setKinds] = useState([]); + const [filterKindId, setFilterKindId] = useState(""); const [error, setError] = useState(null); const [resolvingId, setResolvingId] = useState(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 (

Review Queue

{error &&

{error}

} + {items.length > 0 && ( +
+ +
+ )} + {items.length === 0 ? (

Nothing needs review right now.

+ ) : filteredItems.length === 0 ? ( +

No runs match this filter.

) : (
    - {items.map((item) => { + {filteredItems.map((item) => { const scored = candidates(item); + const pace = formatPace(item.activity.AvgSpeedMps); return (
  • @@ -59,6 +99,7 @@ export function ReviewQueue() {
    {(item.activity.DistanceMeters / 1000).toFixed(2)} km {Math.round(item.activity.DurationSeconds / 60)} min + {pace && {pace}} {item.activity.AvgHR != null && {Math.round(item.activity.AvgHR)} bpm avg}