diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 3485551dd..d17aa8d97 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -7,6 +7,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import type { RouteSample } from "@/lib/workouts/route-samples"; /** * v1.4.32 — TanStack Query wrapper for `GET /api/workouts`. @@ -325,7 +326,7 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { metadata: unknown; route: { geometry: unknown; - sampleTimestamps: string[] | null; + sampleTimestamps: RouteSample[] | null; } | null; /** * Raw per-workout HR sample envelope. `samples.samples` is dropped diff --git a/src/lib/workouts/__tests__/route-svg.test.ts b/src/lib/workouts/__tests__/route-svg.test.ts index 0061b74c2..c582de0f5 100644 --- a/src/lib/workouts/__tests__/route-svg.test.ts +++ b/src/lib/workouts/__tests__/route-svg.test.ts @@ -150,6 +150,23 @@ describe("haversineMeters", () => { }); describe("buildGpxDocument", () => { + it("writes the instant of a stored sample object", () => { + const gpx = buildGpxDocument({ + coordinates: [ + [11.0, 50.0], + [11.001, 50.001], + ], + timestamps: [ + { t: "2026-05-15T07:00:00Z", hr: 120 }, + { t: "2026-05-15T07:00:05Z", speedMs: 3.1 }, + ], + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + }); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + }); + it("emits trkpt elements with elevation and time when present", () => { const gpx = buildGpxDocument({ coordinates: [ diff --git a/src/lib/workouts/__tests__/splits.test.ts b/src/lib/workouts/__tests__/splits.test.ts index 6b335053a..48e9e6074 100644 --- a/src/lib/workouts/__tests__/splits.test.ts +++ b/src/lib/workouts/__tests__/splits.test.ts @@ -42,6 +42,16 @@ describe("computeSplits", () => { } }); + it("reads the stored sample shape, an instant with its channels", () => { + // Both ingests store `{ t, speedMs?, hr? }` per point; older tracks a + // bare instant. The two shapes time the same track identically. + const { coords, timestamps } = track(3200, 320, 960); + const samples = timestamps.map((t, i) => ({ t, hr: 120 + (i % 30) })); + const splits = computeSplits(coords, samples); + expect(splits).not.toBeNull(); + expect(splits).toEqual(computeSplits(coords, timestamps)); + }); + it("returns null without aligned timestamps", () => { const { coords } = track(3000, 300, 900); expect(computeSplits(coords, null)).toBeNull(); diff --git a/src/lib/workouts/detail.ts b/src/lib/workouts/detail.ts index ed1d6e026..126fe6943 100644 --- a/src/lib/workouts/detail.ts +++ b/src/lib/workouts/detail.ts @@ -237,7 +237,7 @@ async function readEnrichment( : null; const splits = geometryCoords && Array.isArray(row.route?.sampleTimestamps) - ? computeSplits(geometryCoords, row.route.sampleTimestamps as string[]) + ? computeSplits(geometryCoords, row.route.sampleTimestamps) : null; // Sport context: the user's own last-180-days average for this diff --git a/src/lib/workouts/route-samples.ts b/src/lib/workouts/route-samples.ts new file mode 100644 index 000000000..11d403518 --- /dev/null +++ b/src/lib/workouts/route-samples.ts @@ -0,0 +1,17 @@ +/** + * One point of a stored route's `sampleTimestamps`, aligned with the + * geometry's coordinates. Both ingests store `{ t, speedMs?, hr? }`; tracks + * written before the per-point channels carry a bare ISO instant. Every + * reader goes through `routeSampleInstant` so neither shape is skipped. + */ +export type RouteSample = string | { t: string; speedMs?: number; hr?: number }; + +/** The ISO instant of one sample, or null when it carries none. */ +export function routeSampleInstant(sample: unknown): string | null { + if (typeof sample === "string") return sample.length > 0 ? sample : null; + if (typeof sample === "object" && sample !== null && "t" in sample) { + const t = (sample as { t: unknown }).t; + return typeof t === "string" && t.length > 0 ? t : null; + } + return null; +} diff --git a/src/lib/workouts/route-svg.ts b/src/lib/workouts/route-svg.ts index 2c4d29952..c07e455c4 100644 --- a/src/lib/workouts/route-svg.ts +++ b/src/lib/workouts/route-svg.ts @@ -16,6 +16,7 @@ * correction squashes the longitude axis by `cos(midLat)` so one screen * unit east equals one screen unit north in real metres. */ +import { routeSampleInstant } from "@/lib/workouts/route-samples"; export type RouteCoordinate = [number, number] | [number, number, number] | number[]; @@ -323,7 +324,8 @@ function escapeXml(value: string): string { */ export function buildGpxDocument(params: { coordinates: readonly RouteCoordinate[]; - timestamps?: readonly string[] | null; + /** The stored samples: an instant each, or `{ t, … }` (`RouteSample`). */ + timestamps?: readonly unknown[] | null; sportType: string; startedAt: string; }): string { @@ -336,8 +338,8 @@ export function buildGpxDocument(params: { if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return; const parts = [``]; if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`${c[2]}`); - const ts = timestamps?.[i]; - if (typeof ts === "string" && ts.length > 0) { + const ts = routeSampleInstant(timestamps?.[i]); + if (ts) { parts.push(``); } parts.push(``); diff --git a/src/lib/workouts/splits.ts b/src/lib/workouts/splits.ts index 3a8377c1b..6e353647d 100644 --- a/src/lib/workouts/splits.ts +++ b/src/lib/workouts/splits.ts @@ -14,6 +14,7 @@ import { haversineMeters, type RouteCoordinate, } from "@/lib/workouts/route-svg"; +import { routeSampleInstant } from "@/lib/workouts/route-samples"; export interface WorkoutSplit { /** 1-based kilometre index. */ @@ -34,7 +35,8 @@ const KM = 1000; */ export function computeSplits( coordinates: readonly RouteCoordinate[], - timestamps: readonly string[] | null | undefined, + /** The stored samples: an instant each, or `{ t, … }` (`RouteSample`). */ + timestamps: readonly unknown[] | null | undefined, ): WorkoutSplit[] | null { if (!timestamps || timestamps.length !== coordinates.length) return null; @@ -43,7 +45,7 @@ export function computeSplits( coordinates.forEach((c, i) => { const [lonLat] = extractLonLat([c]); if (!lonLat) return; - const tMs = Date.parse(timestamps[i] ?? ""); + const tMs = Date.parse(routeSampleInstant(timestamps[i]) ?? ""); if (!Number.isFinite(tMs)) return; pts.push({ lon: lonLat[0], lat: lonLat[1], tMs }); }); diff --git a/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap b/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap index b4b299c13..1b9359e7a 100644 --- a/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap +++ b/tests/integration/routes/parity/__snapshots__/workout-detail.get-01-canonical.json.snap @@ -145,7 +145,13 @@ } ] }, - "splits": null, + "splits": [ + { + "km": 1, + "durationSec": 530, + "paceSecPerKm": 530 + } + ], "sportContext": { "count": 1, "avgDurationSec": 1800, diff --git a/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap b/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap index d1510e4c2..b2b4dc296 100644 --- a/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap +++ b/tests/integration/routes/parity/__snapshots__/workout-detail.get-02-compact.json.snap @@ -115,7 +115,13 @@ } ] }, - "splits": null, + "splits": [ + { + "km": 1, + "durationSec": 530, + "paceSecPerKm": 530 + } + ], "sportContext": { "count": 1, "avgDurationSec": 1800,