From 513418123f26bbc31d2282274c5c20b42f6601e6 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 17:38:50 +0200 Subject: [PATCH] Add configurable pace-artifact filtering to replace the hardcoded cutoff Two new Profile settings -- "minimum representative pace" and "minimum representative time" -- replace the previous hardcoded 20:00/km cutoff. A stretch of consecutive samples slower than the configured pace is now dropped from the chart (and its Y-axis scale) only if it lasts no longer than the configured time; a longer stretch is kept as a real stop or walk break rather than noise. Defaults to 12:00/km and 3 seconds. Caught a boundary bug while verifying against real data: a run lasting exactly the threshold duration survived filtering because the comparison used strict "<" instead of "<=", contradicting "not lasting more than N seconds" (which should include exactly N). --- .../migrations/0010_pace_artifact_filter.sql | 9 +++ backend/internal/store/profile.go | 13 ++++ backend/internal/store/profile_test.go | 8 +++ .../charts/ExpectedVsActualChart.tsx | 66 ++++++++++++++--- frontend/src/pages/Profile.tsx | 71 +++++++++++++++++++ frontend/src/pages/ReviewQueue.tsx | 15 ++-- frontend/src/types/api.ts | 2 + 7 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 backend/internal/store/migrations/0010_pace_artifact_filter.sql diff --git a/backend/internal/store/migrations/0010_pace_artifact_filter.sql b/backend/internal/store/migrations/0010_pace_artifact_filter.sql new file mode 100644 index 0000000..517647d --- /dev/null +++ b/backend/internal/store/migrations/0010_pace_artifact_filter.sql @@ -0,0 +1,9 @@ +-- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as +-- recording starts, before the run itself begins) out of the Review +-- Queue's pace chart: a stretch of samples slower than +-- min_representative_pace_sec_per_km is dropped unless it persists for at +-- least min_representative_time_seconds, in which case it's treated as a +-- real stop or walk break, not noise. Defaults match the values used to +-- design this feature (12:00/km, 3 seconds). +ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720; +ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3; diff --git a/backend/internal/store/profile.go b/backend/internal/store/profile.go index 89d9ffd..cec892a 100644 --- a/backend/internal/store/profile.go +++ b/backend/internal/store/profile.go @@ -32,6 +32,15 @@ type Profile struct { // don't use these. WarmupMinutes, CooldownMinutes float64 + // MinRepresentativePaceSecPerKm/MinRepresentativeTimeSeconds drive the + // Review Queue pace chart's artifact filter: a stretch of samples slower + // than MinRepresentativePaceSecPerKm is dropped from the chart (and its + // Y-axis scale) unless it persists for at least + // MinRepresentativeTimeSeconds, in which case it's treated as a real + // stop or walk break rather than noise (e.g. GPS/motion still settling + // right as recording starts, before the run itself begins). + MinRepresentativePaceSecPerKm, MinRepresentativeTimeSeconds float64 + CreatedAt, UpdatedAt string } @@ -41,6 +50,7 @@ const profileColumns = ` hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, hr_zone5_min_pct, hr_zone5_max_pct, warmup_minutes, cooldown_minutes, + min_representative_pace_sec_per_km, min_representative_time_seconds, created_at, updated_at ` @@ -53,6 +63,7 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) { &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct, &p.WarmupMinutes, &p.CooldownMinutes, + &p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { @@ -72,6 +83,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, hr_zone5_min_pct=?, hr_zone5_max_pct=?, warmup_minutes=?, cooldown_minutes=?, + min_representative_pace_sec_per_km=?, min_representative_time_seconds=?, updated_at=datetime('now') WHERE id = 1`, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, @@ -79,6 +91,7 @@ func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone5MinPct, p.HRZone5MaxPct, p.WarmupMinutes, p.CooldownMinutes, + p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds, ) if err != nil { return fmt.Errorf("update profile: %w", err) diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go index 2433e7a..68e7628 100644 --- a/backend/internal/store/profile_test.go +++ b/backend/internal/store/profile_test.go @@ -25,6 +25,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { if p.BackfillHorizonDays != 1095 { t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays) } + if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 { + t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p) + } maxHR, restingHR := 190.0, 50.0 p.GarminEmail = "runner@example.com" @@ -34,6 +37,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { p.RestingHeartRate = &restingHR p.WarmupMinutes = 8 p.BackfillHorizonDays = 14 + p.MinRepresentativePaceSecPerKm = 600 + p.MinRepresentativeTimeSeconds = 5 if err := db.UpdateProfile(ctx, p); err != nil { t.Fatalf("UpdateProfile: %v", err) @@ -55,4 +60,7 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) { if got.BackfillHorizonDays != 14 { t.Errorf("BackfillHorizonDays = %v, want 14", got.BackfillHorizonDays) } + if got.MinRepresentativePaceSecPerKm != 600 || got.MinRepresentativeTimeSeconds != 5 { + t.Errorf("pace artifact filter after update = %+v, want pace=600, time=5", got) + } } diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index be1cd31..fb8e9a9 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -1,17 +1,46 @@ import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { Lap, Sample } from "../../types/api"; -// Speeds below this read as an implausibly slow "pace" (20:00/km is well -// past even a very slow walk) -- in practice these come from GPS/motion -// still settling right as recording starts, before the run itself begins, -// not a real pace. Treating them as unknown keeps that artifact out of both -// the trace and the axis scale it would otherwise blow out. -const MAX_PLAUSIBLE_PACE_SEC_PER_KM = 1200; - function paceSecPerKm(mps: number | null): number | null { if (mps == null || mps <= 0) return null; - const pace = 1000 / mps; - return pace <= MAX_PLAUSIBLE_PACE_SEC_PER_KM ? pace : null; + return 1000 / mps; +} + +// Drops brief pace "artifacts" -- e.g. GPS/motion still settling right as +// recording starts, before the run itself begins -- without hiding a real +// stop or walk break. A stretch of consecutive points slower than +// minRepresentativePaceSecPerKm (including points with no pace at all, +// i.e. truly stationary) is nulled out unless it lasts at least +// minRepresentativeTimeSeconds, in which case it's kept as-is: long enough +// to be a real part of the run, not noise. A pace threshold of 0 (or below) +// disables filtering entirely. +function filterPaceArtifacts( + points: Array<{ t: number; pace: number | null }>, + minRepresentativePaceSecPerKm: number, + minRepresentativeTimeSeconds: number, +): Array { + const result: Array = points.map((p) => p.pace); + if (minRepresentativePaceSecPerKm <= 0) return result; + + const isSlowOrStopped = (pace: number | null) => pace == null || pace > minRepresentativePaceSecPerKm; + + let i = 0; + while (i < points.length) { + if (!isSlowOrStopped(points[i].pace)) { + i++; + continue; + } + let j = i; + while (j < points.length && isSlowOrStopped(points[j].pace)) j++; + // "Not lasting more than" the threshold means a run exactly at the + // threshold still counts as an artifact, hence <= rather than <. + const durationSeconds = (points[j - 1].t - points[i].t) * 60; + if (durationSeconds <= minRepresentativeTimeSeconds) { + for (let k = i; k < j; k++) result[k] = null; + } + i = j; + } + return result; } // Rounds the total seconds first, then splits into minutes/seconds -- doing @@ -263,7 +292,17 @@ function weightedMean(pairs: Array<[number | null, number]>): number | null { // would hide real within-lap variation (a single-lap hill repeat, for // example, would otherwise render as a dead-flat line despite the pace/HR // swinging throughout it). -export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: Sample[] }) { +export function ExpectedVsActualChart({ + laps, + samples, + minRepresentativePaceSecPerKm, + minRepresentativeTimeSeconds, +}: { + laps: Lap[]; + samples: Sample[]; + minRepresentativePaceSecPerKm: number; + minRepresentativeTimeSeconds: number; +}) { if (laps.length === 0) return null; const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null); @@ -313,6 +352,13 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: elapsedMin = lapTotalMin; } + const filteredActualPace = filterPaceArtifacts( + points.map((p) => ({ t: p.t, pace: p.actualPace })), + minRepresentativePaceSecPerKm, + minRepresentativeTimeSeconds, + ); + points = points.map((p, i) => ({ ...p, actualPace: filteredActualPace[i] })); + if (singleEffortType) { // The whole workout is one undifferentiated effort -- draw one flat // average line across the entire span instead of per-lap segments. diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index ab9fa08..02ef3ae 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -26,6 +26,56 @@ function NumberField({ ); } +// Pace is entered/displayed as "m:ss" but stored as whole seconds. +function parsePace(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + const match = /^(\d+):([0-5]?\d)$/.exec(trimmed); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +function formatPace(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function PaceField({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (v: number) => void; +}) { + const [text, setText] = useState(formatPace(value)); + useEffect(() => setText(formatPace(value)), [value]); + + function commit(raw: string) { + const parsed = parsePace(raw); + if (parsed != null) { + onChange(parsed); + } else { + setText(formatPace(value)); // invalid input -- revert to the last valid value + } + } + + return ( + + ); +} + function NullableNumberField({ label, value, @@ -178,6 +228,27 @@ export function Profile() {

+
+ Pace chart artifacts +
+ set("MinRepresentativePaceSecPerKm", v)} + /> + set("MinRepresentativeTimeSeconds", v)} + /> +
+

+ A stretch of samples slower than this pace is hidden from the Review Queue's pace chart (and doesn't stretch + its scale) unless it lasts at least this long -- e.g. a brief GPS blip right as recording starts gets + dropped, but a real walk break or stop is kept. +

+
+ ); diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index a10ccdf..10f92ac 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { api } from "../api/client"; import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; import { RawDataModal } from "../components/RawDataModal"; -import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api"; +import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api"; const UNSORTED = "__unsorted__"; const PAGE_SIZE = 10; @@ -30,6 +30,7 @@ export function ReviewQueue() { const [initialLoading, setInitialLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [kinds, setKinds] = useState([]); + const [profile, setProfile] = useState(null); const [filterKindId, setFilterKindId] = useState(""); const [error, setError] = useState(null); const [resolvingId, setResolvingId] = useState(null); @@ -89,13 +90,14 @@ export function ReviewQueue() { function reload() { setItems([]); setInitialLoading(true); - Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds()]) - .then(([page, workoutKinds]) => { + Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds(), api.getProfile()]) + .then(([page, workoutKinds, userProfile]) => { nextCursorRef.current = page.next_cursor; setItems(page.items); setNextCursor(page.next_cursor); setTotal(page.total); setKinds(workoutKinds); + setProfile(userProfile); }) .catch((e) => setError(String(e))) .finally(() => setInitialLoading(false)); @@ -217,7 +219,12 @@ export function ReviewQueue() {

)} - +
{manuallyAssignableKinds.map((k) => ( diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index be435f4..13a45f3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -113,6 +113,8 @@ export interface Profile { HRZone5MaxPct: number; WarmupMinutes: number; CooldownMinutes: number; + MinRepresentativePaceSecPerKm: number; + MinRepresentativeTimeSeconds: number; CreatedAt: string; UpdatedAt: string; }