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() {