trails/packages/gpx/src/moving-time.ts
Ullrich Schäfer 0325d01bca
activity-stats: shared StatRow + canonical metrics across surfaces
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>
2026-06-12 14:48:36 +02:00

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;
}