Docs + OpenSpec proposals only (no app/infra code); merged directly to
main via admin override — no CI-relevant surface, no deploy triggers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add docs/inspirations.md as the durable record of the 2026-07-05/06
prior-art research — per-project learnings with source paths, canonical
credit lines, and the changes each spawned — and extend the
acknowledgment lists in philosophy.md/architecture.md (Organic Maps,
Endurain, wanderer).
New OpenSpec changes (proposal/design/specs/tasks each):
- Organic Maps: elevation-profile-hardening, gpx-parser-robustness,
hiking-time-estimate, poi-index, hiking-foot-profile
- Endurain: account-export, activity-duplicate-review,
fit-parsing-hardening, activity-locations, self-hosting-guide,
activity-privacy-controls
- wanderer: federation-hardening, link-share-tokens
- credits-page (user-visible acknowledgments)
Updated in-flight changes with wanderer prior-art sections:
route-federation, route-discovery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing in CI booted the journal's production image. typecheck / lint /
test / build all run against the source tree (where every file is
present), and the e2e job boots the journal via `react-router-serve` —
not the production `node server.ts` entrypoint. The runtime stage of
apps/journal/Dockerfile copies source files in by name, so a refactor
that adds a file server.ts imports without a matching COPY builds green
everywhere and only crash-loops once deployed (ERR_MODULE_NOT_FOUND).
That has taken prod down more than once: app/lib (8631c8f), app/jobs
(e16bd6d), and serve-static.ts (#554/#555, today's outage).
Add a job that builds the `runtime` stage and actually boots it against
a throwaway Postgres, polling /api/health for a 200. A missing static
OR dynamic import never reaches a healthy boot, so this fails the PR
instead of the deploy. Verified locally: the fixed image goes healthy;
the image with the serve-static.ts COPY removed exits with the exact
ERR_MODULE_NOT_FOUND and the job fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #554 extracted serve-static.ts as a sibling of server.ts, but the
journal Dockerfile copies runtime source files explicitly by name and
the COPY list was never updated. The runtime runs `node server.ts`,
whose `import "./serve-static.ts"` then fails with ERR_MODULE_NOT_FOUND,
crash-looping both the production and staging journals (they share the
image). Caddy stayed up with no upstream, so the site hung.
Add the missing COPY line. serve-static.ts imports only node: builtins,
so this single file restores startup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A request for path `//` (also `///`, `/\`, ...) makes `new URL(req.url,
base)` throw ERR_INVALID_URL. serveStatic runs synchronously inside the
createServer callback, so the throw is an uncaught exception that kills
the process. Docker (`unless-stopped`) restarts it, and a client looping
on `//` crash-loops the journal — a trivial unauthenticated DoS. This
fired the "Container restart loop" Grafana alert in production (journal
restarted ~10x in 6 minutes).
Guard the URL parse with try/catch and fall through to the React Router
handler, which 404s malformed paths cleanly (the same way it already
handles scanner probes like /root/.ssh/id_rsa).
Extract serveStatic into its own module so it can be unit-tested without
booting the HTTP server, and add a regression test covering the
malformed-path cases. Widen the journal vitest include to discover
co-located tests for root-level server infra.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routine SDK maintenance bump (catalog 10.57.0 -> 10.58.0). Keeps the
Sentry SDKs current.
Note: this is NOT the fix for the ongoing journal heap growth. Sentry's
recent memory-leak fixes (WeakRef for OTel context on scope, 10.49.0;
WeakRef for Span-Scope circular refs, 10.56.0) are already present in
10.57.0, and 10.58.0 contains no memory-related change. Tracked separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the route-label cardinality fix. The first cut collapsed
dynamic segments via regex (:id/:username/:provider) and capped the
distinct-route set at 200 with a /other overflow. In production that cap
filled almost entirely with vulnerability-scanner junk (`/.ssh/id_rsa`,
`/%00.aws/credentials`, …) on a first-come-first-served basis: only 3 real
templates made it in before the cap saturated, so legitimate routes hit
afterward were misbucketed into /other — the metric became useless for
per-route latency even though memory was bounded.
Replace the regex+cap approach with explicit matching against the journal's
known route templates (mirroring app/routes.ts). A `:param` segment matches
any single path segment; literals are preferred over params (so /routes/new
beats /routes/:id); anything matching no template collapses to /other
immediately — no per-path tracking, no cap race. Cardinality is hard-bounded
to (templates + 1) regardless of traffic, and scanner noise can never crowd
out real routes.
A co-located drift guard flattens the real route config and fails if
ROUTE_TEMPLATES and app/routes.ts ever diverge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The httpRequestDuration histogram labeled every request with the raw URL
path (`url.split("?")[0]`), so every distinct path — `/activities/<uuid>`,
`/routes/<uuid>`, probed usernames, crawler junk — became a permanent
label-set. prom-client never evicts label-sets, so RSS grew linearly for
the life of the process and reset only on restart. On the flagship this
showed as ~25 MiB/day of unbounded journal memory growth; a live
`/api/metrics` scrape held 2,655 histogram series across 291 distinct
`route` values (344 KB body), dominated by per-UUID activity paths.
Add `normalizeRoute()` in metrics.server.ts: it collapses dynamic path
segments back to the `:param` templates declared in app/routes.ts (UUID
and numeric segments -> `:id`; `:username`/`:provider` slots keyed by
their preceding static segment), strips query/fragment, and enforces a
hard cap (MAX_ROUTES) that funnels anything beyond the known route table
into a single `/other` bucket as an absolute backstop. Cardinality is now
proportional to the route table, not to traffic.
Logs still record the raw path (`logger.info`) — only the metric label is
bounded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers routes/activities that enter the journal without BRouter waytags
(imports, uploads, pre-existing rows):
- overpass-ways.server.ts: server-side Overpass client (way[highway] + geom in
a bbox, configurable OVERPASS_URLS, timeout + oversized-bbox guard).
- surface-match.server.ts: pure nearest-way map-matcher → per-segment
surface/highway (unmatched → unknown). Unit-tested.
- surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass
→ match → computeSurfaceBreakdown → store → emit `surface_breakdown` SSE to
the owner. Idempotent, retry-safe, best-effort; registered in server.ts.
- Enqueued from createActivity (imports/uploads) + owner-on-open in the route &
activity detail loaders (non-Planner routes, old rows), deduped via singletonKey.
- useSurfaceBackfillUpdates: detail pages subscribe to /api/events and
revalidate() when their row's backfill lands (live bars, no reload).
- Privacy manifest updated (DE + EN) for the Overpass bbox lookup.
Tests: matchSurfaces unit; surface-backfill job (mocked Overpass/db/events:
store + emit, skip-if-present, skip-if-no-ways). Verified the real
Overpass→match→breakdown pipeline against a live Berlin bbox (509 ways →
plausible asphalt/paving_stones/footway mix). typecheck + lint + unit
(journal 333) green.
Note: the worker runs via server.ts (prod/staging), not `react-router dev`, so
the live job + SSE exercise on a deployed instance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
routeData and sends it; `api.save-to-journal` forwards it; the journal route
callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
legend category · % · km largest-first, unknown → "other", hidden when empty)
on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).
Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.
Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the merged A-only proposal. Broadens the capability:
- breakdown now on routes AND activities;
- Path 1 (sync) unchanged: BRouter waytags → breakdown at Planner save;
- Path 2 (async): a `surface-backfill` pg-boss job map-matches geometry to OSM
via Overpass for imports/uploads/older rows, stores the breakdown, and pushes
a `surface_breakdown` SSE event so an open detail page fills in live;
- privacy note: backfill sends geometry to Overpass → route via the proxy /
self-hostable Overpass; documented in the manifest.
Validates with `openspec validate --strict`. Specs only; no code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface + waytype proportion bars on route detail, from the BRouter waytags the
Planner already computes (and currently discards on save). Design records the
data-source decision: persist a compact distance-weighted breakdown for
Planner-created routes (accurate, no external call); Overpass map-matching for
imports/uploads is parked as a future option.
Validates with `openspec validate --strict`. Specs only; no code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both changes are implemented and merged (#539, #541). Promote their deltas into
openspec/specs/, move the changes to changes/archive/2026-06-14-*, and add the
two capabilities to the CAPABILITIES index.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses feedback that a lone bar conveyed nothing. Rewrite WeeklyDistanceChart
as a proper SVG chart:
- y-scale topped at the busiest week with 0 / half / peak gridlines + km labels;
- faint per-week track columns so the 12-week axis is always visible (empty
weeks read as gaps, not nothing — no more floating bar);
- oldest→newest date bounds on the x-axis;
- a hover readout naming the week + its distance ("Week of {{date}} · X km").
i18n adds profileStats.weekOf. Component test updated for the SVG structure
(bar per non-zero week, peak-topped scale, hover readout). typecheck + lint +
unit (journal 322) green; verified in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the profile-weekly-distance change (specs/profile-weekly-distance):
- getWeeklyDistance(ownerId, { publicOnly, weeks=12 }) in activities.server.ts:
one query that gap-fills in SQL — generate_series of week-starts LEFT JOINed
to activities — so it returns exactly 12 contiguous { weekStart, distance }
rows with matching Postgres week boundaries, viewer-scoped, no cache, no
schema change.
- WeeklyDistanceChart: SVG bars normalized to the busiest week (empty weeks keep
their slot as zero-height bars), per-bar title distance, localized label;
renders nothing when there's no distance in the window. Mounted under the
ProfileStats header.
- i18n journal.profileStats.weeklyDistance (en + de).
Tests: WeeklyDistanceChart component (jsdom: bar count incl. zero weeks, empty
→ hidden, normalization); e2e creates an activity with distance and asserts the
chart renders. typecheck + lint + unit (journal 321) green; verified in the
browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 12-week distance bar chart under the profile stats header, viewer-scoped,
built on profile-stats (#539). Design records the same no-cache rationale: one
indexed grouped aggregate over a 12-week slice (≤12 rows), gap-filled in JS.
Validates with `openspec validate --strict`. Specs only; no code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the profile-stats change (specs/profile-stats):
- getActivityStats(ownerId, { publicOnly }) in activities.server.ts: one indexed
aggregate over stored columns (count, sum distance/ascent/elapsed duration) +
a rolling last-4-weeks count. No cache table, no schema change, no GPX parsing
(design §D1).
- Profile loader computes it scoped to the viewer (public-only for visitors,
full totals for the owner); ProfileStats header renders count · distance ·
ascent · time + "N in the last 4 weeks" via the shared StatRow + stats.ts
formatters; hidden when there are no visible activities.
- i18n journal.profileStats.* in en + de.
Tests: ProfileStats component (jsdom: totals, empty, last-4-weeks toggle);
e2e asserts the owner roll-up counts their activities. typecheck + lint + unit
(journal 318) green; verified in the browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lifetime totals (count · distance · ascent · elapsed time) + a last-4-weeks
count on the profile, viewer-scoped (public-only for visitors, full for the
owner).
Design records the cost decision: compute on the fly with a single aggregate
over stored columns on the owner_id-leading indexes (cheap even for power
users) — no cache table, no schema change — with a documented escape hatch to
a user_activity_stats cache if profiling ever shows it's hot.
Validates with `openspec validate --strict`. Specs only; no code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Map previews were missing on every list view (activities, routes, home,
profile, feed): the batch geojson helpers used `WHERE id = ANY(${ids}::text[])`,
but drizzle expands a JS array in a sql template to `($1,$2,...)`, producing the
invalid `ANY((...)::text[])`. That throws, and the helpers' `try/catch` swallowed
it — so every card fell back to "No map preview".
Rewrite getSimplifiedActivityGeojsonBatch (activities) and getSimplifiedGeojsonBatch
(routes) to use the query builder: `inArray(table.id, ids)` for the id list +
a raw `ST_AsGeoJSON(ST_Simplify(geom, 0.001))` select column. Same result, valid
SQL.
E2E: list-map-previews.test.ts creates an activity with geometry and asserts a
Leaflet preview renders on /activities (no "No map preview"). Registered in
playwright.config. typecheck + lint + unit (journal 315) green; verified in the
browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The home page's activity list (signed-in dashboard + anonymous public feed) was
the one surface still rendering the old `distance ↑ elevation` line, leaving it
inconsistent with the feed, detail, and profile after the activity-stats work.
Adopt the shared SportBadge + compact StatRow on both home card variants;
expose sportType in the home loader projection. No behavior change beyond the
consistent presentation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>