fix(workouts): read the stored route sample shape for splits and the GPX export

Both ingests store each route point's sample as { t, speedMs?, hr? }, while
computeSplits parsed every sample as a bare instant and the GPX export
skipped anything that was not a string. Splits were therefore null for
every ingested track, and an exported track carried no times. One reader,
routeSampleInstant, takes either shape. The workout detail walk is
re-pinned: its ingested run now answers its first kilometre split.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 16:37:58 +02:00
1 parent 06d55afc46
commit 976b2ba0bc
9 files changed
+70 -9

No files matched your search

+2 -1
View File
@@ -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
@@ -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("<time>2026-05-15T07:00:00Z</time>");
expect(gpx).toContain("<time>2026-05-15T07:00:05Z</time>");
});
it("emits trkpt elements with elevation and time when present", () => {
const gpx = buildGpxDocument({
coordinates: [
+10
View File
@@ -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();
+1 -1
View File
@@ -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
+17
View File
@@ -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;
}
+5 -3
View File
@@ -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 = [`<trkpt lat="${lat}" lon="${lon}">`];
if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`<ele>${c[2]}</ele>`);
const ts = timestamps?.[i];
if (typeof ts === "string" && ts.length > 0) {
const ts = routeSampleInstant(timestamps?.[i]);
if (ts) {
parts.push(`<time>${escapeXml(ts)}</time>`);
}
parts.push(`</trkpt>`);
+4 -2
View File
@@ -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 });
});
@@ -145,7 +145,13 @@
}
]
},
"splits": null,
"splits": [
{
"km": 1,
"durationSec": 530,
"paceSecPerKm": 530
}
],
"sportContext": {
"count": 1,
"avgDurationSec": 1800,
@@ -115,7 +115,13 @@
}
]
},
"splits": null,
"splits": [
{
"km": 1,
"durationSec": 530,
"paceSecPerKm": 530
}
],
"sportContext": {
"count": 1,
"avgDurationSec": 1800,