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:
parent
ede263d4d1
commit
0325d01bca
15 changed files with 482 additions and 67 deletions
|
|
@ -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";
|
||||
|
|
|
|||
56
packages/gpx/src/moving-time.test.ts
Normal file
56
packages/gpx/src/moving-time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/gpx/src/moving-time.ts
Normal file
46
packages/gpx/src/moving-time.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue