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>
This commit is contained in:
Ullrich Schäfer 2026-06-12 14:48:36 +02:00
parent ede263d4d1
commit 0325d01bca
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 482 additions and 67 deletions

View file

@ -3,4 +3,5 @@ export { generateGpx } from "./generate.ts";
export { extractWaypoints } from "./waypoints.ts";
export { computeDays } from "./compute-days.ts";
export type { DayStage } from "./compute-days.ts";
export { movingTime } from "./moving-time.ts";
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { movingTime } from "./moving-time.ts";
import type { TrackPoint } from "./types.ts";
// ~0.0001° latitude ≈ 11.1 m. At 11 m per interval, speed depends on dt.
function pt(lat: number, lon: number, time?: string): TrackPoint {
return { lat, lon, time };
}
describe("movingTime", () => {
it("returns null when no trackpoints carry timestamps", () => {
const tracks = [[pt(0, 0), pt(0, 0.0001), pt(0, 0.0002)]];
expect(movingTime(tracks)).toBeNull();
});
it("sums intervals where the athlete is moving", () => {
// Three points 10s apart, each ~11 m → ~1.1 m/s (moving). 2 intervals.
const tracks = [[
pt(0, 0, "2026-06-01T08:00:00Z"),
pt(0, 0.0001, "2026-06-01T08:00:10Z"),
pt(0, 0.0002, "2026-06-01T08:00:20Z"),
]];
expect(movingTime(tracks)).toBe(20);
});
it("excludes stationary spans (below the speed threshold)", () => {
// Middle interval: no movement over 10s → stopped, excluded.
const tracks = [[
pt(0, 0, "2026-06-01T08:00:00Z"),
pt(0, 0.0001, "2026-06-01T08:00:10Z"), // moving (~11 m / 10s)
pt(0, 0.0001, "2026-06-01T08:00:20Z"), // stationary (0 m)
pt(0, 0.0002, "2026-06-01T08:00:30Z"), // moving again
]];
expect(movingTime(tracks)).toBe(20); // 2 moving intervals of 10s
});
it("excludes long gaps (pauses / signal loss)", () => {
const tracks = [[
pt(0, 0, "2026-06-01T08:00:00Z"),
pt(0, 0.0001, "2026-06-01T08:00:10Z"), // 10s moving
pt(0, 0.0002, "2026-06-01T09:00:00Z"), // ~1h gap → excluded
]];
expect(movingTime(tracks)).toBe(10);
});
it("never exceeds elapsed time", () => {
const tracks = [[
pt(0, 0, "2026-06-01T08:00:00Z"),
pt(0, 0.0001, "2026-06-01T08:00:10Z"),
pt(0, 0.0001, "2026-06-01T08:00:40Z"), // 30s stationary
]];
const elapsed = 40;
const moving = movingTime(tracks)!;
expect(moving).toBeLessThanOrEqual(elapsed);
});
});

View file

@ -0,0 +1,46 @@
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;
}

View file

@ -396,6 +396,15 @@ export default {
public: "Öffentlich (für alle sichtbar)",
},
},
stats: {
distance: "Distanz",
duration: "Zeit",
movingTime: "Bewegungszeit",
avgSpeed: "⌀ Geschwindigkeit",
avgPace: "⌀ Pace",
ascent: "Anstieg",
descent: "Abstieg",
},
settings: {
title: "Einstellungen",
nav: {

View file

@ -396,6 +396,15 @@ export default {
public: "Public (anyone on the internet)",
},
},
stats: {
distance: "Distance",
duration: "Time",
movingTime: "Moving time",
avgSpeed: "Avg speed",
avgPace: "Avg pace",
ascent: "Ascent",
descent: "Descent",
},
settings: {
title: "Settings",
nav: {