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,