Implements the activity-stats change (specs/activity-stats): - gpx: `movingTime(tracks)` — moving seconds from trackpoint timestamps, excluding stationary spans + long gaps; null when no timestamps. - stats.ts: pure formatters (distance/elevation/duration/speed/pace), sport-aware `deriveRate` (pace for foot sports, speed otherwise, speed default), and `activityStatItems` encoding the canonical order (distance · time · [moving] · pace/speed · ascent · descent; compact subset). - StatRow: one shared presentational component (size sm/lg), adopted on the activity detail (full set; loader derives moving time), route detail (distance/ascent/descent), feed card + profile list (compact). - i18n: journal.stats.* in en + de. Tests: movingTime (stationary/gap exclusion, moving ≤ elapsed), deriveRate, formatters, activityStatItems ordering + compact, StatRow render (jsdom). typecheck + lint + unit (gpx 63, journal 311) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 lines
1.9 KiB
TypeScript
46 lines
1.9 KiB
TypeScript
import type { TrackPoint } from "./types.ts";
|
|
|
|
// Below this speed we treat the athlete as stopped (traffic lights, photo
|
|
// stops, regroups). 0.5 m/s ≈ 1.8 km/h — slower than a slow walk.
|
|
const STATIONARY_SPEED_MS = 0.5;
|
|
// Intervals longer than this are pauses or GPS gaps, not continuous motion;
|
|
// they're excluded entirely so a lunch break doesn't inflate moving time.
|
|
const MAX_GAP_S = 60;
|
|
|
|
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
const R = 6371000;
|
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLon = toRad(lon2 - lon1);
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
/**
|
|
* Moving time in seconds, derived from trackpoint timestamps: the sum of
|
|
* inter-point intervals during which the athlete was actually moving
|
|
* (excluding stationary spans and long gaps). Returns `null` when the track
|
|
* has no usable timestamps (planned routes, or recordings without time), so
|
|
* callers can fall back to elapsed time. Moving time is always ≤ elapsed.
|
|
*/
|
|
export function movingTime(tracks: TrackPoint[][]): number | null {
|
|
let movingSeconds = 0;
|
|
let timedIntervals = 0;
|
|
|
|
for (const seg of tracks) {
|
|
for (let i = 1; i < seg.length; i++) {
|
|
const prev = seg[i - 1];
|
|
const cur = seg[i];
|
|
if (!prev || !cur || !prev.time || !cur.time) continue;
|
|
const dt = (Date.parse(cur.time) - Date.parse(prev.time)) / 1000;
|
|
if (!Number.isFinite(dt) || dt <= 0 || dt > MAX_GAP_S) continue;
|
|
timedIntervals++;
|
|
const speed = haversineMeters(prev.lat, prev.lon, cur.lat, cur.lon) / dt;
|
|
if (speed >= STATIONARY_SPEED_MS) movingSeconds += dt;
|
|
}
|
|
}
|
|
|
|
return timedIntervals > 0 ? Math.round(movingSeconds) : null;
|
|
}
|