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).
This commit is contained in:
@@ -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 (
|
||||
<label>
|
||||
{label}
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
placeholder="12:00"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NullableNumberField({
|
||||
label,
|
||||
value,
|
||||
@@ -178,6 +228,27 @@ export function Profile() {
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="kind-editor">
|
||||
<legend>Pace chart artifacts</legend>
|
||||
<div className="controls">
|
||||
<PaceField
|
||||
label="Minimum representative pace (m:ss/km)"
|
||||
value={profile.MinRepresentativePaceSecPerKm}
|
||||
onChange={(v) => set("MinRepresentativePaceSecPerKm", v)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum representative time (seconds)"
|
||||
value={profile.MinRepresentativeTimeSeconds}
|
||||
onChange={(v) => set("MinRepresentativeTimeSeconds", v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="empty-state">
|
||||
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.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<button onClick={save}>Save</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<WorkoutKind[]>([]);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(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() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
|
||||
<ExpectedVsActualChart
|
||||
laps={item.laps}
|
||||
samples={item.samples}
|
||||
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
|
||||
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
|
||||
/>
|
||||
|
||||
<div className="review-item-actions">
|
||||
{manuallyAssignableKinds.map((k) => (
|
||||
|
||||
Reference in New Issue
Block a user