mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
06d55afc46
commit
976b2ba0bc
9 files changed
+70
-9
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
+7
-1
@@ -145,7 +145,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"splits": null,
|
||||
"splits": [
|
||||
{
|
||||
"km": 1,
|
||||
"durationSec": 530,
|
||||
"paceSecPerKm": 530
|
||||
}
|
||||
],
|
||||
"sportContext": {
|
||||
"count": 1,
|
||||
"avgDurationSec": 1800,
|
||||
|
||||
+7
-1
@@ -115,7 +115,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"splits": null,
|
||||
"splits": [
|
||||
{
|
||||
"km": 1,
|
||||
"durationSec": 530,
|
||||
"paceSecPerKm": 530
|
||||
}
|
||||
],
|
||||
"sportContext": {
|
||||
"count": 1,
|
||||
"avgDurationSec": 1800,
|
||||
|
||||
Reference in new issue
Block a user