From 0325d01bcaac248c0d3b25271db442d374ce6d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 12 Jun 2026 14:48:36 +0200 Subject: [PATCH] activity-stats: shared StatRow + canonical metrics across surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/journal/app/components/StatRow.test.tsx | 29 +++++ apps/journal/app/components/StatRow.tsx | 34 ++++++ apps/journal/app/lib/stats.test.ts | 102 ++++++++++++++++ apps/journal/app/lib/stats.ts | 112 ++++++++++++++++++ .../app/routes/activities.$id.server.ts | 15 +++ apps/journal/app/routes/activities.$id.tsx | 37 +++--- apps/journal/app/routes/feed.tsx | 20 ++-- apps/journal/app/routes/routes.$id.tsx | 34 ++---- apps/journal/app/routes/users.$username.tsx | 19 ++- openspec/changes/activity-stats/tasks.md | 26 ++-- packages/gpx/src/index.ts | 1 + packages/gpx/src/moving-time.test.ts | 56 +++++++++ packages/gpx/src/moving-time.ts | 46 +++++++ packages/i18n/src/locales/de.ts | 9 ++ packages/i18n/src/locales/en.ts | 9 ++ 15 files changed, 482 insertions(+), 67 deletions(-) create mode 100644 apps/journal/app/components/StatRow.test.tsx create mode 100644 apps/journal/app/components/StatRow.tsx create mode 100644 apps/journal/app/lib/stats.test.ts create mode 100644 apps/journal/app/lib/stats.ts create mode 100644 packages/gpx/src/moving-time.test.ts create mode 100644 packages/gpx/src/moving-time.ts diff --git a/apps/journal/app/components/StatRow.test.tsx b/apps/journal/app/components/StatRow.test.tsx new file mode 100644 index 0000000..cdde2cf --- /dev/null +++ b/apps/journal/app/components/StatRow.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { StatRow } from "./StatRow.tsx"; + +afterEach(cleanup); + +describe("StatRow", () => { + it("renders nothing for an empty item list", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders each item's value and label, in order", () => { + const { container } = render( + , + ); + const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent); + const values = [...container.querySelectorAll("dd")].map((el) => el.textContent); + expect(labels).toEqual(["Distance", "Time", "Avg speed"]); + expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]); + }); +}); diff --git a/apps/journal/app/components/StatRow.tsx b/apps/journal/app/components/StatRow.tsx new file mode 100644 index 0000000..8d8d33e --- /dev/null +++ b/apps/journal/app/components/StatRow.tsx @@ -0,0 +1,34 @@ +import type { StatItem } from "~/lib/stats"; + +/** + * The one shared headline-stat presentation. Surfaces pass an ordered list of + * {value, label} items (built via `activityStatItems`); the component never + * fetches or formats. `size="lg"` is the detail-page treatment; the default + * compact size is for feed cards and the profile list. + */ +export function StatRow({ + items, + size = "sm", + className, +}: { + items: StatItem[]; + size?: "sm" | "lg"; + className?: string; +}) { + if (items.length === 0) return null; + const valueCls = + size === "lg" + ? "text-2xl font-bold text-gray-900" + : "text-sm font-semibold text-gray-900"; + const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1"; + return ( +
+ {items.map((it) => ( +
+
{it.value}
+
{it.label}
+
+ ))} +
+ ); +} diff --git a/apps/journal/app/lib/stats.test.ts b/apps/journal/app/lib/stats.test.ts new file mode 100644 index 0000000..a0ab68c --- /dev/null +++ b/apps/journal/app/lib/stats.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + formatDistanceKm, + formatElevationM, + formatDuration, + formatSpeedKmh, + formatPace, + deriveRate, + activityStatItems, +} from "./stats.ts"; + +// Identity translate: labels come back as their keys so we can assert order. +const t = (k: string) => k; + +describe("formatters", () => { + it("formats distance (1 decimal under 100 km, integer above)", () => { + expect(formatDistanceKm(12_340)).toBe("12.3 km"); + expect(formatDistanceKm(123_400)).toBe("123 km"); + }); + it("formats elevation as integer metres", () => { + expect(formatElevationM(512.6)).toBe("513 m"); + }); + it("formats duration h/m", () => { + expect(formatDuration(9000)).toBe("2h 30m"); + expect(formatDuration(2820)).toBe("47m"); + }); + it("formats speed and pace", () => { + expect(formatSpeedKmh(30.86)).toBe("30.9 km/h"); + expect(formatPace(318)).toBe("5:18 /km"); + }); +}); + +describe("deriveRate", () => { + it("returns pace for foot sports", () => { + expect(deriveRate(10_000, 3000, "run")).toEqual({ kind: "pace", value: 300 }); + expect(deriveRate(10_000, 3000, "hike")?.kind).toBe("pace"); + }); + it("returns speed for wheeled/ski sports", () => { + expect(deriveRate(30_000, 3600, "ride")).toEqual({ kind: "speed", value: 30 }); + expect(deriveRate(1000, 3600, "mtb")?.kind).toBe("speed"); + }); + it("defaults to speed when sport is unset", () => { + expect(deriveRate(30_000, 3600, null)?.kind).toBe("speed"); + }); + it("returns null without usable distance/time", () => { + expect(deriveRate(0, 3600, "run")).toBeNull(); + expect(deriveRate(1000, 0, "run")).toBeNull(); + expect(deriveRate(null, null, "run")).toBeNull(); + }); +}); + +describe("activityStatItems", () => { + it("builds the full canonical order", () => { + const items = activityStatItems( + { + distance: 30_000, + durationSec: 3600, + movingTimeSec: 3300, + elevationGain: 500, + elevationLoss: 480, + sportType: "ride", + }, + t, + ); + expect(items.map((i) => i.label)).toEqual([ + "stats.distance", + "stats.duration", + "stats.movingTime", + "stats.avgSpeed", + "stats.ascent", + "stats.descent", + ]); + }); + + it("compact drops moving time and ascent/descent, keeps order", () => { + const items = activityStatItems( + { distance: 10_000, durationSec: 3000, movingTimeSec: 2800, elevationGain: 100, sportType: "run" }, + t, + { compact: true }, + ); + expect(items.map((i) => i.label)).toEqual([ + "stats.distance", + "stats.duration", + "stats.avgPace", + ]); + }); + + it("omits items with no value", () => { + const items = activityStatItems({ distance: 5000 }, t); + expect(items.map((i) => i.label)).toEqual(["stats.distance"]); + }); + + it("rate prefers moving time over elapsed", () => { + const items = activityStatItems( + { distance: 10_000, durationSec: 4000, movingTimeSec: 3000, sportType: "run" }, + t, + ); + const pace = items.find((i) => i.label === "stats.avgPace"); + // 10 km in 3000s moving → 5:00 /km (not 6:40 from elapsed) + expect(pace?.value).toBe("5:00 /km"); + }); +}); diff --git a/apps/journal/app/lib/stats.ts b/apps/journal/app/lib/stats.ts new file mode 100644 index 0000000..b2d68fa --- /dev/null +++ b/apps/journal/app/lib/stats.ts @@ -0,0 +1,112 @@ +import type { SportType } from "@trails-cool/db/schema/journal"; + +// Minimal translate signature (the `t` returned by react-i18next satisfies it) +// so this stays a plain util with no react-i18next coupling. +type Translate = (key: string) => string; + +export interface StatItem { + label: string; + value: string; +} + +// --- pure formatters (metric baseline; an imperial toggle is future work) --- + +export function formatDistanceKm(meters: number): string { + const km = meters / 1000; + return km >= 100 ? `${Math.round(km)} km` : `${km.toFixed(1)} km`; +} + +export function formatElevationM(meters: number): string { + return `${Math.round(meters)} m`; +} + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +export function formatSpeedKmh(kmh: number): string { + return `${kmh.toFixed(1)} km/h`; +} + +export function formatPace(secondsPerKm: number): string { + const m = Math.floor(secondsPerKm / 60); + const s = Math.round(secondsPerKm % 60); + return `${m}:${String(s).padStart(2, "0")} /km`; +} + +// --- derived rate (pace vs speed, sport-aware) --- + +const PACE_SPORTS = new Set(["hike", "walk", "run"]); + +export function deriveRate( + distanceM: number | null | undefined, + timeSec: number | null | undefined, + sportType: SportType | null | undefined, +): { kind: "pace" | "speed"; value: number } | null { + if (!distanceM || !timeSec || distanceM <= 0 || timeSec <= 0) return null; + const km = distanceM / 1000; + if (sportType && PACE_SPORTS.has(sportType)) { + return { kind: "pace", value: timeSec / km }; + } + return { kind: "speed", value: km / (timeSec / 3600) }; +} + +export interface ActivityStatsInput { + distance?: number | null; + durationSec?: number | null; // elapsed + movingTimeSec?: number | null; + elevationGain?: number | null; + elevationLoss?: number | null; + sportType?: SportType | null; +} + +/** + * Build the canonical ordered stat items for an activity or route. Order is + * fixed (distance · time · avg pace/speed · ascent · descent); moving time, when + * present, is shown right after elapsed time. `compact` drops moving time and + * ascent/descent for tight surfaces (feed card, profile list) — the remaining + * items keep the canonical order. Items with no value are omitted. + */ +export function activityStatItems( + input: ActivityStatsInput, + t: Translate, + opts: { compact?: boolean } = {}, +): StatItem[] { + const { compact = false } = opts; + const items: StatItem[] = []; + + if (input.distance != null) { + items.push({ label: t("stats.distance"), value: formatDistanceKm(input.distance) }); + } + + const elapsed = input.durationSec; + if (elapsed != null) { + items.push({ label: t("stats.duration"), value: formatDuration(elapsed) }); + } + if (!compact && input.movingTimeSec != null) { + items.push({ label: t("stats.movingTime"), value: formatDuration(input.movingTimeSec) }); + } + + // Rate prefers moving time when available, else elapsed. + const rate = deriveRate(input.distance, input.movingTimeSec ?? elapsed, input.sportType); + if (rate) { + items.push( + rate.kind === "pace" + ? { label: t("stats.avgPace"), value: formatPace(rate.value) } + : { label: t("stats.avgSpeed"), value: formatSpeedKmh(rate.value) }, + ); + } + + if (!compact) { + if (input.elevationGain != null) { + items.push({ label: t("stats.ascent"), value: `↑ ${formatElevationM(input.elevationGain)}` }); + } + if (input.elevationLoss != null) { + items.push({ label: t("stats.descent"), value: `↓ ${formatElevationM(input.elevationLoss)}` }); + } + } + + return items; +} diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index d808f03..d426f9a 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -13,6 +13,8 @@ import { import { deleteImportByActivity } from "~/lib/sync/imports.server"; import { listRoutes } from "~/lib/routes.server"; import { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.server"; +import { parseGpxAsync, movingTime } from "@trails-cool/gpx"; +import { logger } from "~/lib/logger.server"; import type { Visibility } from "@trails-cool/db/schema/journal"; const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); @@ -36,6 +38,18 @@ export async function loadActivityDetail(request: Request, id: string | undefine const userRoutes = isOwner && user ? await listRoutes(user.id) : []; + // Moving time is derived from trackpoint timestamps (null when the GPX has + // none). Detail-page only — too costly to parse per row in list views. + let movingTimeSec: number | null = null; + if (activity.gpx) { + try { + const parsed = await parseGpxAsync(activity.gpx); + movingTimeSec = movingTime(parsed.tracks); + } catch (err) { + logger.warn({ activityId: activity.id, err }, "moving-time: failed to parse gpx"); + } + } + return { activity: { id: activity.id, @@ -46,6 +60,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, duration: activity.duration, + movingTimeSec, routeId: activity.routeId, hasGpx: !!activity.gpx, geojson: activity.geojson ?? null, diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 27d1636..eba8b8e 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -4,6 +4,8 @@ import type { Route } from "./+types/activities.$id"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import { SportBadge } from "~/components/SportBadge"; +import { StatRow } from "~/components/StatRow"; +import { activityStatItems } from "~/lib/stats"; import { loadActivityDetail, activityDetailAction } from "./activities.$id.server"; import { federationEnabled, @@ -79,28 +81,21 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) -
- {activity.distance != null && ( -
-

- {(activity.distance / 1000).toFixed(1)} km -

-

Distance

-
+ -

↑ {activity.elevationGain} m

-

Ascent

-
- )} - {activity.elevationLoss != null && ( -
-

↓ {activity.elevationLoss} m

-

Descent

-
- )} - + /> {activity.geojson && (
diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 97a3d6d..c24a868 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -5,6 +5,8 @@ import type { Route } from "./+types/feed"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; import { SportBadge } from "~/components/SportBadge"; +import { StatRow } from "~/components/StatRow"; +import { activityStatItems } from "~/lib/stats"; import { loadFeed } from "./feed.server"; export async function loader({ request }: Route.LoaderArgs) { @@ -142,14 +144,18 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {" · "}
-
- {a.distance != null && ( - {(a.distance / 1000).toFixed(1)} km + ↑ {Math.round(a.elevationGain)} m - )} -
+ /> diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 4579444..dff9e9d 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import { StatRow } from "~/components/StatRow"; +import { activityStatItems } from "~/lib/stats"; import { loadRouteDetail, routeDetailAction } from "./routes.$id.server"; export async function loader({ params, request }: Route.LoaderArgs) { @@ -174,28 +176,18 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )} -
- {route.distance != null && ( -
-

- {(route.distance / 1000).toFixed(1)} km -

-

{t("routes.distance")}

-
+ -

↑ {route.elevationGain} m

-

Ascent

-
- )} - {route.elevationLoss != null && ( -
-

↓ {route.elevationLoss} m

-

Descent

-
- )} - + /> {dayStats.length > 1 && (
diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index e344b46..f630ac9 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { ClientDate } from "~/components/ClientDate"; import { FollowButton } from "~/components/FollowButton"; import { SportBadge } from "~/components/SportBadge"; +import { StatRow } from "~/components/StatRow"; +import { activityStatItems } from "~/lib/stats"; import { loadUserProfile } from "./users.$username.server"; import { federationEnabled, @@ -229,11 +231,18 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) { {a.name} - {a.distance != null && ( - - {(a.distance / 1000).toFixed(1)} km - - )} +
{a.description && (

{a.description}

diff --git a/openspec/changes/activity-stats/tasks.md b/openspec/changes/activity-stats/tasks.md index 669b517..8f5772d 100644 --- a/openspec/changes/activity-stats/tasks.md +++ b/openspec/changes/activity-stats/tasks.md @@ -1,27 +1,27 @@ ## 1. Helpers -- [ ] 1.1 Add a pure `movingTime(gpx)` helper to `@trails-cool/gpx` (sum inter-point intervals, exclude sub-threshold/stationary segments, max-gap guard); unit-test against fixtures with and without trackpoint times. -- [ ] 1.2 Add a `deriveRate({ distance, time, sportType })` helper returning `{ kind: "pace" | "speed", value }` with the sport→kind rule and speed default. -- [ ] 1.3 Add a `formatStat` helper (distance/elevation/time/speed/pace) routing labels + units through i18n. +- [x] 1.1 Add a pure `movingTime(tracks)` helper to `@trails-cool/gpx` (sum inter-point intervals, exclude sub-threshold/stationary segments, max-gap guard); unit-tested with and without trackpoint times (`moving-time.ts` + test). +- [x] 1.2 Add a `deriveRate(distance, time, sportType)` helper returning `{ kind: "pace" | "speed", value }` with the sport→kind rule and speed default (`stats.ts`). +- [x] 1.3 Add pure `format*` helpers (distance/elevation/duration/speed/pace) in `stats.ts`; labels route through i18n at the call site (`activityStatItems`). ## 2. Component -- [ ] 2.1 Build the shared `StatRow` (ordered `{ value, label, unit }` items; value-prominent layout; no data fetching). -- [ ] 2.2 Define the canonical headline order as a single exported constant the surfaces draw from. +- [x] 2.1 Build the shared `StatRow` (ordered `{ value, label }` items; size sm/lg; no data fetching). +- [x] 2.2 Canonical order encoded once in `activityStatItems` (distance · time · [moving] · pace/speed · ascent · descent); surfaces draw from it. ## 3. Adopt across surfaces -- [ ] 3.1 Activity detail: replace the hand-rolled stats block with `StatRow` (full set incl. pace/speed + moving/elapsed). -- [ ] 3.2 Route detail: adopt `StatRow` (distance, ascent, descent; no time/pace). -- [ ] 3.3 Feed card: adopt `StatRow` compact subset (distance · time · pace/speed); expose the needed fields in the feed loader. -- [ ] 3.4 Profile activity list: adopt `StatRow` compact subset. +- [x] 3.1 Activity detail: replaced the card grid with `StatRow size="lg"` (full set incl. pace/speed + moving/elapsed); loader computes moving time from the GPX. +- [x] 3.2 Route detail: adopt `StatRow size="lg"` (distance, ascent, descent; no time/pace). +- [x] 3.3 Feed card: compact `StatRow` (distance · time · pace/speed); feed loader already exposes duration + sportType. +- [x] 3.4 Profile activity list: compact `StatRow`. ## 4. i18n -- [ ] 4.1 Add `journal.stats.*` keys (labels + unit suffixes for distance, elevation, time, speed, pace) to en + de. +- [x] 4.1 Add `journal.stats.*` keys (distance, duration, movingTime, avgSpeed, avgPace, ascent, descent) to en + de. ## 5. Tests & checks -- [ ] 5.1 Unit: `movingTime` (with/without timestamps; moving ≤ elapsed), `deriveRate` (pace/speed/default), `formatStat`. -- [ ] 5.2 Component: `StatRow` renders ordered items; subset preserves order. -- [ ] 5.3 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. +- [x] 5.1 Unit: `movingTime` (with/without timestamps; moving ≤ elapsed; stationary/gap exclusion), `deriveRate` (pace/speed/default/null), formatters. +- [x] 5.2 Component: `StatRow` renders ordered items (jsdom); `activityStatItems` ordering + compact-subset order asserted. +- [x] 5.3 typecheck + lint + unit (gpx 63, journal 311) green. Full `pnpm test:e2e` runs in CI. diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index f958032..7cc11af 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -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"; diff --git a/packages/gpx/src/moving-time.test.ts b/packages/gpx/src/moving-time.test.ts new file mode 100644 index 0000000..1513386 --- /dev/null +++ b/packages/gpx/src/moving-time.test.ts @@ -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); + }); +}); diff --git a/packages/gpx/src/moving-time.ts b/packages/gpx/src/moving-time.ts new file mode 100644 index 0000000..aba8d88 --- /dev/null +++ b/packages/gpx/src/moving-time.ts @@ -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; +} diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9677bfb..ff1c098 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index cc40ce6..4fd48d2 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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: {