Commit graph

1490 commits

Author SHA1 Message Date
Ullrich Schäfer
c9ad475e59
Merge pull request #598 from trails-cool/feat/ui-primitives
feat(ui): shared primitives (Button, Badge, Card, Input) on the tokens
2026-07-15 23:37:09 +02:00
Ullrich Schäfer
6344a513ce
feat(ui): shared primitives (Button, Badge, Card, Input) on the tokens
Second step of the visual-redesign (tokens -> primitives -> surfaces):
a small set of shared, token-driven primitives in @trails-cool/ui so
both apps compose from one styled vocabulary instead of hand-rolling
bg-white/text-gray-* per screen.

- @trails-cool/ui now ships React components (Button variants+sizes,
  Badge tones, Card raised/subtle, Input) plus a tiny cn() helper.
  React is a peer dep; package builds with no bundling step.
- Co-located jsdom unit tests (@testing-library/react) assert roles,
  token classes, variant switching, and behavior — run in the existing
  Unit Tests gate, cross-platform, no snapshot baseline needed.
- Tailwind @source directive in both apps' styles.css so the package's
  token utility classes get generated.
- Dev-only /dev/ui gallery route in the planner renders every primitive
  in all states — a zero-dependency stand-in for Storybook. Excluded
  from production builds (NODE_ENV guard in routes.ts).

No app surfaces are refactored yet; adopting the primitives in the
topbar/sidebar/etc. is the surface task groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:32:28 +02:00
Ullrich Schäfer
546d1d61d6
Merge pull request #597 from trails-cool/fix/dockerfile-ui-package
fix(docker): copy packages/ui/package.json in both Dockerfiles
2026-07-15 23:21:30 +02:00
Ullrich Schäfer
91fb764556
Merge branch 'main' into fix/dockerfile-ui-package 2026-07-15 23:17:29 +02:00
Ullrich Schäfer
df6df023e0
fix(docker): copy packages/ui/package.json in both Dockerfiles
New @trails-cool/ui workspace package needs its package.json in the
deps stage so pnpm install --frozen-lockfile can resolve the
workspace:* dependency. Fixes the Dockerfile Package Check and the
journal image build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:11:50 +02:00
Ullrich Schäfer
834536308d
Merge pull request #596 from trails-cool/feat/design-system-foundation
feat(ui): design-system foundation — shared token layer + fonts
2026-07-15 23:05:04 +02:00
Ullrich Schäfer
34c3a31e73
feat(ui): design-system foundation — shared token layer + fonts
Establish the visual-redesign foundation (task group 1): a single
shared source of design tokens both apps consume, so Planner and
Journal read from one palette/typography layer instead of drifting
per-app.

- New @trails-cool/ui package exporting theme.css: Tailwind v4 @theme
  tokens (warm off-white surfaces, one sage accent, earthy overnight
  tones, elevation-gradient colors, shadows) extracted from the
  visual-redesign mockup.
- Self-hosted Outfit (body) + Geist Mono (stats) via @fontsource-variable
  — privacy-first, no Google Fonts CDN.
- Both apps import the theme and set the base canvas to the warm
  off-white token + Outfit as the default font.

Foundation only: tokens are now available as Tailwind utilities
(bg-bg-raised, text-accent, font-mono, …) and raw CSS vars. Wiring
them into specific surfaces (topbar, sidebar, markers, elevation
chart) is later task groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:00:28 +02:00
Ullrich Schäfer
ad9a633ae0
Merge pull request #595 from trails-cool/fix/planner-note-tooltip-adaptive-width
fix(planner): note tooltip sizes to content (max-content, cap 280)
2026-07-15 21:48:22 +02:00
Ullrich Schäfer
eb1ab7d9e4
fix(planner): note tooltip sizes to content (width max-content, cap 280)
Refines #594: a fixed width made short notes an oversized box and wrapped
long notes early. Use `width: max-content` capped at `max-width: 280` so
the tooltip hugs short notes on one line and grows to 280px before wrapping
long ones (instead of collapsing to the longest word, the pre-#593 bug).
Keeps the centered text.

Verified in local dev with both a short ("Zug zurück?") and a long note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:44:05 +02:00
Ullrich Schäfer
f751f5057d
Merge pull request #594 from trails-cool/fix/planner-note-tooltip-width-align
fix(planner): note tooltip fixed width + centered text
2026-07-15 21:38:11 +02:00
Ullrich Schäfer
64d770ebea
fix(planner): give the note tooltip a fixed width + centered text
Follow-up to #593. With a long note the tooltip still squeezed to ~1 word
wide: an inline-block with only max-width, inside Leaflet's auto-width
tooltip, shrinks to its longest word rather than filling max-width.

Give it an explicit `width: 200` (a consistent, comfortably wide box that
wraps long notes cleanly) and `text-align: center`. Verified live in the
browser against a long repeated note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:33:43 +02:00
Ullrich Schäfer
b660b1582c
Merge pull request #593 from trails-cool/fix/planner-note-tooltip-width
fix(planner): waypoint-note tooltip collapses to one char per line
2026-07-15 17:52:18 +02:00
Ullrich Schäfer
8cf483a4ee
fix(planner): waypoint-note tooltip no longer collapses to one char per line
The note tooltip's inner span was `display: block` with
`word-break: break-word` and no width, so the Leaflet tooltip sized itself
to the block's *min-content* — which under break-word is ~1 character —
and every character wrapped onto its own line (a tall vertical strip).

Switch to `display: inline-block` (sizes to content up to maxWidth, so it
stays one line when short and wraps at 220px when long) and the standard
`overflow-wrap: break-word`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:48:33 +02:00
Ullrich Schäfer
9ecce6a219
Merge pull request #592 from trails-cool/chore/archive-planner-route-encoding
chore(openspec): archive planner-route-encoding
2026-07-15 17:17:37 +02:00
Ullrich Schäfer
6fcd1e21cd
chore(openspec): archive planner-route-encoding
All 12 tasks complete (shipped in #590 codec + #591 wiring). Archive via
`openspec archive`:
- Creates openspec/specs/planner-route-encoding/spec.md — new capability
  (5 requirements: compact single-source geometry, RLE road metadata,
  backward-compatible reads, preserved compute-once model, bounded doc
  size). Purpose filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-15-planner-route-encoding/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:13:19 +02:00
Ullrich Schäfer
86fc596e50
Merge pull request #591 from trails-cool/feat/planner-route-encoding-wire
feat(planner): store computed route compact-encoded
2026-07-15 17:12:00 +02:00
Ullrich Schäfer
c33a413395
feat(planner): store computed route compact-encoded (groups 2–4)
Wire the map-core codec into route-data.ts so the Yjs session doc stores
the computed route compactly, without changing the routing-host
compute-once-and-share model.

- Codec: add encodeElevations/decodeElevations (delta+varint) so geometry
  = encoded [lon,lat] polyline + elevation channel; precision bumped to
  1e6 (~0.11 m) to preserve the router's 6-decimal output.
- writeComputedRoute: geometry stored once (encoded polyline + elevations,
  no redundant geojson), road metadata run-length encoded.
- Dual-format reads: getCoordinates / readRoadMetadata / new readGeojson
  transparently handle the new encoding AND legacy JSON docs (+ oldest
  geojson-only) — no migration, no flag day; legacy docs re-encode on the
  next recompute. use-elevation-data reads readGeojson (assembled for new
  docs).
- Spec correction: a lossy compact codec can't be byte-identical to legacy
  (generateGpx emits raw precision), so "GPX byte-compatible" is corrected
  to "coordinates preserved within the router's ~0.1 m precision".

Tests: legacy-format read, size assertion (>4x smaller / round-trips),
elevation round-trip. map-core + planner typecheck/lint clean; full
pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:07:28 +02:00
Ullrich Schäfer
84744567bc
Merge pull request #590 from trails-cool/feat/planner-route-encoding-codec
feat(map-core): compact route codec (polyline + RLE)
2026-07-15 17:00:00 +02:00
Ullrich Schäfer
b81108b10b
feat(map-core): compact route codec (polyline + RLE) for planner doc
Task group 1 of planner-route-encoding. Framework-free `route-codec.ts`:
- encodePolyline/decodePolyline — fixed-precision (1e5) delta + zig-zag
  varint + base64; lossless to ~1 m, version-tagged ("p1:").
- encodeRuns/decodeRuns — run-length codec for the per-coordinate
  road-metadata channels, tagged ("r1:").
- isEncodedPolyline/isEncodedRuns — so the read path (group 3) tells the
  new encoding apart from legacy JSON.

Not yet wired into route-data.ts (groups 2–3). Verified: map-core
typecheck + lint clean, 52/52 tests (round-trip within 1 m, empty/single,
RLE collapse, >3x smaller than JSON).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:55:34 +02:00
Ullrich Schäfer
e92591b3ff
Merge pull request #589 from trails-cool/chore/propose-planner-route-encoding
docs(openspec): propose planner-route-encoding
2026-07-14 15:27:20 +02:00
Ullrich Schäfer
b55f4020cf
Merge branch 'main' into chore/propose-planner-route-encoding 2026-07-14 15:22:55 +02:00
Ullrich Schäfer
5856baac0d
Merge pull request #588 from trails-cool/ci/tolerate-prune-collision
ci: don't fail deploys on a transient prune collision
2026-07-14 09:32:22 +02:00
Ullrich Schäfer
d933cca451
docs(openspec): propose planner-route-encoding change
Follow-up to the planner WS reconnect-loop incident. Proposes compactly
encoding the computed route in the Yjs session doc (single-source geometry
+ delta/varint polyline + run-length-encoded road metadata, dual-format
backward-compatible reads) to cut a ~305 KB / 77 km route to ~40–60 KB —
WITHOUT changing the routing-host "compute once, share via Yjs" model or
GPX export output.

Artifacts: proposal, design, specs (new planner-route-encoding
capability), tasks. Validates --strict. Ready for /opsx:apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:58 +02:00
Ullrich Schäfer
a75eb1965c
ci: don't fail deploys on a transient "prune already running" collision
cd-apps's post-deploy `docker image prune` runs under `set -euo pipefail`
after the containers are already swapped, so when it collides with a
concurrent deploy/disk-maintenance prune ("Error response from daemon: a
prune operation is already running") it fails an otherwise-successful
deploy (observed on the #587 planner deploy). The cleanup is best-effort;
disk-maintenance.yml is the real image-prune safety net.

- cd-apps: `docker image prune -af || true`.
- disk-maintenance: tolerate the same collision on its prune; the disk-%
  threshold check afterward stays the real failure gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:27:50 +02:00
Ullrich Schäfer
5debe5643e
Merge pull request #586 from trails-cool/ci/opt-in-pr-previews
ci(staging): make PR previews opt-in (label or body marker)
2026-07-14 09:22:39 +02:00
Ullrich Schäfer
2405ff288b
Merge branch 'main' into ci/opt-in-pr-previews 2026-07-14 09:18:47 +02:00
Ullrich Schäfer
2421b59cf8
Merge pull request #587 from trails-cool/fix/planner-yjs-frame-cap
fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop)
2026-07-14 09:03:30 +02:00
Ullrich Schäfer
e34a06ff5a
fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop)
The Yjs sync WebSocket had MAX_MESSAGE_BYTES = 256 KB (per frame) but
MAX_DOC_BYTES = 5 MB (per session doc). A full-state sync frame carries
the entire doc, so any session doc between 256 KB and 5 MB was allowed to
exist yet could never sync: the client's sync frame tripped the 256 KB
per-frame cap, the server closed it (1008), the client reconnected, resent
the same oversized frame, and looped forever — the UI froze, the connection
flapped Verbunden/Verbinde, and the network tab filled with 101 reconnects.

Real routes hit this fast because the BRouter geometry is stored in the doc:
a 4-waypoint, 77 km cycling route was already 305 KB.

Fix: the per-frame cap must be >= the doc cap (a full-doc sync must fit in
one frame). Set MAX_MESSAGE_BYTES = MAX_DOC_BYTES + 256 KB overhead; the
5 MB doc cap stays the real size/abuse guard. Updated the test that had
codified the inverted ordering.

Verified: planner typecheck + lint clean, yjs-server 9/9, planner suite
183/183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:58:24 +02:00
Ullrich Schäfer
696ffb7306
Merge pull request #585 from trails-cool/fix/flagship-log-rotation
fix(infra): cap container log sizes to prevent disk-full outages
2026-07-14 08:38:03 +02:00
Ullrich Schäfer
3db78a4a48
ci(staging): make PR previews opt-in (label or body marker)
PR previews were built + deployed for every apps/packages PR — each one a
journal container + its own database on the flagship, which is a standing
contributor to the disk pressure behind recurring deploy failures and the
2026-07-14 disk-full outage.

Make them opt-in: a preview is built/deployed only when the PR carries the
`preview` label OR a `<!-- preview -->` marker in its description. Gate the
(costly) build-images job, deploy-preview, and — via new labeled/unlabeled
triggers — tear the preview down when the label is pulled, so a de-flagged
PR can't orphan its stack. Main-push / dispatch deploys are unchanged.

Note: the pull_request `paths` filter still applies, so labeling a PR that
touches no apps/packages files won't spin up a preview (nothing to preview).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:37:50 +02:00
Ullrich Schäfer
010edae636
fix(infra): cap container log sizes to prevent disk-full outages
Every compose service used docker's default json-file driver with no
rotation, so a service logging in a loop grew unbounded — a 2.6 GB
container log contributed to a 2026-07-14 disk-full outage (/ at 100%,
Postgres down, apps 500ing, deploys failing at the SCP step).

Add an `x-logging` anchor (10 MB x 3 files = 30 MB max per container) and
apply it to all 11 services. Complements disk-maintenance.yml (which prunes
images but can't touch container logs). Takes effect when cd-infra recreates
the containers. Documented the disk-pressure runbook in docs/deployment.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:34:17 +02:00
Ullrich Schäfer
0328fb7f33
Merge pull request #584 from trails-cool/chore/archive-elevation-profile-hardening
chore(openspec): archive elevation-profile-hardening
2026-07-14 01:13:37 +02:00
Ullrich Schäfer
a57441fce8
chore(openspec): archive elevation-profile-hardening
All 11 tasks complete (shipped in #581/#582/#583). Archive via
`openspec archive` (CLI 1.6.0):
- Creates openspec/specs/elevation-computation/spec.md — new capability
  (4 requirements: spike removal, threshold-filtered ascent/descent,
  cleaned profile series, per-day totals from cleaned data). Purpose
  filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-13-elevation-profile-hardening/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 01:09:53 +02:00
Ullrich Schäfer
3e711a624a
Merge pull request #583 from trails-cool/feat/elevation-hardening-days
feat(gpx): per-day ascent/descent from cleaned elevation
2026-07-14 01:08:58 +02:00
Ullrich Schäfer
3db59d99e3
feat(gpx): per-day ascent/descent from cleaned elevation
Task group 2 (part b) + verification of elevation-profile-hardening.

compute-days.ts no longer sums every raw point-to-point delta for
cumulative ascent/descent (which overstated day totals the same 20–50%).
It now despikes each segment, runs cumulativeFilteredTotals once over the
despiked track, and maps the running filtered ascent/descent back onto the
flat allPoints indices (carrying forward across ele-less points). Day
totals = cumulative[end] − cumulative[start] therefore match the filtered
route total by construction. DayStage shape + rounding unchanged.

Test: a two-day split over the shared noisy/spiky track reports filtered
(not inflated) per-day ascent, and the day ascents sum exactly to the
whole-route filtered ascent.

Completes the change (11/11). Verified: gpx 112/112; full pnpm typecheck
13/13, lint 13/13, test 11/11 (journal + planner compile unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 01:04:12 +02:00
Ullrich Schäfer
84db039648
Merge pull request #582 from trails-cool/feat/elevation-hardening-wire
feat(gpx): apply elevation cleaning to totals + profile chart
2026-07-14 01:01:07 +02:00
Ullrich Schäfer
29be9cbe5f
feat(gpx): apply elevation cleaning to totals + profile chart
Task group 2 (part a) of elevation-profile-hardening — wire the group-1
primitives into the headline stat and the chart.

- computeElevation (parse.ts): despike each segment's elevation sequence,
  then take filteredTotals; gain/loss are now noise-filtered (no more
  20–50% inflation from per-point jitter), the profile is built from the
  despiked data, and additive gainRaw/lossRaw expose the unfiltered sums.
- elevationSeries: despike per segment (no cross-trkseg interpolation)
  before downsampling, so the chart and the headline totals derive from
  the same cleaned data.
- types.ts: GpxData.elevation gains gainRaw/lossRaw (additive).

Well-formed monotonic climbs are unchanged (steps exceed the 5 m
threshold → filtered == raw); a shared noisy-track test proves jitter is
filtered and a 300 m single-point spike is interpolated out of both the
totals and the chart.

Verified: gpx 111/111; full pnpm typecheck 13/13, lint 13/13, test 11/11
(journal + planner compile with the additive fields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:56:36 +02:00
Ullrich Schäfer
79124620d3
Merge pull request #581 from trails-cool/feat/elevation-hardening-clean
feat(gpx): elevation cleaning primitives (despike + hysteresis totals)
2026-07-14 00:50:48 +02:00
Ullrich Schäfer
990bbaa99e
feat(gpx): elevation cleaning primitives (despike + hysteresis totals)
Task group 1 of elevation-profile-hardening. New pure module
elevation-clean.ts (not yet wired — group 2 does that):

- despike(points): interpolates out isolated peak/pit spikes (opposite-sign
  slope outliers both exceeding MAX_SLOPE_PERCENT=100); steep monotonic
  terrain (same-sign) is never touched; per-segment, no cross-gap interp.
- filteredTotals(points): hysteresis ascent/descent (NOISE_THRESHOLD_M=5)
  that suppresses sub-threshold jitter, returning {gain,loss,gainRaw,lossRaw}.
- cumulativeFilteredTotals(points): running filtered arrays for per-day
  splits (day total = cumulative[end] − cumulative[start]).

DESIGN NOTE for review: the spec's task 1.2 says the zero-filtered
fallback should "report raw", but the raw sum-of-deltas re-inflates the
very jitter we remove (a ±2 m wobble sums to several metres) — directly
contradicting task 1.4's "±2 m jitter → 0 totals". Resolved toward the
stated goal: fall back to NET change (end−start), so flat wobble reads ~0
while a genuine small net climb still surfaces. gainRaw/lossRaw still
expose the raw sums.

Verified: gpx typecheck+lint clean, 108/108; full pnpm test 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:46:13 +02:00
Ullrich Schäfer
bc91edb988
Merge pull request #580 from trails-cool/chore/archive-gpx-parser-robustness
chore(openspec): archive gpx-parser-robustness
2026-07-14 00:40:23 +02:00
Ullrich Schäfer
5fca8d8f18
chore(openspec): archive gpx-parser-robustness
All 16 tasks complete (shipped in #576/#578/#579). Archive via
`openspec archive` (CLI 1.6.0):
- Creates openspec/specs/gpx-parsing/spec.md — the new capability (5
  requirements: invalid-point skipping, route support, timestamp repair,
  metadata fallback, fixture corpus). Purpose filled in (CLI leaves TBD).
- Moves the change to openspec/changes/archive/2026-07-13-gpx-parser-robustness/.

Spec passes `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:36:45 +02:00
Ullrich Schäfer
5dd8c6e796
Merge pull request #579 from trails-cool/feat/gpx-parser-robustness-fixtures
test(gpx): fixture corpus + verification (groups 5–6)
2026-07-14 00:34:57 +02:00
Ullrich Schäfer
36c5a8beaa
test(gpx): fixture corpus + verification (groups 5–6)
Task groups 5 and 6 of gpx-parser-robustness.

Group 5 — packages/gpx/fixtures/*.gpx (synthetic, no personal data):
route-only, missing-coords, garbage-ele, timestamps-partial,
timestamps-mostly-invalid, multi-track, unicode-name,
namespaced-extensions, plus trimmed Komoot/Wahoo-style exports.
parse-node.test.ts gains a fixture table test that auto-discovers every
*.gpx from disk, asserts finite stats + per-fixture shape, and fails
loudly if a file has no EXPECTATIONS entry (the corpus is the regression
mechanism).

Group 6 — verification: GpxData shape unchanged; journal + planner
compile with no changes; gpx suite 98/98; full pnpm typecheck 13/13,
lint 13/13, test 11/11. Route-only + broken-file import behaviour is
covered by the fixtures + consumer compile; the browser leg rides e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:30:46 +02:00
Ullrich Schäfer
ca3e4232c3
Merge pull request #578 from trails-cool/feat/gpx-parser-robustness-metadata
feat(gpx): metadata name/desc fallback + <cmt> merge
2026-07-14 00:27:34 +02:00
Ullrich Schäfer
b7b8906ce9
feat(gpx): metadata name/desc fallback + <cmt> merge
Task group 4 of gpx-parser-robustness. Many apps put the only
human-readable title on the <trk>/<rte>, not in <metadata>.

- name/description now fall back to the first <trk>/<rte>'s <name>/<desc>
  when <metadata> lacks them (<metadata> still wins when present).
- that track/route's <cmt> is appended to the description (blank-line
  separated) when present and not already identical (OM BuildDescription
  dedup); used as the description outright when there's no <desc>.

`directChildText` reads direct-child text without relying on `:scope`
(portable across the jsdom + linkedom paths). No GpxData shape change;
well-formed files with metadata are unaffected.

Verified: gpx typecheck+lint clean, 87/87; full `pnpm test` 11/11;
journal + planner typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:23:53 +02:00
Ullrich Schäfer
7c0fa60ae2
Merge pull request #576 from trails-cool/feat/gpx-parser-robustness-lenience
feat(gpx): lenient point parsing + route (<rte>) support
2026-07-14 00:21:29 +02:00
Ullrich Schäfer
ee87a55fdd
test(fit): adapt single-point GPX case to the parser's <2-point drop
CI "Unit Tests" (full monorepo) caught that @trails-cool/fit's round-trip
fixture loop included single-point.gpx, which now parses to zero track
points (the parser drops <2-point segments), so gpxToFitCourse correctly
throws its zero-points guard. Drop single-point.gpx from the round-trip
loop and cover the degenerate case explicitly: a lone point → zero points
→ refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:17:49 +02:00
Ullrich Schäfer
82d726bc14
Merge pull request #577 from trails-cool/feat/gpx-parser-robustness-timestamps
feat(gpx): post-parse timestamp repair
2026-07-14 00:11:14 +02:00
Ullrich Schäfer
4536470eb9
feat(gpx): post-parse timestamp repair
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) <noreply@anthropic.com>
2026-07-14 00:10:57 +02:00
Ullrich Schäfer
99278fd305
feat(gpx): lenient point parsing + route (<rte>) support
Task groups 1–2 of gpx-parser-robustness. The parser trusted its input:
`parseFloat(attr ?? "0")` turned a missing lat/lon into a 0,0 Null Island
point (which passes range validation) and garbage into NaN that poisoned
distance and gain/loss totals; `<rte>`/`rtept` files (Garmin courses, many
exporters) parsed to zero track points and were rejected.

- `parsePoint`: skip a trkpt/rtept whose lat/lon is missing or non-finite
  (no more 0,0 default); a non-finite `<ele>` becomes `undefined` so it
  never leaks NaN into totals. Parsing stays parseFloat-lenient (trailing
  junk like `471.0m` still accepted), gated by Number.isFinite.
- Drop segments left with fewer than 2 points (render nothing / break
  distance math).
- Parse `<rte>` as track segments appended after `<trk>` segments, rtept
  handled identically — route-only files now import.

No GpxData shape change; well-formed files parse identically. Updated the
geom single-point test to the new drop-invariant.

Verified: gpx typecheck + lint clean, 76/76 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:06:35 +02:00