From 4536470eb92b72622e2050607a10f0c4450ad220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 14 Jul 2026 00:10:57 +0200 Subject: [PATCH] feat(gpx): post-parse timestamp repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task group 3 of gpx-parser-robustness. Timestamps were passed through raw; a partially-broken time channel from a flaky recorder silently shrank moving time and skewed start-time derivation. New pure `timestamp-repair.ts` applied per segment after parsing: - validity = Date.parse yields a finite epoch; - a segment with no valid timestamps is left untouched (untimed track); - >50% invalid → drop all the segment's timestamps (noise, not signal); - otherwise → linearly interpolate invalid runs between valid neighbours (by point index), leading/trailing runs clamp to the nearest valid timestamp; only repaired points are rewritten as ISO 8601, valid points keep their original string. Monotonicity intentionally not enforced (movingTime already skips non-positive intervals; reordering would be fabrication). No GpxData shape change. Wired into parseTracks output in parse.ts. Verified: gpx typecheck + lint clean, 81/81 tests pass, journal + planner typecheck unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/gpx-parser-robustness/tasks.md | 6 +- packages/gpx/src/parse.ts | 3 +- packages/gpx/src/timestamp-repair.test.ts | 56 ++++++++++++++++ packages/gpx/src/timestamp-repair.ts | 67 +++++++++++++++++++ 4 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/gpx/src/timestamp-repair.test.ts create mode 100644 packages/gpx/src/timestamp-repair.ts diff --git a/openspec/changes/gpx-parser-robustness/tasks.md b/openspec/changes/gpx-parser-robustness/tasks.md index 0eaaba4..65b827f 100644 --- a/openspec/changes/gpx-parser-robustness/tasks.md +++ b/openspec/changes/gpx-parser-robustness/tasks.md @@ -11,9 +11,9 @@ ## 3. Timestamp repair -- [ ] 3.1 Create `packages/gpx/src/timestamp-repair.ts`: per-segment pass — >50% invalid → drop all; else linear interpolation between valid neighbors with leading/trailing clamp; output ISO 8601 strings -- [ ] 3.2 Wire the repair pass into `parseTracks` output in `parse.ts` -- [ ] 3.3 Unit tests (`timestamp-repair.test.ts`): sparse invalid interpolated, >50% invalid dropped (and `movingTime` returns null), leading/trailing clamp, all-valid untouched, no-timestamps untouched +- [x] 3.1 Create `packages/gpx/src/timestamp-repair.ts`: per-segment pass — >50% invalid → drop all; else linear interpolation between valid neighbors with leading/trailing clamp; output ISO 8601 strings +- [x] 3.2 Wire the repair pass into `parseTracks` output in `parse.ts` +- [x] 3.3 Unit tests (`timestamp-repair.test.ts`): sparse invalid interpolated, >50% invalid dropped (and `movingTime` returns null), leading/trailing clamp, all-valid untouched, no-timestamps untouched ## 4. Metadata fallback diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 18f21e6..fecb2e2 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -1,5 +1,6 @@ import type { Waypoint } from "@trails-cool/types"; import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; +import { repairTimestamps } from "./timestamp-repair.ts"; /** * Parse a GPX XML string into structured data. @@ -31,7 +32,7 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const description = doc.querySelector("metadata > desc")?.textContent ?? undefined; const waypoints = parseWaypoints(doc); - const tracks = parseTracks(doc); + const tracks = repairTimestamps(parseTracks(doc)); const noGoAreas = parseNoGoAreas(doc); const { totalDistance, ...elevation } = computeElevation(tracks); diff --git a/packages/gpx/src/timestamp-repair.test.ts b/packages/gpx/src/timestamp-repair.test.ts new file mode 100644 index 0000000..df31c5e --- /dev/null +++ b/packages/gpx/src/timestamp-repair.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { repairSegmentTimestamps } from "./timestamp-repair.ts"; +import { movingTime } from "./moving-time.ts"; +import type { TrackPoint } from "./types.ts"; + +const pt = (time?: string, lat = 52.5, lon = 13.4): TrackPoint => ({ lat, lon, time }); + +describe("repairSegmentTimestamps", () => { + it("interpolates a sparse invalid timestamp between valid neighbours", () => { + const out = repairSegmentTimestamps([ + pt("2026-01-01T10:00:00Z"), + pt("garbage"), + pt("2026-01-01T10:02:00Z"), + pt("2026-01-01T10:03:00Z"), + ]); + // index 1 is midway (by index) between valid index 0 and index 2. + expect(out[1]!.time).toBe("2026-01-01T10:01:00.000Z"); + // Valid points keep their original strings untouched. + expect(out[0]!.time).toBe("2026-01-01T10:00:00Z"); + expect(out[3]!.time).toBe("2026-01-01T10:03:00Z"); + }); + + it("drops all timestamps when more than half the segment is invalid", () => { + const out = repairSegmentTimestamps([ + pt("2026-01-01T10:00:00Z"), + pt("garbage"), + pt(undefined), + pt("nope"), + ]); + expect(out.every((p) => p.time === undefined)).toBe(true); + // A dropped time channel means the segment reads as untimed. + expect(movingTime([out])).toBeNull(); + }); + + it("clamps leading and trailing invalid runs to the nearest valid timestamp", () => { + const out = repairSegmentTimestamps([ + pt("garbage"), + pt("2026-01-01T10:01:00Z"), + pt("2026-01-01T10:02:00Z"), + pt(undefined), + ]); + expect(out[0]!.time).toBe("2026-01-01T10:01:00.000Z"); // leading clamp + expect(out[3]!.time).toBe("2026-01-01T10:02:00.000Z"); // trailing clamp + }); + + it("leaves an all-valid segment untouched", () => { + const input = [pt("2026-01-01T10:00:00Z"), pt("2026-01-01T10:01:00Z")]; + const out = repairSegmentTimestamps(input); + expect(out.map((p) => p.time)).toEqual(["2026-01-01T10:00:00Z", "2026-01-01T10:01:00Z"]); + }); + + it("leaves an untimed segment (no timestamps) untouched", () => { + const out = repairSegmentTimestamps([pt(undefined), pt(undefined), pt(undefined)]); + expect(out.every((p) => p.time === undefined)).toBe(true); + }); +}); diff --git a/packages/gpx/src/timestamp-repair.ts b/packages/gpx/src/timestamp-repair.ts new file mode 100644 index 0000000..4386601 --- /dev/null +++ b/packages/gpx/src/timestamp-repair.ts @@ -0,0 +1,67 @@ +import type { TrackPoint } from "./types.ts"; + +// Post-parse timestamp repair (spec: gpx-parser-robustness "Timestamp +// repair"). Recorders from flaky devices emit runs of missing or garbage +// `