Compare commits

...
Sign in to create a new pull request.

1307 commits

Author SHA1 Message Date
Ullrich Schäfer
76cb64f4ef
Merge pull request #616 from trails-cool/chore/archive-fit-parsing-hardening
chore(openspec): archive fit-parsing-hardening
2026-07-17 00:14:43 +02:00
Ullrich Schäfer
0e04c48225
chore(openspec): archive fit-parsing-hardening
Sync the wahoo-import "FIT to GPX conversion" requirement into the main
spec and move the completed change to
openspec/changes/archive/2026-07-16-fit-parsing-hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:09:27 +02:00
Ullrich Schäfer
e262b671e2
Merge pull request #615 from trails-cool/feat/fit-parsing-hardening
feat(journal): harden FIT→GPX conversion (fit-parsing-hardening)
2026-07-17 00:08:11 +02:00
Ullrich Schäfer
6eea6af673
feat(journal): harden FIT→GPX conversion (pause/session segmentation, validation, sport)
Implements the fit-parsing-hardening change. The shared converter now:
- splits output into <trkseg>s on timer stop/start events, falling back to
  record gaps > 5 min, so downstream moving-time never bridges a pause;
- slices records into per-session windows (multisport → one activity, one
  segment per session), single-session behavior unchanged;
- validates records: finite/in-range coordinates required, timestamp
  required, non-finite altitude dropped (point kept), prefers
  enhanced_altitude;
- returns { gpx, sport }, mapping FIT session sport/sub-sport to a Journal
  SportType (first session wins), consumed by the Wahoo importer + webhook
  and Garmin importer as a fallback when the provider sends no type.

Tests drive the converter via mocked fit-file-parser output (segmentation,
validation, sport mapping, no-GPS) per the chosen fixtures approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:03:25 +02:00
Ullrich Schäfer
1473e2f291
Merge pull request #614 from trails-cool/fix/planner-clear-route-under-two
fix(planner): clear the route when waypoints drop below two
2026-07-16 23:50:21 +02:00
Ullrich Schäfer
0f57e9b9ac
fix(planner): clear the route when waypoints drop below two
Deleting waypoints down to <2 left the last computed route rendered —
nothing cleared the stale geometry (recompute only runs for >=2). Add a
waypoints observer that drops the computed geometry once fewer than two
waypoints remain, via a new clearComputedRoute (keeps the routing
profile, unlike clearRouteData). Covers all delete paths (sidebar, map,
undo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:46:00 +02:00
Ullrich Schäfer
104424d531
Merge pull request #613 from trails-cool/fix/planner-nearby-pois-session
fix(planner): nearby-POI lookup sends the real session (was 401 Unauthorized)
2026-07-16 23:41:58 +02:00
Ullrich Schäfer
267c2fb8b7
fix(planner): nearby-POI lookup sends the real session (was 401)
fetchNearbyPois defaulted sessionId to the placeholder "nearby", which
/api/pois's requireSession rejects (401 Unauthorized) — so the nearby-POI
lookup for a selected waypoint always failed on real deployments. Thread
the live planner sessionId through useNearbyPois → fetchNearbyPois →
queryPois (same session the main POI markers already use), and make
sessionId a required arg so the placeholder can't silently return.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:37:31 +02:00
Ullrich Schäfer
0f371d868f
Merge pull request #612 from trails-cool/feat/planner-route-segment-perf
perf(planner): group colored route into color runs (not one polyline per coordinate)
2026-07-16 08:37:03 +02:00
Ullrich Schäfer
413c46623e
perf(planner): group colored route into color runs, not 1 polyline/coord
ColoredRoute rendered one interactive-registered <Polyline> per
coordinate pair — ~14.6k on a 485 km route — flooding Leaflet's SVG
layer and event-target map. Run-length group consecutive same-color
segments into a single polyline each (new pure buildColorRuns helper,
unit-tested). Discrete modes (surface/highway/…) collapse to a handful;
the elevation gradient is quantized into 24 buckets so its runs merge
too. Rendering is otherwise identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:32:36 +02:00
Ullrich Schäfer
75b28919c4
Merge pull request #611 from trails-cool/fix/planner-waypoint-click-fallthrough
fix(planner): waypoint click + drag (event fell through to map; drag snapped back)
2026-07-16 08:16:33 +02:00
Ullrich Schäfer
c6ae08676b
fix(planner): waypoint drag no longer snaps back on release
Extract the waypoint marker into a React.memo'd component. The marker's
position/icon were new references every render, so any unrelated
PlannerMap re-render during a drag (e.g. the route-hover chart-sync
state update) re-applied the stale saved position mid-drag — orphaning
the drag so it never committed and the pin jumped back on release.
Memoizing means unrelated re-renders no longer touch the marker, so the
drag commits and the moved position persists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:12:06 +02:00
Ullrich Schäfer
6871b154db
fix(planner): clicking a waypoint no longer adds a duplicate
Waypoint markers had no click listener, so Leaflet routed the click to
the map — MapClickHandler then added a new waypoint on top of the one
you clicked (and made markers feel unresponsive). Add a click handler
that consumes the event, so Leaflet treats the marker as the target and
suppresses the map click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:59:48 +02:00
Ullrich Schäfer
23882dae68
Merge pull request #610 from trails-cool/feat/planner-map-markers
feat(planner): restyle map markers + route on tokens
2026-07-16 07:24:39 +02:00
Ullrich Schäfer
7b15ba609f
feat(planner): restyle map markers + route on tokens
Bring the map surface on-brand (visual-redesign group 4):

- Waypoint markers: sage accent numbered circles (was blue #2563eb);
  overnight stops keep the warm stop tone; note indicator uses the
  eg-mid gold token.
- Plain route line + ghost (insert) marker: sage accent (was blue).
- Map highlight dot (synced with the chart hover): sage accent (was
  red), so map and chart hovers match.
- Day labels: bg-raised surface + text-hi.
- No-go areas: danger token (#a03c3c) instead of bright red.
- GPX drag-over overlay: accent tokens.

Per-mode route coloring (surface/grade/…) is unchanged (data-viz);
coordinating the elevation-mode gradient with the route line is task 1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 07:20:17 +02:00
Ullrich Schäfer
92821e6e8b
Merge pull request #609 from trails-cool/feat/planner-elevation-collapsible
feat(planner): collapsible elevation chart with summary bar
2026-07-16 07:11:16 +02:00
Ullrich Schäfer
a520668dc0
Merge pull request #607 from trails-cool/feat/planner-elevation-canvas
feat(planner): restyle elevation chart canvas on tokens
2026-07-16 01:44:50 +02:00
Ullrich Schäfer
a5162fcc90
Merge branch 'main' into feat/planner-elevation-canvas 2026-07-16 01:40:27 +02:00
Ullrich Schäfer
8cbaceb356
feat(planner): collapsible elevation chart with summary bar
Docked-but-collapsible placement (the chosen direction):

- A collapse toggle (chevron) in the chart header; state persists to
  localStorage.
- Collapsed = a thin summary bar: a mini sage sparkline + distance and
  ascent/descent + an expand toggle. Reclaims map space on demand.
- Summary figures come from the same authoritative routeStats the
  sidebar uses (distance, elevationGain, elevationLoss), so the numbers
  match the sidebar exactly — not recomputed from the raw elevation
  points (which over-counted ascent ~2x from noise).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:39:01 +02:00
Ullrich Schäfer
87c06ead2d
Merge pull request #608 from trails-cool/fix/planner-elevation-interaction
fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom button
2026-07-16 01:32:24 +02:00
Ullrich Schäfer
97ddf36621
fix(planner): elevation chart drag survives leaving the canvas; move reset-zoom
- Drag-to-zoom on the chart now tracks via window mousemove/mouseup, so
  it keeps going when the pointer leaves the chart and still completes if
  released outside it (previously mouseleave aborted the drag and an
  outside release was lost). Listeners are detached on mouseup/unmount.
- Move the "reset zoom" button from top-right (which overlapped the
  color-mode dropdown) to the bottom-right of the chart, and restyle it
  on tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:27:45 +02:00
stigi
0b157ce96e chore: update visual snapshots [skip ci] 2026-07-15 23:22:05 +00:00
Ullrich Schäfer
c38e054088
feat(planner): restyle elevation chart canvas on tokens
Bring the chart's chrome onto the design system (spec task 5.1):

- Plain mode: sage accent line + soft accent→transparent gradient fill
  (was off-palette blue).
- Hover: calm near-black crosshair + accent dot with a light ring, mono
  label (was red).
- Axis + day-divider labels: token colors, Geist Mono; dividers use the
  border-md token.
- Drag-select: accent tint (was blue).

Per-mode data-viz palettes (grade, surface, …) are unchanged. Darwin
visual-regression baselines regenerated; linux baselines to follow via
the update-snapshots workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:20:36 +02:00
Ullrich Schäfer
6d905c152f
Merge pull request #606 from trails-cool/feat/planner-computing-overlay
feat(planner): move "computing route" indicator from topbar to a map overlay
2026-07-16 01:16:09 +02:00
Ullrich Schäfer
d2d2d057cd
Merge pull request #604 from trails-cool/feat/planner-colormode-select
feat(planner): restyle color-mode selector + chart header on tokens
2026-07-16 01:07:11 +02:00
Ullrich Schäfer
24913b9f3e
Merge branch 'main' into feat/planner-colormode-select 2026-07-16 01:03:09 +02:00
Ullrich Schäfer
46018c9f89
feat(planner): move "computing route" from topbar to a map overlay
The topbar's "Route wird berechnet…" text appeared/disappeared with
routing, shifting the topbar layout. Move it onto the map as a floating
status pill (spinner + label, top-center, pointer-events-none) that
appears temporarily without affecting the topbar. Restyle the ambient
top-edge progress bar to accent tokens. Drop the now-unused `computing`
prop from Topbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:01:08 +02:00
Ullrich Schäfer
d17d4bc3a4
Merge pull request #602 from trails-cool/feat/topbar-button-restyle
feat(planner): restyle Export + SaveToJournal buttons on tokens
2026-07-16 00:49:09 +02:00
Ullrich Schäfer
079ce5aba2
Merge branch 'main' into feat/planner-colormode-select 2026-07-16 00:44:42 +02:00
Ullrich Schäfer
c504d6e34b
Merge branch 'main' into feat/topbar-button-restyle 2026-07-16 00:44:25 +02:00
Ullrich Schäfer
8eae92b8a2
Merge pull request #605 from trails-cool/feat/planner-sidebar
feat(planner): restyle sidebar on tokens (waypoints, notes, days)
2026-07-16 00:43:34 +02:00
Ullrich Schäfer
3603624b70
fix(planner): distinct aria-label for export dropdown toggle
The restyled Export split-button's dropdown toggle got aria-label
"Export GPX", colliding with the main button's text and breaking the
E2E selector that clicked the old "▾" glyph. Give it a dedicated
exportOptions label (en + de) and target it in the E2E test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:43:30 +02:00
Ullrich Schäfer
1d3ba956cb
feat(planner): restyle Export + SaveToJournal buttons on tokens
Bring the two slotted topbar actions onto the design system so the bar
reads as one cohesive surface:

- SaveToJournalButton: primary Button primitive; "saved" uses the
  accent, error uses a new --color-danger token, and the return link
  gets secondary token styling.
- ExportButton: token split-button (secondary look, chevron icon,
  single divider) and a token dropdown menu (raised surface, soft
  shadow, muted descriptions).
- New --color-danger token (#a03c3c, the no-go hue at full strength)
  for error text.

Behavior and i18n unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:42:37 +02:00
Ullrich Schäfer
b09d810301
feat(planner): restyle sidebar on tokens (waypoints, notes, days)
Migrate the whole sidebar surface onto the design system:

- SidebarTabs: token tab bar, sage active indicator.
- WaypointSidebar: token surfaces/text; sage number badges; overnight
  marker uses the Badge (stop tone) primitive; route summary + stats
  footer in Geist Mono; destructive delete signalled via bg-nogo.
- NotesPanel + DayBreakdown headers/rows on tokens.
- i18n the previously-hardcoded empty-state string (en + de).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:39:12 +02:00
Ullrich Schäfer
28f92d0d8f
feat(planner): restyle color-mode selector + chart header on tokens
The elevation chart's 10-mode color selector now uses the Select
primitive (token-styled, consistent with ProfileSelector). Chart-header
chrome (top border, title link, legend text) moved onto tokens too.

Kept as a dropdown rather than a segmented control — 10 modes don't fit
a segmented toggle. Canvas/legend data-viz colors are unchanged (task
1.5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:32:05 +02:00
Ullrich Schäfer
2bc6afcf4f
Merge pull request #603 from trails-cool/feat/planner-profile-selector
feat(ui): Select primitive; restyle ProfileSelector on tokens
2026-07-16 00:16:40 +02:00
Ullrich Schäfer
a56445daf3
feat(ui): Select primitive; restyle ProfileSelector on tokens
- New Select primitive in @trails-cool/ui: token-styled native <select>
  (appearance-none + overlaid chevron for a consistent closed control),
  sm/md sizes, sibling of Input. Unit-tested + added to /dev/ui.
- ProfileSelector uses Select (size sm) with token label; the profile
  label now hides on small screens. Completes the topbar's migration
  onto the design system.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:12:23 +02:00
Ullrich Schäfer
bf73feb75a
Merge pull request #601 from trails-cool/fix/dev-ui-scroll
fix(planner): make the /dev/ui gallery scrollable
2026-07-16 00:01:47 +02:00
Ullrich Schäfer
723cd8069b
fix(planner): make the /dev/ui gallery scrollable
The app body is h-screen overflow-hidden (for the full-screen map
editor), which clipped the taller gallery so it couldn't scroll. Give
the gallery route its own full-height scroll container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:57:44 +02:00
Ullrich Schäfer
5e75894372
Merge pull request #600 from trails-cool/feat/planner-topbar
feat(planner): redesign topbar with tokens + primitives
2026-07-15 23:55:59 +02:00
Ullrich Schäfer
4f0d4a34b3
feat(planner): redesign topbar with tokens + primitives, presentational
Extract a presentational Topbar driven by plain props, so SessionView
is the container (reads Yjs/awareness) and the topbar can render in the
/dev/ui gallery without a live session.

- New useParticipants(yjs) hook: awareness -> sorted Participant[] +
  renameLocal. Replaces ParticipantList's inline logic.
- New presentational ParticipantAvatars (Avatar + Host Badge + inline
  rename) and Topbar (token-styled shell; undo/redo via IconButton;
  connection status with a live dot). ProfileSelector/Export/Save are
  passed in as slots (their own restyle is separate).
- SessionView composes <Topbar> from real data; delete ParticipantList.
- /dev/ui gains a "Topbar - configurations" section (solo, multiplayer
  + computing, guest/connecting). Surfaced a narrow-width overflow, now
  fixed by making the participant strip the shrink/clip element.

Connection status text ("Connected"/"Verbunden") and behavior are
unchanged, so existing planner E2E selectors still match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:51:27 +02:00
Ullrich Schäfer
9463c5b1e5
Merge pull request #599 from trails-cool/feat/ui-primitives-2
feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
2026-07-15 23:47:48 +02:00
Ullrich Schäfer
1bd5f18a33
feat(ui): topbar primitives — IconButton, SegmentedControl, Avatar
Adds the shared primitives the planner topbar redesign needs, on the
design-system tokens:

- IconButton — icon-only button (undo/redo), ghost/secondary variants,
  requires an accessible label.
- SegmentedControl — generic single-select toggle (color mode
  Plain/Elevation/Surface), radiogroup semantics.
- Avatar — colored initial circle for participants, per-user color with
  an accent fallback.

Co-located jsdom unit tests for each (roles, aria-checked, onChange,
disabled, color fallback). Extends the dev-only /dev/ui gallery with
sections for all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:43:14 +02:00
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
Ullrich Schäfer
e2f25e1d27
Merge pull request #575 from trails-cool/chore/archive-federation-hardening
chore(openspec): archive federation-hardening
2026-07-13 23:59:00 +02:00
Ullrich Schäfer
42149d8e02
chore(openspec): archive federation-hardening
All 13 tasks complete (implementation shipped in #570–573; staging
verification in #574). Archive via `openspec archive` (CLI 1.6.0):

- Creates openspec/specs/federation-operations/spec.md — the new
  capability: durable federation queue, inbound replay defense, instance
  blocklist, published protocol doc, delivery observability (Purpose
  filled in; CLI leaves a TBD placeholder).
- Applies the MODIFIED requirement to openspec/specs/social-federation:
  "Push delivery on local activity create" now guarantees persistent
  queueing + a "Fan-out survives a deploy" scenario.
- Moves the change to openspec/changes/archive/2026-07-13-federation-hardening/.

Both specs pass `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:54:56 +02:00
Ullrich Schäfer
55da03132a
Merge pull request #574 from trails-cool/chore/federation-hardening-5.1-verified
chore(federation-hardening): 5.1 verified on staging
2026-07-13 23:51:41 +02:00
Ullrich Schäfer
cca1428f26
chore(federation-hardening): 5.1 verified on staging
Ran the post-deploy staging check on staging.trails.cool:
- Durability: enqueued a real delivery, restarted the journal mid-flight;
  the job survived the restart (still `created` in Postgres afterward)
  and completed after — "Successfully sent activity … to
  social.ullrich.is/users/ullrich/inbox", federation_delivery_total
  {outcome="delivered"}=1, queue drained to 0. The old in-process queue
  would have dropped it.
- Blocklist outbound: a poll-remote-actor for a blocked domain returned
  {skipped: "blocked instance"} with no network fetch.
- Operator block/unblock procedure works against the live staging DB.

Inbound-drop leg is covered by the group-2/3 real-Postgres integration
tests + the two-instance e2e harness (firing it live needs a
peer-initiated signed request). All 13 tasks now complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:47:32 +02:00
Ullrich Schäfer
b1f5f853c3
Merge pull request #573 from trails-cool/feat/federation-hardening-docs-observability
feat(journal): federation protocol doc + delivery observability
2026-07-13 23:16:59 +02:00
Ullrich Schäfer
711065586e
chore(federation-hardening): record group 5 verification status
5.2 typecheck/lint/test pass locally; test:e2e is enforced by the
required "E2E Tests" CI check. 5.1 is a post-deploy staging check
(fan-out survives a restart; a blocked domain is inert both ways) —
left open with the runbook commands in the PR body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:13:04 +02:00
Ullrich Schäfer
881991ca18
feat(journal): federation protocol doc + delivery observability
Task group 4 of federation-hardening.

4.1 — FEDERATION.md at the repo root: actor discovery (WebFinger, actor,
NodeInfo), object/activity types with real JSON examples (Note, Create,
Delete, the narrow follow-graph inbox), addressing, HTTP-Signature
expectations, the two-layer dedup contract, durable delivery/retry
policy, and blocklist moderation semantics — precise enough for another
implementation to interoperate. Linked from README and docs/architecture.

4.2 — three prom-client metrics + a journal dashboard row:
- `federation_delivery_total{outcome}` — incremented in deliver-activity
  (delivered/skipped/failed).
- `federation_inbox_dropped_total{reason}` — incremented at every inbox
  drop (duplicate | blocked); this is the counter deferred from task 3.2.
- `federation_queue_depth` — gauge sampled at scrape time in
  /api/metrics from PgBossMessageQueue.getDepth(); the restart-loss
  regression detector.
Grafana journal.json gains a Federation row (delivery rate, queue depth,
inbox drops); the logs panels shift down to make room.

Verified: dashboard JSON valid; journal typecheck + lint clean; unit
suite 357 passing (route-template guard unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:43 +02:00
Ullrich Schäfer
8f7fd15685
Merge pull request #572 from trails-cool/feat/federation-hardening-blocklist
feat(journal): federation instance blocklist
2026-07-13 23:03:40 +02:00
Ullrich Schäfer
57696286e4
feat(journal): federation instance blocklist
Task group 3 of federation-hardening. There was no blocklist of any kind;
the only lever against a hostile instance was an IP/host block in Caddy.

- New `federation_blocked_instances` table (domain PK, reason,
  created_at). Additive → created by drizzle-kit push.
- `federation-blocklist.server.ts`: exact-host matching —
  `isBlockedDomain`, `isBlockedIri` (unparseable IRI ⇒ treated as
  blocked), and `filterBlockedDomains` for batch recipient filtering.
- Enforced at all three boundaries (spec: federation-operations
  "Instance blocklist"):
  - inbox — each of the 4 listeners silently drops a blocked actor's
    activity (202, no error oracle) before dedup/side effects;
  - delivery enqueue — `enqueueActivityDeliveries` filters blocked
    recipients in one batch query;
  - outbox poll / actor fetch — `pollRemoteActor` refuses a blocked host
    up front (`skipped: "blocked instance"`), before any network.
- Operator procedure (SQL insert/list/delete) documented in the
  deployment runbook's federation section.
- Tests: unit (hostOfIri) + integration against real Postgres covering
  the helper and the delivery + outbox boundaries; inbox uses the same
  tested isBlockedIri primitive.

Note: the inbox-drop *counter* (federation_inbox_dropped_total{reason})
lands with the other metrics in task 4.2; this commit is the enforcement.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; blocklist integration tests green against real Postgres;
journal unit suite 357 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:59:11 +02:00
Ullrich Schäfer
3bd1f87e63
Merge pull request #571 from trails-cool/feat/federation-hardening-replay-blocklist
feat(journal): inbound federation replay defense
2026-07-13 22:55:08 +02:00
Ullrich Schäfer
105659df7c
feat(journal): inbound federation replay defense
Task group 2 of federation-hardening. The narrow inbox
(Follow/Undo/Accept/Reject) had no replay protection — only Create(Note)
did, via the activities.remote_origin_iri unique constraint. A remote
redelivering a signed follow-graph activity would re-run its side
effects.

- New `federation_processed_activities` table (activity IRI PK,
  received_at + index). Additive, so drizzle-kit push creates it; no
  hand-written migration needed.
- `federation-replay.server.ts`: `markInboundActivityProcessed` does an
  insert-or-drop (ON CONFLICT DO NOTHING RETURNING) and reports whether
  the IRI is fresh; `sweepProcessedActivities` deletes rows > 30 days old
  (signature date-freshness already rejects older replays).
- Each inbox listener drops a duplicate before side effects. The
  follow-graph handlers are idempotent, so a handler failure whose retry
  is later dropped as a duplicate can't corrupt state.
- `federation-dedup-sweep` job (daily 04:30 UTC) runs the TTL sweep.

Verified: db + journal typecheck + lint clean; drizzle-kit push creates
the table; replay integration test (fresh-vs-duplicate + 30-day sweep)
green against real Postgres; journal unit suite 355 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:07:30 +02:00
Ullrich Schäfer
71d92306db
Merge pull request #570 from trails-cool/fix/federation-durable-queue
fix(journal): durable Fedify message queue over pg-boss
2026-07-13 21:54:51 +02:00
Ullrich Schäfer
b8fc8fadff
feat(journal): durable Fedify message queue over pg-boss
Task group 1 of federation-hardening. Fedify was configured with
`InProcessMessageQueue`, so every queued outbound delivery, its pending
retry state, and inbox processing task was lost on a container restart
(routine here: deploys, OOM history) — directly contradicting the
social-federation spec's promise that fan-out survives a deploy.

- `federation-queue.server.ts`: `PgBossMessageQueue` implementing Fedify's
  `MessageQueue` over the pg-boss instance the journal already runs,
  mirroring the `PostgresKvStore` adapter. `nativeRetrial = false` keeps
  Fedify the retry-policy owner; pg-boss supplies durability + delayed
  jobs (delay → whole-second `startAfter`, `retryLimit: 0`).
- Swap it in for `InProcessMessageQueue` in `federation.server.ts`;
  document the two intentional queueing layers (our fan-out jobs feed
  Fedify; Fedify's sends now durable underneath).
- `server.ts`: create the durable queue at startup when federation is on.
- Broaden the structural `BossLike` in `boss.server.ts` with the
  work/offWork/createQueue/getQueue methods the adapter needs.
- Tests: enqueue maps retry/delay correctly, consume roundtrip, restart
  durability (fresh listener drains a prior instance's backlog), abort
  stops the worker, depth reports ready vs delayed.

Verified: journal typecheck + lint clean, 7/7 new unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:46:46 +02:00
Ullrich Schäfer
1ea2559246
Merge pull request #569 from trails-cool/chore/archive-route-surface-breakdown
chore(openspec): archive route-surface-breakdown
2026-07-13 21:25:48 +02:00
Ullrich Schäfer
fc5e361a11
chore(openspec): archive route-surface-breakdown
The route-surface-breakdown change is fully implemented (18/18 tasks,
all artifacts done). Archive it via `openspec archive` (CLI 1.6.0):

- Moves the change to openspec/changes/archive/2026-07-13-route-surface-breakdown/
- Promotes its 5 delta requirements into a new capability spec at
  openspec/specs/route-surface-breakdown/spec.md (surface/waytype
  breakdown from BRouter waytags, async Overpass backfill, live SSE
  update, proportion bars, localized labels)

Purpose paragraph filled in (the CLI leaves a TBD placeholder). Both the
spec and archived change pass `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:21:58 +02:00
Ullrich Schäfer
ea10e9dd19
Merge pull request #568 from trails-cool/ci/dependabot-openspec-autofix
ci(dependabot): regenerate OpenSpec tool files on bump
2026-07-13 21:17:26 +02:00
Ullrich Schäfer
52aada6242
Merge remote-tracking branch 'origin/ci/dependabot-openspec-autofix' into ci/dependabot-openspec-autofix 2026-07-13 21:12:04 +02:00
Ullrich Schäfer
fd80815119
ci: rename workflow file to match its name (dependabot-auto-fix.yml)
The workflow was renamed "Dependabot dedupe" -> "Dependabot auto-fix" in
the previous commit; rename the file to match and fix the self-reference
in its error message. Safe — this workflow is not a required status check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:10:37 +02:00
Ullrich Schäfer
47be98bd51
Merge branch 'main' into ci/dependabot-openspec-autofix 2026-07-13 21:09:10 +02:00
Ullrich Schäfer
905c2d83b6
ci(dependabot): regenerate OpenSpec tool files on bump
Extends the dependabot auto-fix workflow (formerly "Dependabot dedupe")
to also run `openspec update --force`, so a @fission-ai/openspec bump
regenerates the generated agent skills (.agents/skills/openspec-*) and
opsx slash commands (.claude/commands/opsx/*) and pushes them back to the
PR branch — the manual step that PR #567 had to do by hand for 1.2.0 ->
1.6.0.

Kept as a single workflow (one checkout/commit/push) so the dedupe and
openspec fixups don't race to push the same branch. The commit message
names only the parts that actually changed; no-op when neither applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:06:57 +02:00
Ullrich Schäfer
8b7420be19
Merge pull request #567 from trails-cool/chore/openspec-1.6-tooling
chore(openspec): regenerate skills + commands for CLI 1.6.0
2026-07-13 21:03:31 +02:00
Ullrich Schäfer
c0bb992d6b
chore(openspec): regenerate skills + commands for CLI 1.6.0
Regenerated the OpenSpec agent skills (.agents/skills/openspec-*) and
opsx slash commands (.claude/commands/opsx/*) from openspec CLI 1.6.0
(was 1.2.0). Adds `allowed-tools` frontmatter, multi-store selection
support (`--store <id>`), and new status fields (planningHome,
changeRoot, artifactPaths, actionContext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:59:01 +02:00
Ullrich Schäfer
614e577e26
Merge pull request #566 from trails-cool/fix/dependabot-security-overrides
fix(deps): resolve 12 Dependabot alerts via pnpm overrides
2026-07-13 20:12:04 +02:00
Ullrich Schäfer
b4f290ceed
fix(deps): resolve 12 Dependabot alerts via pnpm overrides
All 12 open Dependabot alerts are transitive dependencies, so resolve
them with pnpm.overrides (the pattern already used in this repo for
picomatch, brace-expansion, path-to-regexp, lodash).

- shell-quote <1.8.4 -> 1.8.4         (critical, #22; react-devtools)
- undici 7.x <7.28.0 -> 7.28.0        (high/med/low ×6, #30-36; jsdom/vitest)
- form-data <4.0.6 -> 4.0.6           (high, #28; jest-expo)
- js-yaml <3.15.0 -> 3.15.0           (medium, #37; jest istanbul)
- esbuild 0.27.x -> 0.28.1            (low, #23; vite/tsx)
- @opentelemetry/core <2.8.0 -> 2.9.0 (medium, #25; fedify runtime)
- uuid 7.0.3 -> 11.1.1                (medium, #19; xcode/expo prebuild)

Notes:
- undici override is scoped to 7.x so fedify's undici@6.27.0 (not in any
  vulnerable range) is left untouched.
- @opentelemetry/core pinned to 2.9.0 (not the minimum 2.8.0) to dedupe
  with the copy Sentry already pulls in, avoiding an otel version skew.
- uuid pinned narrowly to the vulnerable 7.0.3; xcode calls
  require('uuid').v4(), which still works under v11 (verified).

Verified: pnpm install / build / typecheck / test all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:07:26 +02:00
Ullrich Schäfer
004ec4914f
Merge pull request #561 from trails-cool/dependabot/npm_and_yarn/production-ba42795ad1
build(deps): bump the production group across 1 directory with 35 updates
2026-07-13 09:35:19 +02:00
Ullrich Schäfer
d9eee37e39
build(deps): bump the production group across 1 directory with 35 updates
Bumps 35 dependencies in the production group; lockfile regenerated and deduped against latest main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:29:56 +02:00
Ullrich Schäfer
8af5ea47e0
Merge pull request #559 from trails-cool/dependabot/npm_and_yarn/vite-8.1.0
build(deps): bump vite from 7.3.5 to 8.1.0
2026-07-13 08:53:42 +02:00
Ullrich Schäfer
d98dd3c8f4
build(deps): bump vite from 7.3.5 to 8.1.0
Bumps vite catalog to ^8.1.0 and regenerates the lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:49:22 +02:00
Ullrich Schäfer
79addc8f5a
Merge pull request #557 from trails-cool/dependabot/github_actions/actions/cache-6
build(deps): bump actions/cache from 5 to 6
2026-07-13 08:48:56 +02:00
Ullrich Schäfer
ae9c686050
build(deps): bump actions/cache from 5 to 6
Bumps actions/cache from 5 to 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:44:37 +02:00
Ullrich Schäfer
aa515e5fa0
Merge pull request #565 from trails-cool/archive-poi-index
Archive poi-index + sync specs
2026-07-13 01:51:07 +02:00
Ullrich Schäfer
8396041c26
openspec: archive poi-index; sync deltas into canonical specs
poi-index shipped and is live in production (self-hosted planet POI index,
8.4M rows serving /api/pois; Overpass removed from the Planner). Archive the
change and apply its spec deltas:

- new capability: poi-index
- osm-poi-overlays: POIs from the instance index, not Overpass
- rate-limiting: /api/pois limit replaces the Overpass proxy limit
- infrastructure: POI extract (BRouter host) + import (flagship) components

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:46:13 +02:00
Ullrich Schäfer
5539b34613
poi-index: mark operational tasks done (planet extract + import verified in prod)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:43:36 +02:00
Ullrich Schäfer
b7c3297d4a
Merge pull request #564 from trails-cool/fix-poi-import-dedup
Fix poi-import: dedupe elements matching a category via multiple selectors
2026-07-13 00:33:46 +02:00
Ullrich Schäfer
55796e7d15
poi-index: dedupe classify INSERT (element matching a category via 2 selectors)
The import hit a PK violation on (osm_type, osm_id, category): an OSM element
that matches one category through two selectors on different keys (e.g. shelter
= amenity=shelter OR tourism=wilderness_hut, both present) produced two rows for
that category. Add DISTINCT ON (osm_type, osm_id, category) so classification is
one row per (element, category), matching matchingCategoryIds semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:33:17 +02:00
Ullrich Schäfer
7d6a6147a0
Merge pull request #563 from trails-cool/fix-poi-import-selectors
Fix poi-import: stream selectors SQL into psql stdin
2026-07-13 00:29:41 +02:00
Ullrich Schäfer
a6f8595c4b
poi-index: stream selectors SQL into psql stdin (fix \i container path)
poi-import.sh ran the classifier selectors via psql \i, but psql executes
inside the postgres container (docker compose exec), so it couldn't find the
host path /opt/trails-cool/scripts/poi-selectors.sql. Concatenate BEGIN + the
selectors file contents + the build statements on the host and pipe them into
psql stdin instead, keeping the ON COMMIT DROP temp table in one transaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:27:48 +02:00
Ullrich Schäfer
552899e2b7
Merge pull request #562 from trails-cool/poi-index
poi-index: replace Overpass with a self-hosted POI index
2026-07-13 00:07:32 +02:00
Ullrich Schäfer
115c76a69a
poi-index: drop vSwitch mentions from poi-extract README (flagship detail)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:30:19 +02:00
Ullrich Schäfer
4935e5beae
poi-index: generalize poi-extract docs/units (drop host/username specifics)
Remove ullrich.is / 1.8 TB / hardcoded 'trails' username assumptions from the
poi-extract README and service unit; refer to 'the pipeline user' and $USER /
%h instead so the docs serve self-hosters too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:29:45 +02:00
Ullrich Schäfer
9dec68175b
poi-index: drop specific host disk figure from README (drifts; check df at runtime)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:18:34 +02:00
Ullrich Schäfer
1b5f48f118
poi-index: run osmium via Docker (host has no osmium + no sudo)
The BRouter host's trails user is non-root without sudo, so osmium-tool can't
be apt-installed. Ship osmium.Dockerfile (built on first use) and run osmium in
a container with the work dir bind-mounted (native I/O, negligible overhead on
Linux). python3/curl/gzip/sha256sum remain on the host. Correct the README
disk figure to the /home free space (~595 GB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:16:57 +02:00
Ullrich Schäfer
157a4fad64
poi-index: ship poi-extract/ to the BRouter host via cd-brouter tarball
cd-brouter.yml bundles an explicit file list; the new poi-extract dir was
missing, so the extract scripts would never land on the host. Add it (+ a
defensive chmod).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:12:55 +02:00
Ullrich Schäfer
2a59ad67d9
poi-index: mark 9.2 — planner e2e green (POI tests pass on warm server)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:05:50 +02:00
Ullrich Schäfer
bc4bb7988d
poi-index: mark 2.2 done — verified index-driven query plans locally
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:02:23 +02:00
Ullrich Schäfer
b1aa687c51
poi-index: name pois PK explicitly (pois_pkey) for drift-free swap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:55:49 +02:00
Ullrich Schäfer
96de8831cf
poi-index: Grafana dashboard/alert, docs, privacy manifest
- planner dashboard: replace Overpass panels with POI index freshness +
  serving panels (age, rows/category, import status, API request rate/errors)
- alerts: replace overpass-upstream-unhealthy with poi-index-stale (>6 weeks)
- architecture.md: POI data flow now the self-hosted index
- privacy manifest (DE+EN): Overpass is Journal-surface-backfill-only;
  Planner POIs served same-origin from /api/pois
- self-host-overpass README + roadmap: superseded for POIs by poi-index
- poi-extract README: self-hoster story (optional pipeline, regional extract,
  graceful empty index)
- map-core tsconfig: exclude *.sync.test.ts from tsc to keep it zero-dep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:54:51 +02:00
Ullrich Schäfer
e703843b9b
poi-index: extract pipeline (BRouter host) + import job (flagship) + e2e
- poi-extract.sh: PBF download -> osmium tags-filter -> centroid NDJSON +
  manifest, published via the Caddy sidecar at vSwitch-only /poi/*
- osmium-filters.txt + poi-selectors.sql generated from map-core selectors,
  guarded by sync tests so poiCategories stays the single source of truth
- poi-import.sh: checksum verify -> classify into category rows ->
  70% guard (--bootstrap override) -> atomic rename swap; node_exporter
  textfile metric for import outcome
- systemd service+timer units for both halves (monthly, offset)
- cd-infra.yml: ship infrastructure/scripts to the flagship
- e2e: mock /api/pois instead of /api/overpass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:48:32 +02:00
Ullrich Schäfer
b45e69885d
poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics
Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:38:03 +02:00
Ullrich Schäfer
7dd9ccd2b0 Merge branch 'inspirations-research-proposals'
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>
2026-07-06 07:24:00 +02:00
Ullrich Schäfer
5dd4968626 docs+openspec: prior-art research (Organic Maps, Endurain, wanderer) and 15 proposals
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>
2026-07-06 07:23:00 +02:00
Ullrich Schäfer
be4f7e4ae8
Merge pull request #556 from trails-cool/ci-journal-image-smoke-test
ci: smoke-test the journal production Docker image
2026-06-25 09:42:10 +02:00
Ullrich Schäfer
7eb2a20700 ci: smoke-test the journal production Docker image
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>
2026-06-25 09:37:17 +02:00
Ullrich Schäfer
ad35b3adcf
Merge pull request #555 from trails-cool/fix-journal-dockerfile-missing-serve-static
fix(journal): copy serve-static.ts into the runtime image
2026-06-25 09:27:22 +02:00
Ullrich Schäfer
4fe5382b4b fix(journal): copy serve-static.ts into the runtime image
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>
2026-06-25 09:23:15 +02:00
Ullrich Schäfer
96bca827ea
Merge pull request #554 from trails-cool/fix-journal-malformed-url-crash
fix(journal): don't crash the process on a malformed request URL
2026-06-24 15:46:09 +02:00
Ullrich Schäfer
26d45cf2cb fix(journal): don't crash the process on a malformed request URL
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>
2026-06-24 15:41:26 +02:00
Ullrich Schäfer
3d7dfda45f
Merge pull request #553 from trails-cool/dependabot/npm_and_yarn/nodemailer-9.0.1
build(deps): bump nodemailer from 8.0.11 to 9.0.1
2026-06-21 19:08:41 +02:00
dependabot[bot]
8b51e3ccca
build(deps): bump nodemailer from 8.0.11 to 9.0.1
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.11 to 9.0.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 17:04:34 +00:00
Ullrich Schäfer
316a368307
Merge pull request #551 from trails-cool/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-06-21 19:02:40 +02:00
Ullrich Schäfer
da310af684
Merge pull request #552 from trails-cool/dependabot/npm_and_yarn/production-7d38d0195e
build(deps): bump the production group with 18 updates
2026-06-21 19:02:27 +02:00
dependabot[bot]
9a6041c4be [github-actions] pnpm dedupe 2026-06-21 08:33:05 +00:00
dependabot[bot]
ae585290be
build(deps): bump the production group with 18 updates
Bumps the production group with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.61.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.4` | `2.1.5` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.42` | `5.1.43` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.5.0` | `3.5.1` |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `56.0.11` | `56.0.12` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.17` | `56.0.18` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.17` | `56.0.18` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.10` | `56.2.11` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.8` | `4.1.9` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.8` | `4.1.9` |
| [@garmin/fitsdk](https://github.com/garmin/fit-javascript-sdk) | `21.205.0` | `21.208.0` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.19.1` | `12.20.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.58.0` | `10.59.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.59.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.21` | `22.20.0` |


Updates `@playwright/test` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `playwright` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `typescript-eslint` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

Updates `@logtape/logtape` from 2.1.4 to 2.1.5
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.5/packages/logtape)

Updates `isbot` from 5.1.42 to 5.1.43
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.42...v5.1.43)

Updates `@sentry/cli` from 3.5.0 to 3.5.1
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.5.0...3.5.1)

Updates `expo` from 56.0.11 to 56.0.12
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `expo-location` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.10 to 56.2.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `@vitest/browser` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/browser-playwright)

Updates `@garmin/fitsdk` from 21.205.0 to 21.208.0
- [Release notes](https://github.com/garmin/fit-javascript-sdk/releases)
- [Commits](https://github.com/garmin/fit-javascript-sdk/compare/21.205.0...21.208.0)

Updates `pg-boss` from 12.19.1 to 12.20.0
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.19.1...12.20.0)

Updates `@sentry/node` from 10.58.0 to 10.59.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.58.0...10.59.0)

Updates `@sentry/react` from 10.53.1 to 10.59.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.59.0)

Updates `@types/node` from 22.19.21 to 22.20.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: playwright
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo
  dependency-version: 56.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@garmin/fitsdk"
  dependency-version: 21.208.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 08:32:18 +00:00
dependabot[bot]
2dc88197bc
build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 08:22:41 +00:00
Ullrich Schäfer
89861d7f18
Merge pull request #550 from trails-cool/chore-bump-sentry-10.58
chore(deps): bump @sentry/node + @sentry/react to ^10.58.0
2026-06-19 11:25:58 +02:00
Ullrich Schäfer
bfcb2584be chore(deps): bump @sentry/node and @sentry/react to ^10.58.0
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>
2026-06-19 11:21:52 +02:00
Ullrich Schäfer
ce00376f3b
Merge pull request #549 from trails-cool/fix-journal-metrics-template-match
fix(journal): match metric route label against known route templates
2026-06-19 11:05:03 +02:00
Ullrich Schäfer
5643ce257a fix(journal): match metric route label against known templates
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>
2026-06-19 11:00:03 +02:00
Ullrich Schäfer
e6b88ff294
Merge pull request #548 from trails-cool/fix-journal-metrics-route-cardinality
fix(journal): bound Prometheus route label cardinality (memory leak)
2026-06-18 16:02:30 +02:00
Ullrich Schäfer
cd73093beb fix(journal): bound Prometheus route label cardinality
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>
2026-06-18 15:58:01 +02:00
Ullrich Schäfer
9266233614
Merge pull request #547 from trails-cool/apply-surface-breakdown-phase2
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
2026-06-14 20:35:45 +02:00
Ullrich Schäfer
f6da405d2b
Merge branch 'main' into apply-surface-breakdown-phase2 2026-06-14 20:31:35 +02:00
Ullrich Schäfer
f98a72532e
Merge pull request #546 from trails-cool/apply-surface-breakdown-phase1
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
2026-06-14 20:31:04 +02:00
Ullrich Schäfer
279734c607
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
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>
2026-06-14 19:18:03 +02:00
Ullrich Schäfer
3a1c34317d
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
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>
2026-06-14 18:46:49 +02:00
Ullrich Schäfer
6feab65ebb
Merge pull request #545 from trails-cool/revise-route-surface-breakdown-backfill
openspec: route-surface-breakdown — add async Overpass backfill + SSE
2026-06-14 18:29:54 +02:00
Ullrich Schäfer
e1e3dc5c81
openspec: revise route-surface-breakdown — add async Overpass backfill + SSE
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>
2026-06-14 17:54:50 +02:00
Ullrich Schäfer
a81134332b
Merge pull request #544 from trails-cool/propose-route-surface-breakdown
openspec: propose route-surface-breakdown (surface/waytype bars)
2026-06-14 17:27:58 +02:00
Ullrich Schäfer
47f185598d
openspec: propose route-surface-breakdown
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>
2026-06-14 17:13:44 +02:00
Ullrich Schäfer
ce9b5f5ae8
Merge pull request #543 from trails-cool/opsx-archive-profile
openspec: archive profile-stats + profile-weekly-distance
2026-06-14 17:07:15 +02:00
Ullrich Schäfer
c1d8fdc3fb
Merge pull request #542 from trails-cool/dependabot/npm_and_yarn/production-650660aaaa
build(deps): bump the production group with 6 updates
2026-06-14 16:35:20 +02:00
Ullrich Schäfer
2ad9898f9c
openspec: archive profile-stats + profile-weekly-distance, sync specs
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>
2026-06-14 16:34:06 +02:00
dependabot[bot]
070cec00a5 [github-actions] pnpm dedupe 2026-06-14 14:31:16 +00:00
dependabot[bot]
ccca2c8aae
build(deps): bump the production group across 1 directory with 6 updates
Bumps the production group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.3` | `2.1.4` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.3` | `12.19.1` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.57.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |



Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `@logtape/logtape` from 2.1.3 to 2.1.4
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.4/packages/logtape)

Updates `pg-boss` from 12.18.3 to 12.19.1
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.3...12.19.1)

Updates `@sentry/react` from 10.53.1 to 10.57.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.57.0)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-14 14:30:31 +00:00
Ullrich Schäfer
7e76bbd447
Merge pull request #541 from trails-cool/apply-profile-weekly-distance
profile-weekly-distance: weekly distance bar chart
2026-06-14 16:24:14 +02:00
Ullrich Schäfer
4754c229b9
profile-weekly-distance: make the chart legible (axis, grid, tracks, hover)
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>
2026-06-13 08:50:01 +02:00
Ullrich Schäfer
ceda9f1877
profile-weekly-distance: weekly distance bar chart on the profile
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>
2026-06-13 07:46:06 +02:00
Ullrich Schäfer
e0a92b56aa
Merge pull request #540 from trails-cool/propose-profile-weekly-distance
openspec: propose profile-weekly-distance (weekly bars)
2026-06-13 07:36:01 +02:00
Ullrich Schäfer
46a7304421
Merge pull request #539 from trails-cool/apply-profile-stats
profile-stats: lifetime roll-up header on the profile
2026-06-13 07:29:41 +02:00
Ullrich Schäfer
d975cc7402
openspec: propose profile-weekly-distance (weekly bars)
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>
2026-06-13 07:29:23 +02:00
Ullrich Schäfer
3d3c56aaf4
profile-stats: lifetime roll-up header on the profile
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>
2026-06-13 07:25:06 +02:00
Ullrich Schäfer
9845718dee
Merge pull request #538 from trails-cool/propose-profile-stats
openspec: propose profile-stats (profile roll-ups)
2026-06-13 07:13:39 +02:00
Ullrich Schäfer
977e0d8f5f
openspec: propose profile-stats (profile roll-ups)
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>
2026-06-13 06:56:06 +02:00
Ullrich Schäfer
b7e55d09db
Merge pull request #537 from trails-cool/fix-list-map-previews
fix: restore list map previews (batch geojson query)
2026-06-13 06:50:16 +02:00
Ullrich Schäfer
0ee4daaed1
fix: restore list map previews (batch geojson query)
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>
2026-06-12 22:53:14 +02:00
Ullrich Schäfer
8cacc7d91e
Merge pull request #536 from trails-cool/home-statrow-consistency
home: shared StatRow + SportBadge on activity cards
2026-06-12 22:44:31 +02:00
Ullrich Schäfer
296e515ebb
Merge branch 'main' into home-statrow-consistency 2026-06-12 22:44:22 +02:00
Ullrich Schäfer
ee3eff193a
Merge pull request #535 from trails-cool/opsx-archive-catchup
openspec: archive detail catch-up changes (sync specs)
2026-06-12 22:44:07 +02:00
Ullrich Schäfer
acb1e43481
home: use the shared StatRow + SportBadge on activity cards
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>
2026-06-12 16:03:36 +02:00
Ullrich Schäfer
f1cae13605
openspec: archive the detail catch-up changes, sync specs
Archive the three implemented + merged changes (activity-sport-type,
activity-stats, journal-elevation-profile): their deltas are promoted into
openspec/specs/, the changes move to changes/archive/2026-06-12-*, and the
three new capabilities are added to the CAPABILITIES index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:58:17 +02:00
Ullrich Schäfer
1ba063a085
Merge pull request #534 from trails-cool/apply-journal-elevation-profile
journal-elevation-profile: elevation chart + map↔chart sync
2026-06-12 15:45:52 +02:00
Ullrich Schäfer
536a8f98b9
journal-elevation-profile: elevation chart + map↔chart sync
Implements the journal-elevation-profile change (specs/journal-elevation-profile):

- gpx: `elevationSeries(tracks)` → { d, e, lat, lng }[] with cumulative distance,
  downsampled (keeps first/last), empty when <2 points carry elevation.
- ElevationProfile: read-only SVG area chart (vertical gradient fill), highest/
  lowest summary + a hover readout. Reports hovered index (onActive) and clicked
  index (onSeek); draws a marker at the active index. Renders nothing for an
  empty series.
- RouteMapThumbnail: ActiveMarker (CircleMarker at the chart's active point),
  HoverTracker (route hover → nearest sample → onHoverIndex), Recenter (panTo on
  chart click). Props forwarded through ClientMap.
- Wired into the activity + route detail pages via a shared activeIndex/centerOn
  state; loaders expose the series (activity reuses the moving-time parse).
- i18n journal.elevation.{highest,lowest} in en + de.

Ascent/descent stay in the stat row (#532); the chart summary shows highest/
lowest to avoid duplication.

Tests: elevationSeries unit; ElevationProfile component (jsdom); e2e creates an
activity from an elevation GPX and asserts the chart renders. typecheck + lint +
unit (gpx 67, journal 315) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:34:45 +02:00
Ullrich Schäfer
1bfb6f1d86
Merge pull request #533 from trails-cool/fix-map-isolation
fix: isolate the Leaflet map's stacking context
2026-06-12 15:23:45 +02:00
Ullrich Schäfer
830eff7d39
fix: isolate the Leaflet map's stacking context
The mobile nav drawer's backdrop (z-40) was being painted over by the Leaflet
map: `.leaflet-container` doesn't establish its own stacking context, so its
internal high z-indexes (panes ~200–700, zoom controls ~1000) competed with the
page and bled over the drawer overlay — the map stayed bright while the rest of
the page dimmed.

Add `isolation: isolate` (Tailwind `isolate`) to the map container so those
z-indexes stay contained and the map sits below page overlays (menus, modals,
dialogs) like any other content. Verified in the browser with the drawer open
over an activity-detail map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:19:42 +02:00
Ullrich Schäfer
5e6fcde281
Merge pull request #532 from trails-cool/apply-activity-stats
activity-stats: shared StatRow + canonical metrics
2026-06-12 15:14:59 +02:00
Ullrich Schäfer
0325d01bca
activity-stats: shared StatRow + canonical metrics across surfaces
Implements the activity-stats change (specs/activity-stats):

- gpx: `movingTime(tracks)` — moving seconds from trackpoint timestamps,
  excluding stationary spans + long gaps; null when no timestamps.
- stats.ts: pure formatters (distance/elevation/duration/speed/pace),
  sport-aware `deriveRate` (pace for foot sports, speed otherwise, speed
  default), and `activityStatItems` encoding the canonical order
  (distance · time · [moving] · pace/speed · ascent · descent; compact subset).
- StatRow: one shared presentational component (size sm/lg), adopted on the
  activity detail (full set; loader derives moving time), route detail
  (distance/ascent/descent), feed card + profile list (compact).
- i18n: journal.stats.* in en + de.

Tests: movingTime (stationary/gap exclusion, moving ≤ elapsed), deriveRate,
formatters, activityStatItems ordering + compact, StatRow render (jsdom).
typecheck + lint + unit (gpx 63, journal 311) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:48:36 +02:00
Ullrich Schäfer
ede263d4d1
Merge pull request #531 from trails-cool/apply-activity-sport-type
activity-sport-type: sport/activity type on activities
2026-06-12 14:37:20 +02:00
Ullrich Schäfer
953c79befe
activity-sport-type: e2e test + register spec in playwright config
- e2e/activity-sport-type.test.ts: register → create activity with a sport →
  assert the sport badge renders on the detail page. Passes locally against an
  E2E=true server.
- playwright.config.ts: add the `activity-sport-type` project so the spec
  actually runs (specs only execute if a project testMatch matches them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:16:58 +02:00
Ullrich Schäfer
2cb32cd2d3
activity-sport-type: schema, contract, write/read paths, federation
Implements the activity-sport-type change (specs/activity-sport-type):

- db: nullable `sport_type` column on journal.activities + SportType /
  SPORT_TYPES (text().$type<> convention).
- api: optional sportType on the activity read + create schemas (mirrored
  SPORT_TYPES; @trails-cool/api stays zod-only).
- write: ActivityInput + createActivity persist it; mapSportType() normalizes
  provider strings (Komoot bulk import passes tour.sport; Garmin unset);
  threaded through the unified importActivity.
- read/display: sportType added to the detail/feed/profile loaders and the v1
  REST endpoints; shared SportBadge (glyph + i18n label) on detail, feed, and
  profile; sport-aware feed verb; create-form <select>.
- i18n: journal.activities.sport.* (labels + verbs) in en + de.
- federation: `sport` PropertyValue on the Note when set.

Tests: mapSportType unit table; federation asserts the sport attachment is
present when set and omitted when unset. typecheck + lint + unit all green.
E2E (create→badge) still to add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:11:41 +02:00
Ullrich Schäfer
ee76945f20
Merge pull request #530 from trails-cool/propose-detail-catchup-specs
openspec: propose journal detail catch-up (sport type, stats, elevation)
2026-06-12 13:57:24 +02:00
Ullrich Schäfer
2ccdc8dac2
Merge pull request #529 from trails-cool/chore-gitignore-internal-reviews
chore: gitignore internal review material
2026-06-12 13:56:38 +02:00
Ullrich Schäfer
26b59b9c95
openspec: propose journal route/activity detail catch-up changes
Three OpenSpec proposals to bring the journal's route/activity detail and feed
closer to best-in-class outdoor apps, in the order we'll implement them:

- activity-sport-type: nullable sport enum on activities (hike/walk/run/
  ride/gravel/mtb/ski/other), set on create or normalized on import,
  shown as a badge + feed verb, federated as a Note PropertyValue.
- activity-stats: one reusable StatRow + canonical metric set, sport-aware
  avg pace/speed, moving-vs-elapsed time. Derive-only, no schema change.
- journal-elevation-profile: elevation profile chart + chart<->map
  "active distance" hover/click sync on the read-only detail pages.

All three validate with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:56:24 +02:00
Ullrich Schäfer
e3e01a34ae
chore: gitignore internal working notes
docs/reviews/internal/ holds internal working notes we keep out of the
published repo. Commit the ignore rule so the folder is excluded for everyone,
not just on the local working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:56:08 +02:00
Ullrich Schäfer
b5aa61e3b7
Merge pull request #522 from trails-cool/dependabot/npm_and_yarn/production-9d29f9de3d
chore(deps): Bump the production group across 1 directory with 21 updates
2026-06-11 21:27:44 +02:00
dependabot[bot]
8bf555b57a [github-actions] pnpm dedupe 2026-06-11 19:23:24 +00:00
dependabot[bot]
19160bbe82
build(deps): Bump the production group across 1 directory with 21 updates
Bumps the production group with 21 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [turbo](https://github.com/vercel/turborepo) | `2.9.17` | `2.9.18` |
| [@logtape/logtape](https://github.com/dahlia/logtape/tree/HEAD/packages/logtape) | `2.1.1` | `2.1.3` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.41` | `5.1.42` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.10` | `8.0.11` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `8.0.0` | `8.0.1` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.14` | `56.0.15` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.3.3` | `11.3.4` |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `56.0.9` | `56.0.11` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.17` | `56.0.18` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.19` | `56.0.20` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `56.0.7` | `56.0.8` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `56.0.13` | `56.0.14` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.16` | `56.0.17` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.16` | `56.0.17` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.9` | `56.2.10` |
| [expo-sqlite](https://github.com/expo/expo/tree/HEAD/packages/expo-sqlite) | `56.0.4` | `56.0.5` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.2` | `12.18.3` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.56.0` | `10.57.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.57.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.20` | `22.19.21` |



Updates `turbo` from 2.9.17 to 2.9.18
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.17...v2.9.18)

Updates `@logtape/logtape` from 2.1.1 to 2.1.3
- [Changelog](https://github.com/dahlia/logtape/blob/main/CHANGES.md)
- [Commits](https://github.com/dahlia/logtape/commits/2.1.3/packages/logtape)

Updates `isbot` from 5.1.41 to 5.1.42
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.41...v5.1.42)

Updates `nodemailer` from 8.0.10 to 8.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@expo/metro-runtime` from 56.0.14 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.3.3 to 11.3.4
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.3.3...v11.3.4)

Updates `expo` from 56.0.9 to 56.0.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `expo-constants` from 56.0.17 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 56.0.19 to 56.0.20
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-file-system` from 56.0.7 to 56.0.8
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 56.0.13 to 56.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.9 to 56.2.10
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-sqlite` from 56.0.4 to 56.0.5
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-sqlite/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-sqlite)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `pg-boss` from 12.18.2 to 12.18.3
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.2...12.18.3)

Updates `@sentry/node` from 10.56.0 to 10.57.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.56.0...10.57.0)

Updates `@sentry/react` from 10.53.1 to 10.57.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.57.0)

Updates `@types/node` from 22.19.20 to 22.19.21
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@logtape/logtape"
  dependency-version: 2.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo
  dependency-version: 56.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 56.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 56.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-sqlite
  dependency-version: 56.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.42
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 19:22:35 +00:00
Ullrich Schäfer
1128544c9c
Merge pull request #513 from trails-cool/dependabot/npm_and_yarn/vite-8.0.16
build(deps): Bump vite from 7.3.5 to 8.0.16
2026-06-11 20:59:40 +02:00
dependabot[bot]
681de1e515 [github-actions] pnpm dedupe 2026-06-11 18:52:10 +00:00
dependabot[bot]
0018a46f10
build(deps): Bump vite from 7.3.5 to 8.0.16
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 18:51:15 +00:00
Ullrich Schäfer
7092ea42ff
Merge pull request #527 from trails-cool/e2e-hydration-flake
e2e: close the interact-before-hydration race (cold-start flake)
2026-06-11 14:13:13 +02:00
Ullrich Schäfer
a05c8e87a1
e2e: close the interact-before-hydration race (cold-start flake)
Diagnosis (reproduced by looping cold-server runs): the recurring
"cold start" failures in the auth/follow specs were a hydration race,
not a Vite dep-reload (the server.warmup config already handles that —
no reload events in the logs). FollowButton is an onClick button, and
Playwright considers it actionable (visible + enabled) before React
attaches the handler on a freshly-navigated page. A click in that
window is dropped (or triggers a native form submit), so the expected
state change never happens — e.g. notifications.test.ts:59 clicked
"Request to follow" with no hydration wait and the button never
flipped to "Requested".

The existing waitForHydration guard was applied per-interaction and so
was easy to forget (test 1 had it, test 3 didn't). Fix it at the
navigation instead:

- add gotoHydrated(page, url) = goto + waitForHydration, documented as
  the default for "navigate then interact with a React control"
- use it before every FollowButton interaction in notifications +
  social
- consolidate the setProfileVisibility helper (copy-pasted in three
  specs, two missing the hydration wait) into e2e/helpers/profile.ts,
  built on gotoHydrated

Validation against a fresh Postgres, cold servers: notifications 0/10
failures (was the proven flaker); notifications+social+explore 0/5
(55 tests); planner unaffected (25/25).

Note: two other "cold-start"-attributed flakes are NOT this race and
are out of scope here — planner map-load latency (mitigated by warmup
+ CI retries) and the /explore directory assertion (a local artifact
of the scratch DB accumulating >20 public users; CI's fresh DB stays
under the page size, so it doesn't bite there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:09:00 +02:00
Ullrich Schäfer
f669969ad1
Merge pull request #526 from trails-cool/docs-review-decks
docs: archive the 2026-06-10 review decks under docs/reviews
2026-06-11 08:41:41 +02:00
Ullrich Schäfer
a38fdab69d
Merge branch 'main' into docs-review-decks 2026-06-11 08:37:43 +02:00
Ullrich Schäfer
f2677937d7
Merge pull request #525 from trails-cool/e2e-register-dead-specs
e2e: register and repair the settings/explore/social specs
2026-06-11 08:36:11 +02:00
Ullrich Schäfer
10a3a6f1c4
docs: archive the 2026-06-10 architecture + security review decks
Both were sitting untracked in the repo root. They're the source for
the "candidate N from the 2026-06-10 review" references in this
batch of follow-up PRs, so keeping them in-repo makes those references
resolvable. Self-contained HTML; a short README indexes them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:33:48 +02:00
Ullrich Schäfer
7a290cd56f
e2e: register and repair the settings/explore/social specs
settings.test.ts, explore.test.ts, and social.test.ts weren't matched
by any Playwright project, so they had silently never run — which is
how they rotted. Register them (one project each) and repair the
selectors against the current UI:

- settings: the page was split into sibling sections
  (/settings/{profile,security,account}); the spec assumed one page.
  Navigate to the right sub-page per test, use the stable section-nav
  links + #id input locators (the Vite dev server transiently
  double-renders the profile form during hydration, breaking
  getByLabel), and wait for hydration before interacting with
  fetcher-backed forms and the avatar dropdown.
- explore: setProfileVisibility now targets /settings/profile and
  waits for hydration so the visibility save uses the fetcher.
- social: already green once registered.

Also fixes an app bug the specs surfaced: deleting a passkey redirected
to /settings#security, a stale anchor that now resolves to
/settings/profile — so you'd land on the wrong section. It now
redirects to /settings/security.

Verified locally against Postgres/BRouter: green under CI-style
retries (the residual registration flake is the same cold-start class
the rest of the suite has, which is why CI runs retries=2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:32:18 +02:00
Ullrich Schäfer
b4067301cc
Merge pull request #524 from trails-cool/sec-caddy-admin
security: bind Caddy admin API to loopback (split metrics to :2020)
2026-06-11 08:00:38 +02:00
Ullrich Schäfer
959a1c46ae
Merge branch 'main' into sec-caddy-admin 2026-06-11 07:56:59 +02:00
Ullrich Schäfer
d8fd4ff655
security: bind Caddy admin API to loopback, split metrics to :2020
The flagship Caddy ran its admin API on 0.0.0.0:2019, reachable by
every container on the Docker network. The admin API can rewrite
routes and proxy targets, so a journal/planner RCE could repoint
traffic with no further auth. (The brouter-host Caddyfile already
binds admin to localhost; the flagship didn't.)

The admin endpoint also served Prometheus metrics (global `metrics`
option → admin endpoint), and Prometheus scrapes caddy:2019
cross-container — so admin couldn't just move to loopback without
breaking metrics. Split them:

- admin localhost:2019 (loopback only). All reloads are in-container
  (`docker compose exec caddy caddy reload`) so they use this endpoint
  unaffected.
- a dedicated `:2020` server exposes the read-only metrics handler;
  prometheus.yml now scrapes caddy:2020.

Validated with `caddy validate` + `caddy adapt` against caddy:2:
admin.listen=localhost:2019, srv on :2020 carries the metrics handler,
the :443 site is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:56:30 +02:00
Ullrich Schäfer
9d11a4a158
Merge pull request #523 from trails-cool/sec-hardening-journal
security: log redaction, magic-link enumeration, federation doc cap
2026-06-11 07:56:05 +02:00
Ullrich Schäfer
769d1b5d31
security: log redaction, magic-link enumeration, federation doc cap
Three Low/Info hardening items from the 2026-06-10 security review.

OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
  failure; a provider error can embed the auth code / token response,
  which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
  to 200 chars before logging.

Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
  found for this email") when no account matches; the login route
  always responds { step: "magic-link-sent" }, minting a token and
  sending mail only for a real account. The public login form can no
  longer be used to probe which emails are registered. Registration's
  email/username "already in use/taken" messages are intentionally
  unchanged — standard signup UX, and the passkey ceremony can't be
  made to fake-succeed.

Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
  serialized, applied in the ingest fetchJson seam. Fedify owns the
  transfer (with its own SSRF + redirect limits) and our poll uses the
  authenticated loader for secure-mode instances, so this is a
  downstream guard on iteration/persistence, complementing the existing
  per-poll item cap.

Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:52:34 +02:00
Ullrich Schäfer
55f8758c70
Merge pull request #521 from trails-cool/sec-upload-validation
security: authorize & validate presigned upload requests
2026-06-10 22:27:40 +02:00
Ullrich Schäfer
31ace379d7
Merge branch 'main' into sec-upload-validation 2026-06-10 22:23:37 +02:00
Ullrich Schäfer
01d4832edb
security: authorize and validate presigned upload requests
POST /api/v1/uploads minted an upload key from caller input with no
ownership check, no content-type allowlist, and the raw filename
interpolated into the S3 key.

- Ownership: a caller may only mint upload URLs for a route/activity
  they own, enforced via the branded loadOwnedRoute/loadOwnedActivity
  (404 on miss, no existence leak). Closes writing into another
  user's resource key-space.
- Content type: the request schema now constrains contentType to an
  image/gpx allowlist, so active content (HTML/SVG/JS) that would
  execute if served inline is rejected at the request boundary.
- Filename: sanitizeUploadFilename() reduces the client filename to a
  safe basename (charset-restricted, no path components, no leading
  dots, length-bounded) before it becomes part of the key.

Tests: schema allow/deny + filename sanitization in @trails-cool/api;
handler tests covering owned-success, not-owner 404, disallowed
content-type, and the route-vs-activity ownership branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:23:14 +02:00
Ullrich Schäfer
b166c41d9b
Merge pull request #520 from trails-cool/sec-planner-ssrf
security: validate Planner callback URL (SSRF fix)
2026-06-10 22:22:27 +02:00
Ullrich Schäfer
43938473ad
Merge branch 'main' into sec-planner-ssrf 2026-06-10 22:18:52 +02:00
Ullrich Schäfer
c3454641df
security: validate Planner callback URL to close an SSRF sink
The session callbackUrl becomes a server-side fetch target in
api.save-to-journal (POSTed with the callback bearer token, and the
journal's response is reflected to the caller). The /new query-param
loader already validated it, but the programmatic POST /api/sessions
entry point — anonymous, since the Planner is stateless — stored it
unvalidated. An attacker could make the Planner backend POST to
arbitrary hosts, including 169.254.169.254 and other internal targets.

- validateFetchUrl now blocks private / loopback / link-local /
  CGNAT / cloud-metadata hosts (IPv4, IPv6, IPv4-mapped) when an
  explicit allowlist isn't set. Gated on NODE_ENV=production && !E2E
  (the requireSecret idiom) so the dev/e2e journal-on-localhost save
  flow is unaffected. An explicit PLANNER_CALLBACK_ALLOWED_HOSTS still
  takes precedence and remains the full-closure control (it also stops
  DNS-name-to-private rebinding, which literal blocking does not).
- POST /api/sessions now validates callbackUrl exactly as /new does.
- api.save-to-journal re-validates immediately before the fetch
  (defense in depth: covers sessions persisted before this change and
  narrows the create→save rebinding window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:18:18 +02:00
Ullrich Schäfer
0d2593a0c4
Merge pull request #508 from trails-cool/dependabot/npm_and_yarn/development-a6ed03596f
chore(deps-dev): Bump the development group across 1 directory with 4 updates
2026-06-10 08:03:55 +02:00
dependabot[bot]
04e86d4308 [github-actions] pnpm dedupe 2026-06-10 05:59:38 +00:00
dependabot[bot]
26c0a2d059
chore(deps-dev): Bump the development group across 1 directory with 4 updates
Bumps the development group with 4 updates in the / directory: [prettier](https://github.com/prettier/prettier), [turbo](https://github.com/vercel/turborepo), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [@testing-library/react-native](https://github.com/callstack/react-native-testing-library).


Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `turbo` from 2.9.16 to 2.9.17
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.16...v2.9.17)

Updates `typescript-eslint` from 8.60.1 to 8.61.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.0/packages/typescript-eslint)

Updates `@testing-library/react-native` from 13.3.3 to 14.0.0
- [Release notes](https://github.com/callstack/react-native-testing-library/releases)
- [Changelog](https://github.com/callstack/react-native-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/callstack/react-native-testing-library/compare/v13.3.3...v14.0.0)

---
updated-dependencies:
- dependency-name: "@testing-library/react-native"
  dependency-version: 14.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: turbo
  dependency-version: 2.9.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 05:58:57 +00:00
Ullrich Schäfer
e64155b490
Merge pull request #517 from trails-cool/package-shims
Remove the map and ui shim packages
2026-06-10 07:53:57 +02:00
Ullrich Schäfer
f684272eb5
Merge branch 'main' into package-shims 2026-06-10 07:50:17 +02:00
Ullrich Schäfer
5e54f35227
Merge pull request #516 from trails-cool/oauth-flow-module
journal: OAuth connect→callback→resume lifecycle as one module
2026-06-10 07:49:42 +02:00
Ullrich Schäfer
765c9f49a8
remove the map and ui shim packages
Both failed the deletion test in the telling direction:

- @trails-cool/ui: Button/Input/Card had zero consumers — both apps
  roll their own elements inline. The only live part was a 6-line
  styles.css (the Tailwind entry + one keyframe), which now lives in
  each app as app/styles.css.
- @trails-cool/map: MapView and RouteLayer had zero consumers; the
  package was otherwise a re-export of two map-core constants, and the
  two import sites now use @trails-cool/map-core directly. The
  "map components go in @trails-cool/map" convention had drifted long
  ago — the real map components live in apps/planner/app/components.

CLAUDE.md's repository structure and conventions updated to match
reality (including pointing shared-type guidance at the post-#515
sources: db row types, api contracts, Waypoint in types). Dockerfiles
no longer COPY the deleted package manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:47:59 +02:00
Ullrich Schäfer
bed9362004
Merge branch 'main' into oauth-flow-module 2026-06-10 07:45:35 +02:00
Ullrich Schäfer
5d6749618e
Merge pull request #511 from trails-cool/rntl-v14-async-render
mobile: await render() in smoke test for RNTL v14 compatibility
2026-06-10 07:45:25 +02:00
Ullrich Schäfer
f52de808f5
Merge branch 'main' into rntl-v14-async-render 2026-06-10 07:44:46 +02:00
Ullrich Schäfer
5e70aff9fa
Merge branch 'main' into oauth-flow-module 2026-06-10 07:44:29 +02:00
Ullrich Schäfer
6f366293c9
journal: OAuth connect→callback→resume lifecycle as one module
The PKCE verifier cookie, state encoding, redirect-URI construction,
and code exchange were coordinated across three route handlers that
each knew part of the protocol. Two latent bugs lived in the gaps: a
push-initiated re-authorization skipped PKCE entirely (harmless today
only because the sole pusher, Wahoo, is not a PKCE provider), and the
push-resume callback path never cleared the spent verifier cookie.

oauth-flow.server.ts now owns the lifecycle: initiateOAuthFlow builds
the provider redirect (state + verifier cookie, on every entry path),
and completeOAuthFlow consumes the callback — state decode, verifier
recovery, code exchange, connection linking — returning a
discriminated result the route maps to redirects. The three routes are
thin adapters; the next OAuth provider reuses the flow, not the
pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:44:04 +02:00
Ullrich Schäfer
7435353612
Merge pull request #515 from trails-cool/domain-type-unification
One source of truth for Route/Activity shapes; enforce api contracts at the seam
2026-06-10 07:44:01 +02:00
Ullrich Schäfer
06545c8e28
Merge branch 'main' into rntl-v14-async-render 2026-06-10 07:43:16 +02:00
Ullrich Schäfer
362fa11a88
Merge branch 'main' into domain-type-unification 2026-06-10 07:40:07 +02:00
Ullrich Schäfer
61a2d0085b
one source of truth for Route/Activity shapes; enforce api contracts
Route and Activity existed three times: hand-written interfaces in
packages/types, Zod contracts in packages/api, and Drizzle columns in
packages/db — each with different fields and nullability. The
hand-written ones had drifted so far they had zero importers; the Zod
contracts were advisory because v1 handlers hand-rolled Response.json
shapes nothing validated.

- packages/types keeps only what both apps actually share (Waypoint,
  WaypointPoiTags) and documents where row types and wire contracts
  live; the dead Route/RouteMetadata/RouteVersion/Activity interfaces
  are gone
- packages/db exports canonical inferred row types (RouteRow,
  ActivityRow, RouteVersionRow, UserRow)
- packages/api contracts are reconciled with the real wire format
  (RouteVersionSchema gains the id and createdBy fields the endpoint
  has always returned) and gain Create*ResponseSchemas
- apiJson(schema, payload) in api-guard parses every v1 response
  through its contract: drift is now a thrown ZodError in tests/CI,
  unknown keys are stripped, and payloads are compile-checked as
  z.input of the schema

Enforcement immediately caught two real drifts: nullable DB
descriptions could ship null where the contract promises string (now
coalesced at the boundary), and GET /api/v1/activities/:id was missing
the routeName and photos fields its contract declares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:39:50 +02:00
Ullrich Schäfer
ec87445550
Merge pull request #514 from trails-cool/e2e-fixtures
e2e: shared auth + journal seed helpers; flag unregistered specs
2026-06-10 07:36:39 +02:00
Ullrich Schäfer
bcd082dd37
Merge branch 'main' into e2e-fixtures 2026-06-10 07:32:19 +02:00
Ullrich Schäfer
0e267afac7
e2e: shared auth + journal seed helpers; flag unregistered specs
The virtual-authenticator setup and registerUser were copy-pasted into
six spec files and had drifted: the auth spec's copy lost the final
URL assertion, and the settings spec's variant skipped hydration and
the Terms checkbox entirely. Seed-route boilerplate and hardcoded
localhost URLs were repeated across three more files.

- helpers/auth.ts: setup/removeVirtualAuthenticator,
  submitRegistration (no outcome assertion, for expected-failure
  attempts), registerUser (asserts the signed-in redirect),
  registerFreshUser, logout
- helpers/journal.ts: JOURNAL/PLANNER base URLs, seedRoute,
  routeHasGeom, seedKomootConnection
- nine spec files now import the helpers instead of re-deriving them

Found while consolidating: settings.test.ts, explore.test.ts, and
social.test.ts are not matched by any playwright project and have
never run — which is how the settings spec's broken register helper
survived. Registering them (and fixing whatever has rotted) is a
follow-up; the config now carries a warning so the trap is at least
documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:31:51 +02:00
Ullrich Schäfer
5b2c5d53b5
Merge pull request #510 from trails-cool/gpx-save-stats
journal: gpx-save owns the validate-and-derive step (processGpx)
2026-06-10 07:27:25 +02:00
Ullrich Schäfer
00b61d1ebe
Merge branch 'main' into gpx-save-stats 2026-06-10 07:23:42 +02:00
Ullrich Schäfer
67e976f7cc
mobile: await render() in smoke test for RNTL v14 compatibility
RNTL v14 makes render() async; awaiting it is a no-op on v13, so this
works on both and unblocks the dependabot v14 bump (#508).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:29:07 +02:00
Ullrich Schäfer
9a0dae068b
journal: gpx-save owns the validate-and-derive step (processGpx)
createRoute, updateRoute, createActivity, createRouteFromActivity, and
the demo-bot each re-implemented the same choreography after
validateGpx: flatten tracks into [lon, lat] coords for writeGeom and
derive distance / elevation / dayBreaks / description / start time.
The stat derivation lived in a private computeRouteStats in
routes.server.ts that activities couldn't reach, so the two sides had
drifted (activities re-derived inline, with its own start-time logic).

processGpx() in gpx-save.server.ts now owns the whole step: parse +
validate (GpxValidationError as before), coords extraction, and stat
derivation, returning the parsed GpxData so nothing re-parses.
Callers keep their own precedence rules between derived and
caller-supplied stats — routes let explicit input win wholesale, the
activities importer prefers GPX distance unless it is zero. Extends
ADR-0006: the gpx-save module remains the only place that understands
GPX-to-database derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:28:13 +02:00
Ullrich Schäfer
b901d2710f
Merge pull request #509 from trails-cool/fix-expo-sdk56-version-alignment
mobile: realign native deps with Expo SDK 56 expectations
2026-06-10 03:27:38 +02:00
Ullrich Schäfer
ba64b2cd57
mobile: realign native deps with Expo SDK 56 expectations
Dependabot bumped react-native-reanimated (4.4.1), react-native-
safe-area-context (5.8.0), and react-native-worklets (0.9.1) past the
versions Expo SDK 56 pins, which broke the native iOS build: expo's
Swift macro API drifted between expo-modules-core patches and
expo-crypto stopped compiling (OptimizedFunction macro mismatch).
CI never caught it because nothing compiles native code; the first
EAS build since the SDK 56 upgrade (d627b3c1) failed with it.

Applied `npx expo install --fix` (expo ~56.0.9, reanimated 4.3.1,
safe-area-context ~5.7.0, worklets 0.8.3) + pnpm dedupe, and added
the SDK-pinned native packages to the dependabot ignore list so they
only move via SDK bumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 03:24:00 +02:00
Ullrich Schäfer
1a65b40d18
Merge pull request #507 from trails-cool/owned-entity-loading
journal: branded ownership loading for routes and activities
2026-06-10 02:48:21 +02:00
Ullrich Schäfer
e4958419dd
Merge branch 'main' into owned-entity-loading 2026-06-10 02:44:24 +02:00
Ullrich Schäfer
7a1dca378f
journal: branded ownership loading for routes and activities
Ownership was checked ad hoc: some handlers loaded-and-compared
ownerId, some lib mutators enforced it in WHERE clauses and silently
no-op'd for non-owners, and nothing tied the two together. Two real
authorization bugs hid in the gaps: linkActivityToRoute ignored its
ownerId parameter entirely (any logged-in user could relink any
activity), and createRouteFromActivity loaded any activity without an
ownership check (a non-owner could copy a private activity's GPX into
their own route). PUT /api/v1/routes/:id also returned ok:true for
non-owners without updating anything.

ownership.server.ts is now the single enforcement point:

- loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with
  their own error vocabulary) and requireOwnedRoute /
  requireOwnedActivity (throwing data() 404/403 for web handlers; 404
  by default so guessed ids don't leak existence)
- the returned entities carry an Owned<> brand; mutators (updateRoute,
  deleteRoute, deleteActivity, updateActivityVisibility,
  linkActivityToRoute, createRouteFromActivity) now require an
  OwnedRef, so skipping the check is a compile error
- vouchOwnership is the explicit, greppable escape hatch for the one
  non-session authorization path (the Planner JWT callback)
- WHERE ownerId clauses stay in the mutators as defense in depth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:35:00 +02:00
Ullrich Schäfer
d060f7890a
Merge pull request #505 from trails-cool/remove-stray-root-deps
Remove stray root dependencies block
2026-06-10 02:25:03 +02:00
Ullrich Schäfer
45d61082f2
Merge branch 'main' into remove-stray-root-deps 2026-06-10 02:21:48 +02:00
Ullrich Schäfer
1443a82f2b
Merge pull request #506 from trails-cool/jobs-typed-seam
journal: typed job seam + Komoot credentials through the manager
2026-06-10 02:09:26 +02:00
Ullrich Schäfer
855244747c
journal: typed job seam + Komoot credentials through the manager
Two related fixes from the architecture review:

Typed job seam. Job payloads were `unknown` end-to-end: every handler
opened with `job.data as SomePayload`, every enqueue site passed a bare
string queue name and an unchecked object, and a typo meant a runtime
failure in a background worker. packages/jobs gains defineJob(), which
keeps the payload typed inside the handler and performs the
contravariance cast once inside the package. The journal declares all
14 queues and their payload shapes in app/jobs/payloads.ts; enqueue()/
enqueueOptional() and defineJournalJob() key off that map, so enqueue
sites and handlers cannot drift and queue names are compile-checked.

Komoot credential bypass. The bulk-import route enqueued the raw
credentials JSONB in the pg-boss payload, skipping withFreshCredentials
entirely: credentials sat at rest in the job table, were never
refreshed if stale, and markNeedsRelink never fired. The payload now
carries only the serviceId; the handler resolves fresh credentials
through the ConnectedServiceManager at execution time, and marks the
import batch failed (instead of leaving it pending forever) when
credential resolution itself fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:05:24 +02:00
Ullrich Schäfer
e94e7ac19a
Merge pull request #504 from trails-cool/planner-route-data-seam
planner: typed schema seam for the Yjs document + shared GPX assembly
2026-06-10 01:51:49 +02:00
Ullrich Schäfer
bf0f7f8d9f
Merge branch 'main' into planner-route-data-seam 2026-06-10 01:48:18 +02:00
Ullrich Schäfer
d13abe80d6
Merge pull request #455 from trails-cool/dependabot/docker/apps/journal/node-26-slim
chore(deps): Bump node from 25-slim to 26-slim in /apps/journal
2026-06-10 01:44:39 +02:00
dependabot[bot]
ce5fd6da0f
chore(deps): Bump node from 25-slim to 26-slim in /apps/journal
Bumps node from 25-slim to 26-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 23:41:11 +00:00
Ullrich Schäfer
50ce4e69a0
Merge pull request #456 from trails-cool/dependabot/docker/apps/planner/node-26-slim
chore(deps): Bump node from 25-slim to 26-slim in /apps/planner
2026-06-10 01:39:45 +02:00
Ullrich Schäfer
a2735edd1c
Remove stray root dependencies block
The root package.json carried expo/react/react-native pins since
310f20e (added incidentally while debugging the Android dev server).
apps/mobile pins its own expo, and react/react-dom are governed by
the workspace catalog + overrides, so the block only forced a
parallel Expo 55 / react-native 0.83 tree into the lockfile
(-1569 lines) and pinned react at 19.2.0 next to the catalog's
19.2.7 — the likely cause of the react/react-dom version-mismatch
failures on the @testing-library/react-native bump (#480).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:38:27 +02:00
dependabot[bot]
154dccf312
chore(deps): Bump node from 25-slim to 26-slim in /apps/planner
Bumps node from 25-slim to 26-slim.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 23:36:01 +00:00
Ullrich Schäfer
3b9672e0ff
planner: give the Yjs document a typed schema seam
The routeData Y.Map's ~15 string keys (geojson, coordinates,
segmentBoundaries, road metadata, profile, colorMode, baseLayer,
overlays, poiCategories) were read and written raw at ~30 call sites,
each with its own JSON parsing and casts; parseJsonArray existed twice
and waypoint extraction four times. GPX assembly was duplicated between
SaveToJournalButton and ExportButton, so the saved plan and the
exported file could silently diverge.

- new lib/route-data.ts owns the routeData (+ noGoAreas) schema:
  typed read/write, JSON encoding internal, ColorMode moves here
  (re-exported from ColoredRoute for existing importers)
- new lib/gpx-export.ts owns GPX assembly: buildRouteGpx /
  buildPlanGpx / buildDayGpxFiles / hasDayBreaks; multi-day splitting
  becomes a pure, tested function
- waypoint-ymap.ts gains extractWaypoints / extractWaypointData; the
  four hand-rolled copies (use-routing, use-waypoint-manager,
  WaypointSidebar, use-days) now share it, and WaypointSidebar's
  moveWaypoint reuses the round-trip helpers instead of re-listing
  every waypoint field
- all hooks/components consume the seam; no raw routeData key strings
  remain outside route-data.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:04 +02:00
Ullrich Schäfer
3ce7bf0991
Merge pull request #484 from trails-cool/dependabot/npm_and_yarn/production-e9efbc881e
chore(deps): bump the production group across 1 directory with 30 updates
2026-06-10 01:32:22 +02:00
Ullrich Schäfer
8eeb775f54
Merge branch 'main' into dependabot/npm_and_yarn/production-e9efbc881e 2026-06-10 01:28:40 +02:00
Ullrich Schäfer
06c385f6ac
Merge pull request #503 from trails-cool/fix/prometheus-hup-race
cd-infra: fix Prometheus 3.10 SIGHUP startup kill + add readiness gate
2026-06-09 16:03:37 +02:00
Ullrich Schäfer
92468a7f90
Merge branch 'main' into fix/prometheus-hup-race 2026-06-09 15:59:52 +02:00
Ullrich Schäfer
c9e5166106
Merge pull request #502 from trails-cool/skills-to-agents
Move skills to .agents/skills for cross-agent compatibility
2026-06-09 15:47:15 +02:00
Ullrich Schäfer
e4366d626b
Merge branch 'main' into skills-to-agents 2026-06-09 15:47:04 +02:00
Ullrich Schäfer
59bbbcd520 cd-infra: fix Prometheus 3.10 SIGHUP startup kill + add readiness gate
- Capture container ID before/after 'docker compose up -d' and only
  send SIGHUP when the same container persisted (config-only change).
  A recreated container already loaded the fresh config; sending HUP
  immediately after startup kills Prometheus 3.10 with exit code 2.
- Add Prometheus /-/ready gate alongside the postgres/journal health
  check to fail the deploy if monitoring stack never comes up.
- Observed 2026-06-09: infra deploy left trails-cool-prometheus-1
  Exited (2) for ~2h, causing a Grafana alert storm.
2026-06-09 15:44:50 +02:00
Ullrich Schäfer
19f2275b73 Move skills to .agents/skills for cross-agent compatibility
Skills now live in .agents/skills/ — the standard convention
aligned with Pi, OpenAI Codex, and the Agent Skills spec.

.claude/skills is a symlink back to .agents/skills/ so Claude
Code still discovers them.

Updated CLAUDE.md to document the setup.
2026-06-09 13:31:20 +02:00
Ullrich Schäfer
16375f8050
Merge pull request #501 from trails-cool/refactor/config-directory-mounts
refactor(infra): directory-mount configs so deploys actually apply them
2026-06-09 13:16:29 +02:00
Ullrich Schäfer
e60c9d7057 refactor(infra): mount config dirs not single files; reload on deploy
Single-file bind mounts (./foo.yml:/etc/foo.yml) pin to the host file's
inode at container-create time. The CD pipeline scp's a replacement file
(new inode), so the running container keeps reading the OLD inode —
`docker compose up -d` won't recreate on a content-only change, and
neither restart/SIGHUP/`caddy reload` re-reads the new file. Net effect:
config-only infra PRs deployed "successfully" but never took effect
(confirmed with PR #500's prometheus.yml; the WAL retention work only
applied because #498 also changed docker-compose.yml, forcing a
recreate). Caddyfile changes had the same latent gap.

Switch the four single-file config mounts to DIRECTORY mounts, which
resolve children live so a reload/restart picks up the new file:

- prometheus, loki, promtail: mount ./<svc> dir (loki/promtail
  --config.file paths updated to the real filenames).
- caddy: move Caddyfile into caddy/ and mount the dir; ./sites overlays
  /etc/caddy/sites (caddy/sites/.gitkeep keeps the mountpoint). Container
  path /etc/caddy/Caddyfile is unchanged, so every `caddy reload --config
  /etc/caddy/Caddyfile` ref still works. scp source paths in cd-infra and
  cd-apps updated to ship the caddy/ dir.

cd-infra now applies config-only changes after `up -d`: SIGHUP prometheus
(zero downtime), restart loki+promtail (no SIGHUP reload), caddy reload
(graceful). Mirrored prometheus/loki mounts in docker-compose.dev.yml.

Validated: docker compose config (both files), caddy validate from the
new path, read-only-parent + sites-overlay mount mechanics, workflow YAML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:10:35 +02:00
Ullrich Schäfer
8e41b09ac2
Merge pull request #500 from trails-cool/feat/monitoring-observability
feat(infra): self-monitor the observability stack + Overpass alert
2026-06-09 12:27:49 +02:00
Ullrich Schäfer
3a43784f07 feat(infra): self-monitor the observability stack + Overpass alert
Adds the visibility that would have caught the corrupt-WAL incident,
plus a direct Overpass upstream alert.

- prometheus.yml: self-scrape Prometheus (localhost:9090) and Loki
  (loki:3100). Prometheus scraped everything except itself, so TSDB
  health (compaction failures, WAL corruption, head series, retention
  deletions) was invisible.
- monitoring-health.json: new "Monitoring Health" dashboard — TSDB
  compaction/WAL failures, retention deletions/hour, head series,
  samples/s, block bytes vs size-retention limit, retention depth
  (oldest-sample age), Loki ingestion rate + memory chunks.
- alerts.yml: prometheus-compaction-failing (any compaction failure or
  WAL corruption in 1h) and overpass-upstream-unhealthy (>20% upstream
  failure over 10m — sustained public-Overpass degradation, distinct
  from the symptom-level Caddy-502 alert).

Validated: promtool check config, YAML parse, JSON parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:21:16 +02:00
Ullrich Schäfer
42b90a7b78
Merge pull request #499 from trails-cool/fix/overview-dashboard-json
fix(grafana): repair invalid JSON in overview dashboard
2026-06-09 12:18:28 +02:00
Ullrich Schäfer
34c7f24ef2
Merge pull request #498 from trails-cool/fix/prometheus-retention-size
fix(infra): right-size Prometheus storage (retention 6GB + 20s scrape + cAdvisor drops)
2026-06-09 12:18:07 +02:00
Ullrich Schäfer
715cd3dce8
Apply suggestion from @stigi 2026-06-09 12:17:54 +02:00
Ullrich Schäfer
6e48510d75 fix(grafana): repair invalid JSON in overview dashboard
overview.json was missing a comma between the "Health Status" and
"App Error Rate (from logs)" panel objects, making the whole file
invalid JSON. Grafana's file provisioner skips dashboards that fail to
parse, so the entire "trails.cool Overview" dashboard (5 panels) never
loaded — a silent gap in observability. Validated with json.load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:00:19 +02:00
Ullrich Schäfer
f38a224376 perf(infra): scrape every 20s + drop unused cAdvisor metric families
Two cardinality/volume cuts to keep Prometheus storage sustainable
within the size budget:

- scrape_interval 5s -> 20s (evaluation_interval too). 5s tripled the
  sample volume vs the 15s default for no benefit on a single-box
  deployment; all rules use [5m] windows and for: >= 1m.
- Drop high-cardinality cAdvisor families we never chart or alert on
  (container_fs_*, blkio_*, tasks_state, memory_failures_total,
  memory_numa_*, network_tcp*/udp*) on both cadvisor jobs. Dashboards
  only use container CPU, memory, and start_time. This was the bulk of
  the ~12.4k active series.

Validated with `promtool check config`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:59:27 +02:00
Ullrich Schäfer
114f6c2ce7 fix(infra): raise Prometheus retention.size 1GB -> 6GB
Prometheus size-based retention counts the WAL toward its budget. With
retention.size=1GB and a stuck/corrupt WAL that had grown to 2.1GB, the
total was permanently over budget, so Prometheus deleted every
freshly-compacted block the moment it was written. Net effect: only the
in-memory ~2h head block was ever queryable — metrics appeared to "start
this morning" no matter when you looked, and last night's Caddy 502
spike (planner /api/overpass upstream stall, 18:33-18:34 UTC) was
unobservable in Prometheus (logs survived in Loki).

The corrupt WAL was cleared out-of-band on the flagship. This raises the
size backstop to 6GB so compacted blocks survive to the intended 15d
time-based retention. Disk is 38G (43% used), so 6GB is safe headroom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:52:10 +02:00
Ullrich Schäfer
001c53294a
Merge pull request #497 from trails-cool/garmin/provider-import
feat(journal): Garmin activity import — provider, webhook pipeline, backfill (garmin-import §1–5)
2026-06-07 17:51:13 +02:00
Ullrich Schäfer
0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00
Ullrich Schäfer
192481fedb
Merge pull request #496 from trails-cool/openspec/garmin-import-proposal
docs(openspec): propose garmin-import change
2026-06-07 17:18:52 +02:00
Ullrich Schäfer
19f1c06d69 docs(openspec): propose garmin-import change
Garmin Connect as the third connected-services provider, modeled on
wahoo-import but adapted to Garmin's push-first API: OAuth2+PKCE on the
existing oauth credential kind, ping/push webhook ingestion with async
pg-boss processing and an SSRF allowlist on callback URLs, date-range
backfill instead of a pick list (Garmin has no activity-list endpoint),
mandatory deregistration handling. Route push (Courses API) explicitly
deferred to a follow-up, mirroring wahoo-route-push.

Build is fixtures-first; rollout tasks are gated on Garmin Connect
Developer Program approval, which runs in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:15:02 +02:00
Ullrich Schäfer
ebe1b6fd3f
Merge pull request #495 from trails-cool/openspec/archive-social-federation
docs(openspec): archive social-federation; sync delta specs into main
2026-06-07 16:51:04 +02:00
Ullrich Schäfer
d87a1e30c2 docs(openspec): archive social-federation; sync delta specs into main
The change shipped end-to-end (58/58 tasks; live on trails.cool since
2026-06-07, soaked against Mastodon and flagship⇄staging). Delta specs
synced into the mainline:
- specs/social-federation/spec.md created (8 requirements: actor
  objects + WebFinger, signing keypairs, narrow inbox, outbox, push
  delivery, trails-only outbound, poll ingestion, audience filtering)
- specs/social-follows/spec.md: social-activity-feed requirement
  updated for remote rows + audience gating; pending lifecycle for
  outbound remote follows added
- specs/public-profiles/spec.md: Pending follow-button state added

openspec validate --all: green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:47:35 +02:00
Ullrich Schäfer
4b342ad91b
Merge pull request #494 from trails-cool/federation/soak-complete
docs(openspec): social-federation 12.4 soaked — change complete (58/58)
2026-06-07 15:39:26 +02:00
Ullrich Schäfer
2106d345cf docs(openspec): social-federation 12.4 soaked live — change complete (58/58)
Flagship ⇄ staging, both directions, 2026-06-07: follows settled to
Accepted in seconds, first polls ingested each side's public
activities, both feeds render remote attribution. The cross-origin
Accept fix (#490) verified working in production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:36:01 +02:00
Ullrich Schäfer
6cc3be6b11
Merge pull request #493 from trails-cool/federation/prod-flag
feat(infra): enable federation on the flagship (social-federation 12.5)
2026-06-07 14:50:35 +02:00
Ullrich Schäfer
f3a17cf78c feat(infra): enable federation on the flagship (social-federation 12.5)
Wiring follows the IS_FLAGSHIP pattern: the compose env defaults every
FEDERATION_* var to empty (off — the safe self-host default; all
federation surfaces 404), and cd-apps.yml appends
FEDERATION_ENABLED=true + FEDERATION_LOG_LEVEL=info to the flagship's
app.env. FEDERATION_KEY_ENCRYPTION_KEY was already in SOPS.

Rollout 12.2/12.3 soaked on staging against a real Mastodon
(2026-06-06/07); the trails↔trails Accept bug found by the §11 harness
is fixed and deployed. Rollback: drop the two echo lines, merge, rerun
cd-apps — instant off, follow rows persist.

First boot with the flag enqueues backfill-user-keypairs (idempotent)
and registers the federation jobs. Applied via manual cd-apps dispatch
after merge since workflow-file changes don't trigger the path filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:46:32 +02:00
Ullrich Schäfer
b7ae8d2aa0
Merge pull request #492 from trails-cool/federation/rollout-notes
docs(openspec): social-federation §12 rollout status notes
2026-06-07 13:49:33 +02:00
Ullrich Schäfer
819a5d2976 docs(openspec): social-federation §12 rollout status notes
12.1/12.2/12.3/12.6 marked with provenance (additive schema behind the
off flag on prod; the 2026-06-06/07 staging soak against a real
Mastodon covered inbound + push delivery; rollback documented in the
runbook). 12.4 annotated: protocol verified by the two-instance
harness, live staging⇄preview soak queued now that previews federate.
12.5 (prod flag flip) explicitly left as an operator decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:46:14 +02:00
Ullrich Schäfer
a560bde9b7
Merge pull request #491 from trails-cool/federation/preview-flag
ci(staging): enable federation on PR previews (rollout 12.4 surface)
2026-06-07 13:45:49 +02:00
Ullrich Schäfer
1a8ec4e8b9 docs(staging): un-stale the federation comment in compose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:41:36 +02:00
Ullrich Schäfer
24357948c3 ci(staging): enable federation on PR previews (rollout 12.4 surface)
Every PR preview becomes a second live trails instance, so the
trails-to-trails soak (social-federation 12.4) can run against real
DNS + TLS + Caddy: follow from staging.trails.cool to
pr-<N>.staging.trails.cool and back. Same flag + key wiring as
persistent staging; preview teardown leaves the remote side with
ordinary dead-instance delivery failures, which fediverse software
already expires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:41:36 +02:00
Ullrich Schäfer
0acef03e47
Merge pull request #490 from trails-cool/federation/two-instance-test
feat(journal): two-instance federation harness + trails↔trails Accept fix (§11)
2026-06-07 13:40:43 +02:00
Ullrich Schäfer
1eceb6f1f7 feat(journal): two-instance federation harness + trails↔trails Accept fix (§11)
The 11.4 harness — e2e/federation/: postgres (two DBs) + a Caddy with
an internal CA terminating real HTTPS between two complete journal
containers (journal-a.test / journal-b.test), driven by an opt-in
integration test (FEDERATION_TWO_INSTANCE=1, via run.sh). The driver
seeds users straight into each DB, mints a signed session cookie
(known SESSION_SECRET), follows across instances through the real
/follows/outgoing route, and asserts the full pipeline: Follow →
auto-Accept → settle → first outbox poll → bob's public activity
rendered in alice's /feed with @bob@journal-b.test attribution. Plus a
wire-level 11.3 check: unsigned Create(Note) → 4xx, no DB writes.

And the harness immediately earned its keep — it caught a real
trails↔trails bug Mastodon interop never could: our Follow ids are
fragment URIs on OUR domain, so the Follow embedded in another trails
instance's Accept is cross-origin and Fedify rightly distrusts it;
re-fetching the id returns the actor document, getObject() yields a
Person, and the Accept/Reject/Undo listeners bailed silently — follows
stayed Pending forever. The listeners now fall back to the wire
objectId (captured BEFORE getObject(), which memoizes the fetched
document and changes what objectId reports) validated against our
Follow-id shape + the personal inbox's ctx.recipient. Forgery-safe:
settleOutgoingFollow only matches a Pending row toward the
HTTP-Signature-authenticated sender.

Supporting changes:
- federation.server.ts: env-gated allowPrivateAddress
  (FEDERATION_ALLOW_PRIVATE_ADDRESS=true) so the harness's RFC 1918
  Docker network is fetchable — testing only, loudly documented.
- Root .dockerignore: local builds shipped a 1GB+ context and
  overlaid host (darwin) node_modules over the image's own install
  via COPY . . — CI never noticed because it builds from clean
  checkouts. Context is now ~5MB.
- tasks.md: 11.1–11.7 marked with provenance notes; design.md gains
  the cross-origin embedded-object lesson.

Gate: typecheck ✓ lint ✓ unit+integration (FEDERATION_INTEGRATION=1) ✓
e2e 71/72 + known komoot flake green isolated ✓ harness run clean from
scratch (2/2, teardown included) ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:36:35 +02:00
Ullrich Schäfer
314196847c
Merge pull request #489 from trails-cool/federation/poll-backoff
feat(journal): honor 429/Retry-After in outbox polling (§7.4)
2026-06-07 12:39:27 +02:00
Ullrich Schäfer
3c2bdfd2bd feat(journal): honor 429/Retry-After in outbox polling (§7.4)
Completes the 7.4 remainder. Fedify's FetchError carries the failed
Response, so a 429 from a remote's outbox now reads Retry-After
(delta-seconds or HTTP-date per RFC 9110), arms a per-host backoff
(15 min default, capped at the 1 h poll interval), and pollRemoteActor
skips backing-off hosts before doing any DB or network work. State is
in-process like the pacing map — a restart costs at most one extra
request that re-arms the backoff. last_polled_at is deliberately not
stamped on a rate-limited poll so the hourly sweep retries.

Unit tests cover the header parser (incl. the V8 footgun where
Date.parse reads bare negative integers as years), default/cap
behavior, and window expiry; an integration test drives the full
FetchError path and asserts the second poll never touches the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:35:59 +02:00
Ullrich Schäfer
60afacc69e
Merge pull request #487 from trails-cool/federation/feed-remote
feat(journal): audience-aware social feed with remote activities (§8+§9)
2026-06-07 12:35:27 +02:00
Ullrich Schäfer
bf2d8bfbd4 feat(journal): audience-aware social feed with remote activities (§8+§9)
8.1/8.2: listSocialFeed is now a UNION ALL of local and remote
branches, sorted on COALESCE(remote_published_at, created_at):
- local rows: visibility='public' from accepted local follows — and
  the previously missing accepted_at filter is added (the spec's
  'Pending follows contribute nothing' scenario)
- remote rows: gated structurally by joining the viewer's OWN accepted
  follow against the originating actor — which is exactly the
  followers-only audience rule (a row reaches only viewers whose
  follow brought it in); attribution from the remote_actors cache;
  cards link outward to the canonical origin page (no local detail
  page for remote rows)

9.1: annotated — local Pending button shipped with locked accounts;
remote Pending lives on /follows/outgoing.
9.2: already enforced + tested since §3 (actor/webfinger 404).
9.3 hardening: deliver-activity now re-checks the OWNER's profile
visibility at send time, closing the enqueue→delivery flip window
(enqueue-side and inbound-side gates already existed).

Integration tests: the §8 audience-leak guard (A sees followers-only,
B does not), public remote attribution + outward link, pending
contributes nothing, mixed local/remote COALESCE ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:32:11 +02:00
Ullrich Schäfer
f8b24f58ef
Merge pull request #486 from trails-cool/federation/outbox-polling
feat(journal): outbox-poll ingestion of remote trails activities (§7)
2026-06-07 12:30:32 +02:00
Ullrich Schäfer
b96bef91a9 feat(journal): outbox-poll ingestion of remote trails activities (§7)
Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as
remaining). Resolves the activities.owner_id open question.

Schema (design decision from the open question):
- activities.owner_id nullable + check constraint enforcing exactly
  one of (owner_id, remote_actor_iri) — the follows pattern again.
- remote_published_at carries the origin's publish time for the §8
  feed sort; index on remote_actor_iri for the feed join.
- Compiler-audited fallout: notification fan-out, the Note object
  dispatcher, and the activity detail loader explicitly skip/404
  remote rows (their canonical page is the origin; feed links there).
  Every other surface joins users on owner_id and excludes remote rows
  structurally.

Ingestion:
- federation-ingest.server.ts: parseOutboxItem targets exactly our own
  outgoing Create(Note) shape (PropertyValue stats, first-paragraph
  name, audience from to/cc); unknown items skipped, never fatal.
- ingestRemoteActivities: replay-safe via unique remote_origin_iri,
  conflict-streak early exit (outboxes are newest-first).
- pollRemoteActor: signed fetch (Authorized Fetch via a local
  follower's key), outbox resolution via remote_actors cache with
  actor-doc fallback (which refreshes the cache), 1 req/5 s per-host
  pacing, last_polled_at stamping. Network + pacing injectable.

Jobs: poll-remote-actor (per-actor; the §4 Accept listener already
enqueues it as the first poll) + poll-remote-outboxes (5-min cron
sweep over accepted remote follows not polled within the hour).

Tests: parse-shape units; integration suite for ingestion provenance,
audience→visibility mirroring, replay safety, the DB author invariant,
poll flow (actor-doc → collection → page), signer requirement, and
due-polling selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:27:10 +02:00
Ullrich Schäfer
9ada6ab7f3
Merge pull request #488 from trails-cool/federation/privacy-docs
docs(journal): federation privacy manifest + runbook + honest home blurb (§10)
2026-06-07 12:25:49 +02:00
Ullrich Schäfer
f4d2cf027c docs(journal): federation privacy manifest + runbook + honest home blurb (§10)
10.1 Privacy manifest (legal/privacy):
- German legal half: Föderation entry under Empfänger/Drittanbieter —
  what a public profile exposes, what accepted followers' servers
  receive, the loss-of-control over delivered copies, Art. 6(1)(a)
  basis, private profiles don't federate.
- English manifest half: dedicated Federation (ActivityPub) section —
  actor object contents, push delivery + retraction semantics (incl.
  the tombstone caveat), encrypted-at-rest signing keys, remote actor
  cache, ingested remote content with the followers-only viewer gate,
  inbox logging/rate limits. PRIVACY_LAST_UPDATED bumped.

10.2 docs/deployment.md federation runbook: enabling per environment,
key rotation posture, abuse monitoring, and the troubleshooting
checklist distilled from the 2026-06-06/07 soak (IP families first,
no outbox backfill, tombstones, the 10s delivery timeout, actor
re-fetch).

10.3 Home marketing blurb (en+de) aligned to what actually ships:
Mastodon can follow you + trails-to-trails outbound — no more 'follow
friends anywhere' overstatement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 12:21:51 +02:00
Ullrich Schäfer
965df640ad
Merge pull request #485 from trails-cool/federation/outbound-follows
feat(journal): outbound trails-to-trails follows (social-federation §6)
2026-06-07 11:54:14 +02:00
Ullrich Schäfer
62b40a25f3 feat(journal): outbound trails-to-trails follows (social-federation §6)
Tasks 6.1–6.6. A local user can follow a user on another trails
instance: WebFinger-resolve the handle, verify the target runs
trails.cool via NodeInfo — checked against the ACTOR IRI's host, never
the handle's domain (split-domain constraint) — record a Pending
follow row, deliver a signed Follow. The §4 inbox listeners already
settle (Accept) or drop (Reject) the row.

- federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow
  (Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup,
  NodeInfo, delivery) injectable for offline integration tests.
  Software allowlist: trails-cool.
- /follows/outgoing: follow-by-handle form + pending/accepted list +
  cancel; linked from the feed empty state; i18n en+de. Remote actors
  have no local profile page, so the remote Pending state lives here
  (the profile Pending button from locked accounts covers local).
- /.well-known/trails-cool now publishes software: trails-cool (6.2).
- Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed,
  not_trails (6.3).
- Tests: handle-parser + allowlist units; integration suite for the
  Pending lifecycle (create/idempotent/refuse-non-trails/refuse-
  unresolvable/own-host/cancel+Undo) with injected network deps; e2e
  anonymous-redirect guard for the new page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:50:35 +02:00
dependabot[bot]
8afa41690d [github-actions] pnpm dedupe 2026-06-07 09:49:00 +00:00
dependabot[bot]
a4582654e1
chore(deps): bump the production group across 1 directory with 30 updates
Bumps the production group with 29 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.19.3` | `0.19.4` |
| [@fission-ai/openspec](https://github.com/Fission-AI/OpenSpec) | `1.3.1` | `1.4.1` |
| [fit-file-parser](https://github.com/jimmykane/fit-parser) | `3.0.1` | `3.0.2` |
| [i18next](https://github.com/i18next/i18next) | `26.3.0` | `26.3.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.60.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.7` | `4.1.8` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.40` | `5.1.41` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.13` | `56.0.14` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.3.0` | `11.3.3` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.3` | `3.5.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.16` | `56.0.17` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.18` | `56.0.19` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.15` | `56.0.16` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.15` | `56.0.16` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.8` | `56.2.9` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.4.0` | `4.4.1` |
| [react-test-renderer](https://github.com/facebook/react/tree/HEAD/packages/react-test-renderer) | `19.2.6` | `19.2.7` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.7` | `4.1.8` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.7` | `4.1.8` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.16.0` | `7.17.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.16.0` | `7.17.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.16.0` | `7.17.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.55.0` | `10.56.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.56.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.19` | `22.19.20` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.16.0` | `7.17.0` |



Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@expo/fingerprint` from 0.19.3 to 0.19.4
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `@fission-ai/openspec` from 1.3.1 to 1.4.1
- [Release notes](https://github.com/Fission-AI/OpenSpec/releases)
- [Changelog](https://github.com/Fission-AI/OpenSpec/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Fission-AI/OpenSpec/compare/v1.3.1...v1.4.1)

Updates `fit-file-parser` from 3.0.1 to 3.0.2
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

Updates `i18next` from 26.3.0 to 26.3.1
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1)

Updates `typescript-eslint` from 8.60.0 to 8.60.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/typescript-eslint)

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

Updates `isbot` from 5.1.40 to 5.1.41
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.40...v5.1.41)

Updates `@expo/metro-runtime` from 56.0.13 to 56.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.3.0 to 11.3.3
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.3.0...v11.3.3)

Updates `@sentry/cli` from 3.4.3 to 3.5.0
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.3...3.5.0)

Updates `expo-constants` from 56.0.16 to 56.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 56.0.18 to 56.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-location` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.8 to 56.2.9
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `react-native-reanimated` from 4.4.0 to 4.4.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.4.1/packages/react-native-reanimated)

Updates `@types/react` from 19.2.15 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-test-renderer` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-test-renderer)

Updates `@vitest/browser` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/browser-playwright)

Updates `@react-router/dev` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.17.0/packages/react-router-dev)

Updates `@react-router/node` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.17.0/packages/react-router-node)

Updates `@react-router/serve` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.17.0/packages/react-router-serve)

Updates `@sentry/node` from 10.55.0 to 10.56.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.55.0...10.56.0)

Updates `@sentry/react` from 10.53.1 to 10.56.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.56.0)

Updates `@types/node` from 22.19.19 to 22.19.20
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `react-router` from 7.16.0 to 7.17.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.17.0/packages/react-router)

Updates `vite` from 7.3.3 to 7.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@fission-ai/openspec"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: fit-file-parser
  dependency-version: 3.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.60.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-test-renderer
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vite
  dependency-version: 7.3.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 09:48:14 +00:00
Ullrich Schäfer
be53f6670a
Merge pull request #483 from trails-cool/chore/fedify-2.2.5
chore(journal): upgrade @fedify/fedify 2.1.16 → 2.2.5
2026-06-07 11:29:55 +02:00
Ullrich Schäfer
6fd7329f15 chore(journal): upgrade @fedify/fedify 2.1.16 → 2.2.5
The 2.2.x line was uninstallable when federation landed
(@fedify/webfinger@2.2.x wasn't published); fixed upstream, so move to
latest. Exact pin stays deliberate — a federation library defines the
wire shapes remote instances see.

Verified: typecheck, lint, full unit + integration suites (245 tests,
including the wire-shape assertions: JRD, actor attachment arrays,
PropertyValue fields, dereferenceable Note ids) all green on 2.2.5.
Lockfile diff is a clean 24-line swap of the fedify package family.

Note: a transient cross-suite flake surfaced when running both
integration suites concurrently (shared journal.follows state) —
pre-existing, unrelated to the upgrade; clean re-run green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:26:01 +02:00
Ullrich Schäfer
7bac3353e0
Merge pull request #482 from trails-cool/docs/split-domain-notes
docs: split-domain handles idea + interop constraint for the trails check
2026-06-07 11:18:49 +02:00
Ullrich Schäfer
bf907976c4 docs: split-domain handles idea + interop constraint for the trails check
Prompted by Hollo's split-domain setup. Two parts:

- docs/ideas/split-domain-handles.md: offering split handle/server
  domains to self-hosters is config-level work — Fedify's origin
  option natively accepts { handleHost, webOrigin } — and handles are
  permanent identity, so apex handles matter. Single-domain stays the
  default (Decision #18); post-launch polish.
- Interop constraint pinned in social-federation task 6.1 and
  route-federation's gating decision: the trails-to-trails NodeInfo
  check must run against the actor IRI's host after WebFinger
  resolution, never the handle's domain — split-domain remote
  instances would otherwise be wrongly refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:18:13 +02:00
Ullrich Schäfer
76b468d422
Merge pull request #474 from trails-cool/fix/tombstone-aware-retraction
fix(journal): retract federated activities only on public→non-public
2026-06-07 11:12:20 +02:00
Ullrich Schäfer
e3b960e0ef
Merge pull request #476 from trails-cool/docs/fediverse-ideas
docs: capture post-v1 fediverse enhancement ideas from the soak
2026-06-07 11:00:19 +02:00
Ullrich Schäfer
5d31563008
Merge branch 'main' into fix/tombstone-aware-retraction 2026-06-07 10:57:26 +02:00
Ullrich Schäfer
09abd5708e docs: capture post-v1 fediverse enhancement ideas from the soak
Ullrich's notes from the first real federation exchange (2026-06-06/07
staging ↔ Mastodon soak), expanded with the constraints the soak
surfaced: route map images in Notes, fediverse kudos (inbound
Like/Announce), comments from the fediverse, planned routes as
Events (Mobilizon), Wanderer interop, federated explore.

The fourth note item — remote follower counted but not listed — was a
bug, fixed separately in #475.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:57:02 +02:00
Ullrich Schäfer
10a9ea523a
Merge pull request #478 from trails-cool/docs/architecture-vision-capture
docs: capture remaining architecture vision; draft route-federation change
2026-06-07 10:35:36 +02:00
Ullrich Schäfer
bf9787e56a docs: capture remaining architecture vision; draft route-federation change
Gap analysis of docs/architecture.md against openspec/ and docs/ideas/
found the envisioned-but-uncaptured remainder. This commit captures it:

New OpenSpec change (validated):
- route-federation — the collaboration half of the federation vision:
  routes as dereferenceable trails:Route objects, Create/Update
  fan-out, Invite/Accept collaboration mirroring (arch decision #2),
  cross-instance Planner edits via HTTP-Signature-requested scoped
  tokens (decisions #3/#12), mirror sync healing (#16). Depends on
  social-federation §6 + route-sharing; carries the 2026-06-07 soak
  lessons as design constraints.

New docs/ideas/ explorations:
- instance-administration — registration toggle, suspend/ban,
  federation blocklists, reports (moderation now gates the federated
  comments idea)
- social-interactions — local likes + comments (don't exist even
  locally; the foundation federated kudos/comments attach to)
- activity-participants — group tagging with confirm/decline +
  federated mentions (the participants jsonb column is an untyped stub)
- multi-day-collections — architecture open question #1, directions
  evaluated

architecture.md cleanup:
- Mastodon-compat section annotated with shipped/captured state + the
  live-soak interop lessons (attachment arrays, tombstones, 10s
  timeout, no backfill)
- api.trails.cool removed (contradicted resolved decision #18)
- Phase 1 ticked (shipped); Phase 2/3 items annotated with where each
  is tracked; specs/ → openspec/, activity.* → journal.*, cx21 → cx23
- brouter-web open question marked resolved-in-practice; new section
  pointing to where the unshipped vision is tracked

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:31:50 +02:00
Ullrich Schäfer
a26d59c804
Merge pull request #473 from trails-cool/fix/credential-kind-public
fix(db): allow 'public' credential_kind in connected_services check
2026-06-07 10:18:10 +02:00
Ullrich Schäfer
1a96a07a8a
Merge branch 'main' into fix/credential-kind-public 2026-06-07 10:14:10 +02:00
Ullrich Schäfer
c6167c517f
Merge pull request #477 from trails-cool/infra/scheduled-image-prune
ci: scheduled image prune + staging-deploy disk hygiene
2026-06-07 09:56:37 +02:00
Ullrich Schäfer
89caedca05 ci: scheduled image prune + staging-deploy disk hygiene
The 2026-06-07 disk-full outage (flagship 100%, postgres crash-looping
on its pidfile, prod + staging down): cd-apps DOES prune images after
deploys, but it had been failing early all day (migration bug), so its
prune never ran — while ~10 staging/preview deploys kept pulling fresh
images with no prune of their own.

- cd-staging: prune superseded layers (until=1h guard against racing
  in-flight pulls) after persistent staging and preview deploys.
- disk-maintenance.yml: NEW daily scheduled prune (04:30 UTC) that
  also FAILS when the disk is still ≥85% after pruning — a redundant
  alert channel for exactly the case where the Grafana disk alert
  drowns in other noise, as it did during the incident.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:52:49 +02:00
Ullrich Schäfer
d5a8709688
Merge pull request #475 from trails-cool/fix/remote-followers-listing
fix(journal): show remote followers in follower/following lists
2026-06-07 09:17:23 +02:00
Ullrich Schäfer
4e63f7631a fix(journal): show remote followers in follower/following lists
countFollowers counts every accepted follow row, but listFollowers
inner-joined users on follower_id — so a federated follower (NULL
follower_id, follower_actor_iri set) bumped the count while never
appearing in the list. Observed live: profile said '1 follower', list
below was empty. listFollowing had the same latent bug for outbound
trails-to-trails follows.

Both lists now left-join users + remote_actors: local entries link to
the local profile as before; remote entries display cached actor data
(IRI parsing as fallback) and link out to the remote profile.

Integration test pins count/list consistency for a remote follower.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:13:44 +02:00
Ullrich Schäfer
20be961177 fix(journal): retract federated activities only on public→non-public
A received Delete is recorded by Mastodon as a permanent tombstone —
later Creates for the same URI are silently refused forever. The old
code sent Delete on every non-public save 'just in case' (the comment
even called over-sending harmless); on the 2026-06-07 soak this
tombstoned an unlisted activity's URI before its first real publish,
making its later flip to public invisible on the remote with no error
anywhere.

- visibilityTransitionAction(previous, next): Create on any transition
  to public (re-publish doubles as back-delivery; remotes dedupe by
  id), Delete only when leaving public, nothing for
  non-public→non-public.
- updateActivityVisibility reads the previous visibility and acts on
  the transition.
- design.md documents the tombstone permanence + the user-facing
  consequence (un-publish then re-publish won't resurrect the post on
  remotes that processed the retraction — same as Mastodon's own
  delete-and-redraft).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:52:23 +02:00
Ullrich Schäfer
5bf9358dc6 fix(db): allow 'public' credential_kind in connected_services check
The 0002 migration's allowed list predates the Komoot public-profile
connection mode (api.sync.komoot.verify.ts writes credentialKind
'public'). Against production data — which has such a connection —
the drop-then-add CHECK failed at ATRewriteTable and blocked every
cd-apps deploy since 2026-06-07 06:25 UTC.

Also documents 'public' in the schema comment with a pointer to keep
the two lists in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:48:27 +02:00
Ullrich Schäfer
95b21e2c44
Merge pull request #472 from trails-cool/federation/profile-fields-array
fix(journal): actor profile fields must serialize as a JSON array
2026-06-07 08:46:48 +02:00
Ullrich Schäfer
d74ce32cd0 fix(journal): actor profile fields must serialize as a JSON array
Mastodon's PropertyValue parser requires the actor's attachment to be
a JSON *array* and silently ignores a bare object — and Fedify
compacts single-element arrays to bare objects, so the 🥾 trails.cool
field shipped in #464 never rendered (verified: Mastodon stored
fields = {} after a forced actor refresh on the soak instance).

Ship two fields so the array survives serialization: the 🥾 profile
link (rel=me) plus an Instance link — the latter is genuinely useful
for self-hosted instances anyway. Test now asserts the array shape
explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:03 +02:00
Ullrich Schäfer
b0bfed4d4a
Merge pull request #471 from trails-cool/infra/caddy-global-metrics
fix(infra): move Caddy metrics to global option
2026-06-07 08:34:13 +02:00
Ullrich Schäfer
9ed6b7dd1d fix(infra): move Caddy metrics to global option
Caddy 2.9 deprecated the nested `servers { metrics }` form; every
deploy logs a deprecation warning. The global `metrics` option is the
replacement and emits the same Prometheus metrics on the admin
endpoint, so the existing scrape config is unaffected.

Validated with `caddy validate` against the caddy:2 image — config
valid, no deprecation warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:30:17 +02:00
Ullrich Schäfer
a4807aab1a
Merge pull request #469 from trails-cool/fix/boss-singleton-globalthis
fix(journal): pg-boss singleton must live on globalThis, not module scope
2026-06-07 08:18:40 +02:00
Ullrich Schäfer
433365d78d
Merge pull request #470 from trails-cool/infra/deploy-hardening
ci(deploy): fail loudly — set -e, drizzle output guards, health gates
2026-06-07 08:17:55 +02:00
Ullrich Schäfer
f790da2ed3 ci(deploy): fail loudly — set -e, drizzle output guards, health gates
Hardening from two incidents on 2026-06-06/07:

Schema drift (morning): drizzle-kit push exits 0 even when it aborts
on an interactive prompt it can't render in CI, so cd-staging's
set -euo pipefail never fired and a month of staging schema drift
accumulated silently until new code hit missing columns. All three
drizzle push call sites now tee output and fail the deploy on any
'Error:' line.

Production outage (overnight, ~9h): cd-infra and cd-apps had no
failure handling at all. A network-option change stopped postgres for
a network recreation that then deadlocked on containers from other
compose projects holding trails-shared; the script carried on, the
stack stayed down. Both scripts now run set -euo pipefail and gate on
container health at the end (postgres+journal for cd-infra,
journal+planner for cd-apps) so a deploy that leaves the stack down
is a red X, not a shrug.

docs/deployment.md gains the cross-project manual procedure for
network-changing deploys — the CD workflows only manage their own
compose project and cannot apply those safely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:15:17 +02:00
Ullrich Schäfer
c46dc0cbd6
Merge pull request #467 from trails-cool/federation/dereferenceable-notes
feat(journal): dereferenceable Note objects at /activities/:id
2026-06-07 08:12:54 +02:00
Ullrich Schäfer
a34bb0064e
Merge branch 'main' into fix/boss-singleton-globalthis 2026-06-07 08:09:18 +02:00
Ullrich Schäfer
d2be01d6ea
Merge branch 'main' into federation/dereferenceable-notes 2026-06-07 08:09:16 +02:00
Ullrich Schäfer
007845cb59
Merge pull request #468 from trails-cool/infra/trails-shared-ipv6
fix(infra): IPv6 belongs on trails-shared, not a staging default network
2026-06-06 23:25:13 +02:00
Ullrich Schäfer
e4d01f51ca fix(journal): pg-boss singleton must live on globalThis, not module scope
In production there are two copies of boss.server.ts in the process:
server.ts imports the TypeScript source directly (node
--experimental-strip-types) while route handlers live in the bundled
build/server/index.js with its own module instance. setBoss() from
server.ts wrote a module-local variable the bundle never saw, so EVERY
request-path enqueue failed with 'pg-boss not initialized' and was
swallowed by enqueueOptional's best-effort catch:

- notifications fan-out on activity publish: silently dropped
- komoot bulk-import kick-off: silently dropped
- federation activity push delivery: silently dropped (how we found
  it — flipping an activity public on staging delivered nothing)

Dev never reproduces this: vite serves one module graph. Caught live
during the social-federation staging soak.

Fix: store the instance on globalThis under a Symbol.for() key so both
module copies resolve the same boss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:23:59 +02:00
Ullrich Schäfer
2d8046b9a6 fix(infra): IPv6 belongs on trails-shared, not a staging default network
Follow-up to #466: staging and preview services attach only to the
externally-named trails-shared network (created by the production
compose project) — they have no per-project default network, so the
enable_ipv6 added to the staging file was dead config. Move the flag
to trails-shared where it takes effect.

Rollout requires recreating trails-shared on the flagship: detach
staging, previews, and production postgres, remove the network, re-up.
Production journal/planner are unaffected (they use the default
network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:21:25 +02:00
Ullrich Schäfer
5db983386d feat(journal): dereferenceable Note objects at /activities/:id
The Notes we emit (outbox + push delivery) use /activities/{id} as
their id, but that URL only served HTML — so Mastodon's search-fetch
of an activity URL failed, and strict instances that re-fetch pushed
objects to verify them would drop our deliveries.

- Fedify object dispatcher for Note at /activities/{id}: serves the
  same activityToNote mapping used everywhere else; 404 unless the
  activity is public AND the owner's profile is public (private owners
  don't federate, mirroring the actor).
- Content-negotiation middleware on the activity detail route, same
  pattern as the actor route: AP Accept headers short-circuit to
  Fedify, browsers get HTML.

Found during the staging soak (Mastodon showed the outbox post count
but couldn't fetch the posts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:18:30 +02:00
Ullrich Schäfer
fc8d35d7d4
Merge pull request #466 from trails-cool/infra/docker-ipv6-egress
feat(infra): enable IPv6 egress on Docker compose networks
2026-06-06 23:16:01 +02:00
Ullrich Schäfer
915b8121d0 feat(infra): enable IPv6 egress on Docker compose networks
The journal container could not reach v6-only fediverse instances
(e.g. social.ullrich.is) because the compose project network was
IPv4-only. The flagship host has working IPv6, and Docker >= 27
auto-allocates a ULA subnet with NAT66 masquerading when a network
sets enable_ipv6, so the fix is a one-line opt-in per network.

Applying it to an existing deployment requires recreating the
network (docker compose down && up -d) — a brief full-stack
restart on the flagship and the persistent staging project.
PR-preview networks are created fresh per PR and need nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:12:03 +02:00
Ullrich Schäfer
0cb2a901fe
Merge pull request #465 from trails-cool/federation/inbox-queue
feat(journal): async federation inbox via Fedify message queue
2026-06-06 22:05:32 +02:00
Ullrich Schäfer
7bcfa4cc76 feat(journal): async federation inbox via Fedify message queue
Mastodon gives inbox deliveries a 10s read timeout; without a queue,
Fedify runs inbox listeners AND outbound deliveries (the Accept we
push back after a Follow) inside the inbound request. A slow or
unreachable remote then blows the budget, the sender times out and
its circuit breaker (Stoplight on Mastodon) cuts us off — observed
live during the staging soak.

InProcessMessageQueue + manuallyStartQueue: the queue consumer starts
explicitly on first federation use (skipped under vitest so tests
don't leak timers). In-process queueing is acceptable for the
single-process journal: queued work lost on restart is recoverable
(remotes retry Follows; activity push delivery is owned by our own
pg-boss jobs). Revisit with a Postgres-backed MessageQueue if that
changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:02:04 +02:00
Ullrich Schäfer
74e50b2d6a
Merge pull request #464 from trails-cool/federation/actor-profile-fields
feat(journal): trails profile-metadata field on federated actors
2026-06-06 21:51:42 +02:00
Ullrich Schäfer
08d0a78c57 feat(journal): trails profile-metadata field on federated actors
Mastodon renders PropertyValue attachments as the profile metadata
table — adds a '🥾 trails.cool' field linking to the user's canonical
profile, so it's human-visible that an account is a trails profile.
(The machine-readable signal stays NodeInfo; this is flair.) The link
carries rel=me so Mastodon can verify it once our HTML profile links
back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:48:07 +02:00
Ullrich Schäfer
d13a0fcd18
Merge pull request #463 from trails-cool/federation/logtape
feat(journal): surface Fedify logs via LogTape console sink
2026-06-06 21:45:40 +02:00
Ullrich Schäfer
9a1ce61604 feat(journal): surface Fedify logs via LogTape console sink
Inbound Mastodon Follows are being rejected 401 (signature
verification) on staging and Fedify's diagnostics were invisible:
it logs through LogTape, which is silent until configured.

- Configure LogTape with a console sink for the 'fedify' category when
  the federation instance is built; level via FEDERATION_LOG_LEVEL
  (default info).
- Staging deploy sets FEDERATION_LOG_LEVEL=debug for the soak —
  signature-verification detail per request; dial down once proven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:41:28 +02:00
Ullrich Schäfer
b9b1541dc6
Merge pull request #462 from trails-cool/staging/enable-federation
feat(staging): enable federation on persistent staging
2026-06-06 15:51:08 +02:00
Ullrich Schäfer
69dbeb7841
Merge branch 'main' into staging/enable-federation 2026-06-06 15:47:44 +02:00
Ullrich Schäfer
57f9440cf5 feat(staging): enable federation on persistent staging
social-federation rollout step 12.2 — inbound soak. Persistent staging
gets FEDERATION_ENABLED=true; PR previews keep the flag empty so
preview journals never emit federation traffic.

- docker-compose.staging.yml: journal accepts FEDERATION_ENABLED +
  FEDERATION_KEY_ENCRYPTION_KEY (both default empty).
- cd-staging.yml: persistent staging env sets FEDERATION_ENABLED=true;
  the encryption key arrives via the existing SOPS decryption.
- secrets.app.env: new FEDERATION_KEY_ENCRYPTION_KEY (SOPS-encrypted,
  generated with openssl rand -hex 32).

Production is unaffected: the flagship compose doesn't pass these vars
and the flag defaults off in code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:45:44 +02:00
Ullrich Schäfer
1d4f364870
Merge pull request #461 from trails-cool/federation/outbox
feat(journal): federation outbox + push delivery to remote followers
2026-06-06 15:36:47 +02:00
Ullrich Schäfer
bc233e03e5 feat(journal): federation outbox + push delivery to remote followers
social-federation tasks 5.1–5.6. Completes the inbound-federation
story: a Mastodon follower now receives a trails user's new public
activities in their home timeline.

Outbox (5.1/5.2):
- /users/:username/outbox — paginated OrderedCollection of public
  activities as Create(Note), newest first; unlisted/private never
  federate. Private-user 404 enforced at the route layer because
  Fedify builds collection-level responses from counter/cursors
  without consulting the page dispatcher.
- Note shape: HTML content (escaped name/description/stats + link to
  the activity page) with structured PropertyValue attachments
  (distance-m, elevation-gain-m, duration-s) — Mastodon renders the
  text, trails consumers read the structured fields. Resolves the
  design open question toward Create(Note).
- Authorized Fetch: signed and unsigned outbox fetches deliberately
  see the same (public-only) content until locked accounts exist.

Push delivery (5.3–5.6):
- createActivity / updateActivityVisibility(→public) enqueue one
  deliver-activity job per accepted remote follower; flips away from
  public and hard deletes enqueue Delete(Tombstone) retractions
  (enqueued before the row disappears).
- deliver-activity job: re-reads the row at delivery time (skips if
  gone or no longer public), resolves the recipient inbox via the
  remote_actors cache with actor-document fetch fallback (priming the
  cache), HTTP-signs via the owner's key, and POSTs. retryLimit 8 +
  exponential backoff at enqueue time; outbound paced at 1 req/s per
  remote host.
- Actor objects now advertise the outbox IRI.
- @js-temporal/polyfill added (same range Fedify uses) for published
  timestamps; Fedify's types want the global esnext.temporal namespace,
  bridged with a documented cast.

Tests: 9 unit tests for the AS mapping (escaping, stats, attachments,
published fallback, stable ids, tombstones), 4 outbox integration
tests (collection count, page shape/visibility filtering, private-404,
delivery audience query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:32:52 +02:00
Ullrich Schäfer
bec249f93f
Merge pull request #460 from trails-cool/federation/inbox
feat(journal): federation inbox — Mastodon follows land here
2026-06-06 15:27:56 +02:00
Ullrich Schäfer
ae6c338cdc
Merge branch 'main' into federation/inbox 2026-06-06 15:23:32 +02:00
Ullrich Schäfer
5248256574
Merge pull request #459 from trails-cool/federation/schema
feat(journal): federation schema + per-user signing keypairs
2026-06-06 15:19:18 +02:00
Ullrich Schäfer
87b61ce53b
Merge branch 'main' into federation/schema 2026-06-06 15:15:39 +02:00
Ullrich Schäfer
b17685d58c feat(journal): federation inbox — Mastodon follows land here
social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:33:43 +02:00
Ullrich Schäfer
ae0e95fcd5
Merge pull request #458 from trails-cool/federation/fedify-spike
feat(journal): Fedify foundation — WebFinger + actor objects behind FEDERATION_ENABLED
2026-06-06 14:21:00 +02:00
Ullrich Schäfer
9a90b6db55 feat(journal): federation schema + per-user signing keypairs
social-federation tasks 2.1–2.5:

- users.public_key / users.private_key_encrypted (TEXT NULL): RSA 2048
  keypairs as JWK JSON; private key AES-256-GCM encrypted at rest with
  FEDERATION_KEY_ENCRYPTION_KEY.
- remote_actors table: cache of remote AP actors (display fields,
  inbox/outbox URLs, public key, software discovery field, poll cursor).
- activities.remote_origin_iri (UNIQUE) / remote_actor_iri / audience:
  provenance + audience tagging for rows ingested from remote outboxes.
  Replay-safe ingestion keys off the unique origin IRI.
- crypto.server.ts: generalized into createAesCipher(envVar, salt)
  factory; existing INTEGRATION_SECRET surface unchanged.
- federation-keys.server.ts: generate/ensure/load keypairs.
  RSASSA-PKCS1-v1_5 because that's what Mastodon interops on.
- backfill-user-keypairs pg-boss job: enqueued once per server startup
  when FEDERATION_ENABLED=true; only touches users with NULL keys, so
  re-runs are no-ops. New users get keys at registration (both passkey
  and magic-link paths), best-effort with the backfill as safety net.
- design.md: noted open question — activities.owner_id is NOT NULL but
  remote-ingested rows have no local owner; decide in task 7.2.

Schema is additive; zero behavior change while FEDERATION_ENABLED is
off. db:push verified clean against local Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:15:51 +02:00
Ullrich Schäfer
4ef86e4dc2 feat(journal): Fedify spike — WebFinger + actor objects behind FEDERATION_ENABLED
social-federation tasks 1.1–1.3:

- Add @fedify/fedify pinned to exactly 2.1.16: every 2.2.x release
  depends on @fedify/webfinger@2.2.x which was never published to npm,
  so 2.1.16 is the newest installable version.
- app/lib/federation.server.ts: Federation instance with an actor
  dispatcher serving Person objects for public users; private and
  unknown users 404 (no existence leak). MemoryKvStore for now.
- /.well-known/webfinger resource route delegating to federation.fetch.
- ActivityPub content negotiation on /users/:username via route
  middleware (future.v8_middleware — no loader uses the context arg,
  so the flag is a no-op for existing code).
- FEDERATION_ENABLED env flag (default off) gating every federation
  surface.
- Unit tests exercise the Fedify dispatcher as a remote AP client:
  WebFinger resolution, actor fetch with Mastodon's Accept header,
  private-user 404s, flag-off 404s.

Spike verdict: Fedify fits — URL dispatch, JRD/AP serialization, and
visibility gating all work through framework routes without a custom
server layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:15:27 +02:00
Ullrich Schäfer
8638c2fdfa
Merge pull request #451 from trails-cool/dependabot/npm_and_yarn/production-3c42ababf0
chore(deps): bump the production group with 28 updates
2026-06-06 13:23:10 +02:00
Ullrich Schäfer
9297d1e511
Merge branch 'main' into dependabot/npm_and_yarn/production-3c42ababf0 2026-06-06 13:19:49 +02:00
Ullrich Schäfer
52dc571374
Merge pull request #457 from trails-cool/docs/federation-unblocked
docs: fix stale route-sharing → social-federation dependency
2026-06-06 13:18:41 +02:00
Ullrich Schäfer
81ba2d5de3 docs: fix stale route-sharing → social-federation dependency
The roadmap claimed social-federation depends on route-sharing for
public routes, but that prerequisite shipped independently via
public-content-visibility (2026-04-24) and social-feed (2026-04-25):
visibility columns, public profiles, and IRI-keyed follows are all in
place. Reorder Phase 1 to reflect that federation can start now.

Also flag route-sharing's proposal as needing re-scoping: tasks
1.1-1.3 and 3.1 duplicate already-shipped visibility work, and the
proposed enum contradicts the shipped text-column design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:16:06 +02:00
dependabot[bot]
4a21f4e115 [github-actions] pnpm dedupe 2026-05-31 08:37:48 +00:00
dependabot[bot]
2e6a83b063
chore(deps): bump the production group with 28 updates
Bumps the production group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.19.2` | `0.19.3` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.4.1` |
| [fit-file-parser](https://github.com/jimmykane/fit-parser) | `3.0.0` | `3.0.1` |
| [i18next](https://github.com/i18next/i18next) | `26.2.0` | `26.3.0` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.14` | `2.9.16` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.4` | `8.60.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.0` | `13.3.1` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.8` | `8.0.10` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `56.0.12` | `56.0.13` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.2.1` | `11.3.0` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.12.0` | `8.13.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `56.0.15` | `56.0.16` |
| [expo-crypto](https://github.com/expo/expo/tree/HEAD/packages/expo-crypto) | `56.0.3` | `56.0.4` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `56.0.15` | `56.0.18` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `56.0.11` | `56.0.13` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `56.0.13` | `56.0.15` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `56.0.13` | `56.0.15` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `56.2.6` | `56.2.8` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.3.1` | `4.4.0` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.7.0` | `5.8.0` |
| [react-native-worklets](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-worklets) | `0.8.3` | `0.9.1` |
| [yjs](https://github.com/yjs/yjs) | `13.6.30` | `13.6.31` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.15.1` | `7.16.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.15.1` | `7.16.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.15.1` | `7.16.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.55.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.53.1` | `10.55.0` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.15.1` | `7.16.0` |


Updates `@expo/fingerprint` from 0.19.2 to 0.19.3
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `eslint` from 10.4.0 to 10.4.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.4.1)

Updates `fit-file-parser` from 3.0.0 to 3.0.1
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

Updates `i18next` from 26.2.0 to 26.3.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.2.0...v26.3.0)

Updates `turbo` from 2.9.14 to 2.9.16
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.14...v2.9.16)

Updates `typescript-eslint` from 8.59.4 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/typescript-eslint)

Updates `@simplewebauthn/server` from 13.3.0 to 13.3.1
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

Updates `nodemailer` from 8.0.8 to 8.0.10
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.8...v8.0.10)

Updates `@expo/metro-runtime` from 56.0.12 to 56.0.13
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@maplibre/maplibre-react-native` from 11.2.1 to 11.3.0
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.2.1...v11.3.0)

Updates `@sentry/react-native` from 8.12.0 to 8.13.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.12.0...8.13.0)

Updates `expo-constants` from 56.0.15 to 56.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-crypto` from 56.0.3 to 56.0.4
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-crypto/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-crypto)

Updates `expo-dev-client` from 56.0.15 to 56.0.18
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-linking` from 56.0.11 to 56.0.13
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-linking/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 56.0.13 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-notifications` from 56.0.13 to 56.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-notifications/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 56.2.6 to 56.2.8
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-router/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `react-native-reanimated` from 4.3.1 to 4.4.0
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.4.0/packages/react-native-reanimated)

Updates `react-native-safe-area-context` from 5.7.0 to 5.8.0
- [Release notes](https://github.com/AppAndFlow/react-native-safe-area-context/releases)
- [Commits](https://github.com/AppAndFlow/react-native-safe-area-context/compare/v5.7.0...v5.8.0)

Updates `react-native-worklets` from 0.8.3 to 0.9.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/worklets-0.9.1/packages/react-native-worklets)

Updates `yjs` from 13.6.30 to 13.6.31
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.30...v13.6.31)

Updates `@react-router/dev` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.16.0/packages/react-router-dev)

Updates `@react-router/node` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.16.0/packages/react-router-node)

Updates `@react-router/serve` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.16.0/packages/react-router-serve)

Updates `@sentry/node` from 10.53.1 to 10.55.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.55.0)

Updates `@sentry/react` from 10.53.1 to 10.55.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.53.1...10.55.0)

Updates `react-router` from 7.15.1 to 7.16.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.16.0/packages/react-router)

---
updated-dependencies:
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: fit-file-parser
  dependency-version: 3.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 56.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 56.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-crypto
  dependency-version: 56.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 56.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 56.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 56.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 56.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-worklets
  dependency-version: 0.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: yjs
  dependency-version: 13.6.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-31 08:37:04 +00:00
Ullrich Schäfer
f7002be0bd
Merge pull request #450 from trails-cool/fix/extensionless-server-imports
fix: extensionless server-side imports (Grafana app-crash-log smoking gun)
2026-05-26 08:12:41 +02:00
Ullrich Schäfer
10deae88c9
fix: extensionless server-side imports + lint rule to catch them
## Symptom

Grafana's \`app-crash-log\` alert fires on every deploy. The Loki query
that backs it (\`ERR_|FATAL|uncaughtException|…\`) matches:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/app/apps/journal/app/lib/logger.server' imported from
  '/app/apps/journal/app/lib/email.server.ts'

A real bug, not a false positive: \`email.server.ts\` line 2 was
\`import { logger } from \"./logger.server\"\` — no extension.

## Why typecheck didn't catch it

\`tsconfig.base.json\` sets \`moduleResolution: \"bundler\"\`. The bundler
resolver accepts extensionless relative imports because Vite / esbuild
/ webpack resolve them at build time. TypeScript was happy.

Production runs \`node --experimental-strip-types server.ts\`, which
uses Node's NodeNext ESM resolver — strict about extensions. The two
resolvers disagree silently for files Node loads directly (server.ts,
\`.server.ts\` modules dynamically imported from it, and jobs).

## Fix

1. **Added \`.ts\` extensions to the 4 broken imports** I could find:
   - \`apps/journal/app/lib/email.server.ts\` → \`./logger.server.ts\`
     (the actual deploy-blocker)
   - \`apps/planner/app/lib/brouter.ts\` → \`./route-merge.ts\`, \`./http.server.ts\`
   - \`apps/planner/app/lib/use-nearby-pois.ts\` → \`./overpass.ts\`
   - \`packages/map/src/MapView.tsx\` → \`./layers.ts\` (caught by the rule)

2. **Added \`eslint-plugin-import-x\` with \`import-x/extensions: always\`**,
   scoped to files Node actually executes raw:
   - \`apps/*/server.ts\`
   - \`apps/*/app/lib/**/*.server.ts\`
   - \`apps/*/app/jobs/**/*.ts\`
   - \`packages/*/src/**/*.{ts,tsx}\`

   Ignored: route-scoped \`*.server.ts\` files (bundled by Vite into the
   React Router build, never loaded by Node), and test files (Vitest's
   own resolver).

## What's still possible

- Non-\`.server.ts\` files in \`app/lib\` (e.g. \`legal.ts\`, \`actor-iri.ts\`)
  could still ship extensionless imports. They're not in the lint
  scope. If one breaks at runtime we can extend the glob — for now
  they tend to be tiny utility modules that don't import other
  relatives.
- The deeper fix would be \`moduleResolution: nodenext\` for server-side
  tsconfig, or bundling the server code so Node never sees raw \`.ts\`.
  Bigger surgery; the lint rule covers the failure mode for now.

Full repo: pnpm typecheck / lint / test all green after \`pnpm install\`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 08:08:19 +02:00
Ullrich Schäfer
c54f0361cf
Merge pull request #449 from trails-cool/chore/remove-eslint-disables
chore(ts): drop the last 2 `eslint-disable` comments in prod code
2026-05-26 07:38:34 +02:00
Ullrich Schäfer
7918ba052a
chore(ts): drop the last 2 \eslint-disable\ comments in prod code
After this, \`grep -rn 'eslint-disable' apps/ packages/\` returns 0
(excluding tests and node_modules).

**\`apps/planner/app/lib/brouter.ts\`** — \`while (true)\` in
\`readBodyWithCap\` tripped \`no-constant-condition\`. Replaced with
\`for (;;)\` which the rule explicitly allows. No behavior change.

**\`packages/gpx/src/parse.ts\`** — the sync \`parseGpx\` exported a
fallback path that did \`require(\"linkedom\")\` for non-browser sync
use, with an \`eslint-disable\` for \`no-require-imports\`. It was dead
code: \`packages/gpx/src/index.ts\` only re-exports \`parseGpxAsync\`,
and grepping the monorepo finds no caller of the sync version. Deleted
the function entirely; \`getDOMParser\` (the async helper) still serves
the async path with a clean ESM \`await import(\"linkedom\")\`.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:34:09 +02:00
Ullrich Schäfer
5bbb8bfbaa
Merge pull request #448 from trails-cool/chore/jobs-no-payload-generic
chore(ts): eliminate the remaining 4 `as any` casts in production code
2026-05-26 07:14:37 +02:00
Ullrich Schäfer
b0b58d36fb
chore(ts): eliminate the remaining 4 \as any\ casts in production code
Followup to #447. The audit ran on \`as unknown as\` first; this PR
closes out \`as any\` separately. After this, \`grep -rn ' as any\\b'
apps/ packages/\` returns 0 (excluding tests and node_modules).

## Sites fixed

**\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** —
\`komootBulkImportJob as any\`. The job had a typed payload
(\`JobDefinition<KomootBulkImportData>\`) but the worker's
\`JobDefinition[]\` array forced a contravariance cast at every site
that mixed typed and untyped jobs. Dropped the generic from
\`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only
one job (komoot-bulk-import) used the generic, so the surface is tiny.

**\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in
\`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any,
(err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types
require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\`
is structurally \`Buffer<ArrayBufferLike>\` (which includes
SharedArrayBuffer). The runtime accepts either, so narrowed the cast
to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what
we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc
callback typings; the library exports a proper \`FitParserCallback\`.

**\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler
took \`(event: any)\`. maplibre-react-native v11 generates the event
type via React Native codegen but doesn't re-export it as a public
TypeScript type. Replaced with a local structural slice of the parts
we actually read.

Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` /
\`test\` all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 07:11:02 +02:00
Ullrich Schäfer
2fc09165da
Merge pull request #447 from trails-cool/chore/tighten-typescript-coercions
chore(ts): replace 7 `as unknown as` shims with proper types
2026-05-26 01:06:11 +02:00
Ullrich Schäfer
a724f862b8
chore(ts): drop two more redundant casts
- RouteMapThumbnail.client.tsx: `{ type: 'Feature', ... } as unknown
  as GeoJsonObject` → `as GeoJsonObject`. The literal already
  satisfies the type; the unknown bridge was unnecessary.

- use-yjs.ts:102: keep the coercion (y-websocket's `.on()` signature
  doesn't include the legacy `"synced"` event even though the
  runtime still fires it), but document why with a comment so future
  readers don't try to drop it.
2026-05-26 01:02:49 +02:00
Ullrich Schäfer
985ec54023
chore(ts): replace 7 \as unknown as\ shims with proper types
Most of the existing coercions are legitimate (Drizzle raw-SQL row
shapes, linkedom DOMParser interop, fit-file-parser's loose \`any\`
callback API) — leaving those is honest. The ones cleaned up here
were shims around APIs that have real types we just hadn't wired:

- **\`window.__leafletMap\`** (4 sites: MapHelpers.tsx, SessionView.tsx ×3)
  Added \`Window\` declaration in \`apps/planner/app/types/global.d.ts\`.
  Callers now use plain \`window.__leafletMap\` typed as
  \`L.Map | undefined\`.

- **\`L.markerClusterGroup\`** (PoiPanel.tsx) — leaflet.markercluster
  augments the global \`L\` namespace at runtime but ships no types.
  Added a \`declare module \"leaflet\"\` block in the same global.d.ts
  with a minimal signature for what we actually use.

- **\`L.DomEvent.preventDefault / .stop\`** taking a Leaflet event
  rather than a native one (PlannerMap.tsx, RouteInteraction.tsx,
  NoGoAreaLayer.tsx). Leaflet events carry the native event under
  \`.originalEvent\`; using that drops the cast entirely.

- **\`_creds as unknown as OAuthCredentials\`** orphan in
  wahoo/webhook.ts — \`_creds\` was unused inside the callback (the
  void-cast was a no-op preserving the type for documentation).
  Replaced with \`async ()\`, dropped the unused import.

Net: 26 \`as unknown as\` / \`as any\` sites → 19, all remaining ones
gated by external lib interop or Drizzle raw-SQL.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:02:25 +02:00
Ullrich Schäfer
ec86f722d9
Merge pull request #444 from trails-cool/test/e2e-journal-planner-handoff
test(e2e): journal↔planner save handoff covers Phase A + Phase B
2026-05-26 01:01:27 +02:00
Ullrich Schäfer
8160393e55
Merge branch 'main' into test/e2e-journal-planner-handoff 2026-05-26 00:57:57 +02:00
Ullrich Schäfer
ce9da71f10
Merge pull request #446 from trails-cool/fix/planner-brouter-response-size
fix(planner/brouter): cap upstream response size to 10MB
2026-05-26 00:57:00 +02:00
Ullrich Schäfer
89a6c5f900
Merge branch 'main' into test/e2e-journal-planner-handoff 2026-05-26 00:56:42 +02:00
Ullrich Schäfer
e1c696d598
Merge branch 'main' into fix/planner-brouter-response-size 2026-05-26 00:53:52 +02:00
Ullrich Schäfer
206f58b1b9
test(e2e/journal-planner-save): pass waypoints in URL + mock BRouter
The first revision opened the planner session URL without waypoints,
so the in-browser Yjs doc was empty, no route was computed, and the
Save button shipped an empty GPX → journal callback returned 400 →
\"Saved!\" never appeared and the test timed out.

The real journal→planner handoff (in
\`apps/journal/app/routes/api.routes.\$id.edit-in-planner.ts\`) encodes
the planner's session-creation response (initialWaypoints / noGoAreas /
notes) as URL query params on the redirect. The test now mirrors that,
passing a 2-waypoint encoded blob via the \`?waypoints=\` param.

Also mocks BRouter via the existing \`mockBRouter(page)\` fixture so the
route compute is deterministic — \`planner-coloring.test.ts\` uses the
same pattern. Otherwise the test would race against a real BRouter
cold-start on CI.

Asserts canvas is visible before clicking Save (proxy for \"routeData
is populated\" — the same condition that gates a non-empty GPX in
\`SaveToJournalButton\`).
2026-05-26 00:53:16 +02:00
Ullrich Schäfer
6c5d4e6510
Merge pull request #445 from trails-cool/fix/planner-api-sessions-limit
fix(planner): bound /api/sessions listing (default 50, max 200)
2026-05-26 00:50:35 +02:00
Ullrich Schäfer
6f2b4450df
fix(planner/brouter): cap upstream response size to 10MB
Planner-audit #9. \`fetchSegment\` previously \`await response.json()\`
on any body BRouter returned. A misbehaving or compromised upstream
could OOM the planner process by returning gigabytes of JSON.

New \`readBodyWithCap()\`:
- Reject upfront when \`content-length\` declares over the cap.
- Stream the body and abort the reader once received bytes exceed
  the cap (handles the case where upstream lies about content-length
  or omits it).

Cap chosen at 10 MB — real per-segment GeoJSON is <100 KB; even the
longest realistic multi-day route stays well under 2 MB. Above 10 MB
is upstream bug or abuse; we'd rather error.

Applied to both \`fetchSegment\` (GeoJSON path) and \`computeSegmentGpx\`
(GPX path).

Tests: 3 cases (within cap, content-length over cap, streamed body
mid-cap abort).
2026-05-26 00:48:45 +02:00
Ullrich Schäfer
6f18ce8099
fix(planner): bound /api/sessions listing (default 50, max 200)
Addresses planner-audit #8. `listSessions()` had no LIMIT — every call
to `GET /api/sessions` returned every non-closed session and did a
full table scan ordered by last_activity. On a long-running planner
host that's both a memory cliff and a query-plan footgun.

Now: defaults to 50 rows, clamps to [1, 200], accepts `?limit=` for
pagination-light scenarios. `sessions_last_activity_idx` (added in
#438) backs the ORDER BY + LIMIT efficiently.
2026-05-26 00:46:38 +02:00
Ullrich Schäfer
be64e2df5c
test(e2e): journal↔planner save handoff covers Phase A + Phase B
New \`e2e/journal-planner-save.test.ts\` exercises the full save flow:

1. Seed a routeId + JWT via \`/api/e2e/seed\` (journal).
2. POST to \`/api/sessions\` on the planner with \`callbackUrl\` +
   \`callbackToken\` — mirrors what the journal's \`edit-in-planner\`
   action does server-to-server.
3. Open the planner session in a real browser.
4. Click \"Save to Journal\". The button POSTs sessionId+GPX to the
   planner's \`/api/save-to-journal\` action (Phase A); the action
   forwards to the journal callback with the Bearer.

Test 1 asserts:
- The journal's route ends up with geometry (round-trip works).
- The exact JWT string is **never** present in any browser-issued
  request body or \`Authorization\` header — i.e. Phase A correctly
  keeps the token server-side.

Test 2 asserts:
- A second click on Save reuses the same stored JWT, the journal's
  jti consumer rejects it, and the planner UI surfaces the error.
  This is the Phase B replay guard exercised end-to-end through the
  UI rather than just the API.

Added \`journal-planner-save\` Playwright project (no baseURL — the
test navigates both apps using absolute URLs).

Full repo: pnpm typecheck / lint / test all green (cached).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:43:56 +02:00
Ullrich Schäfer
11edcbccb3
Merge pull request #443 from trails-cool/fix/journal-jwt-jti-single-use
fix(journal): single-use JWT for route callback tokens (#2 Phase B)
2026-05-26 00:41:49 +02:00
Ullrich Schäfer
b4c64a40e7
fix(journal): single-use JWT enforcement for route callback tokens (#2 Phase B)
After Phase A (#442) moved the journal callback token off the browser,
the token was still replayable on the wire until \`exp\` (7 days). This
PR makes each token strictly single-use.

Changes:

- **\`journal.consumed_jwt_jti\` table** — \`jti TEXT PRIMARY KEY,
  consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ\`. Picked up by
  drizzle-kit push on deploy.
- **\`createRouteToken\` now sets a \`jti\` claim** (\`randomUUID()\`).
- **\`verifyRouteToken\` atomically consumes the jti** via
  \`INSERT … ON CONFLICT DO NOTHING RETURNING jti\`. Postgres
  serializes the insert, so exactly one concurrent caller wins; the
  rest see an empty result and throw \`TokenAlreadyConsumedError\`.
  Tokens without a \`jti\` claim (i.e. minted before this PR) are
  also rejected — the right call: any in-flight legacy token sitting
  in a planner session is replayable, and we'd rather fail-loud than
  silently grandfather them in.
- **\`consumed-jti-sweep\` job** — daily 03:45 UTC cron that
  \`DELETE WHERE expires_at < now()\`. Keeps the table tiny; offset
  from the other purge jobs to spread load.
- **e2e replay test** — \`integration.test.ts\` now exercises a
  same-token double-submit and asserts the second returns 401 with
  \`/consumed|already/i\`.

UX implication worth flagging: a user who clicks \"Save\" twice (or whose
network retries a failed POST) sees an error on the second attempt.
They go back to the journal for a fresh \"Edit in Planner\" link.

Full repo: pnpm typecheck / lint / test all green (177 + 31 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:38:08 +02:00
Ullrich Schäfer
c525240d0e
Merge pull request #442 from trails-cool/fix/planner-save-token-server-side
fix(planner): keep journal callback token off the client (#2 Phase A)
2026-05-26 00:32:51 +02:00
Ullrich Schäfer
0917de6080
fix(planner): keep journal callback token off the client (#2 Phase A)
The Save-to-Journal flow had the browser fetch the journal with a
\`Bearer \${callbackToken}\` header. The JWT was visible in DevTools,
exfiltratable via any XSS or browser extension, and the planner's
\`loader\` shipped it down to the client as part of the page payload.

Now:

- **New action**: \`POST /api/save-to-journal\` (\`routes/api.save-to-journal.ts\`).
  Body: \`{ sessionId, gpx }\`. The action loads \`callbackUrl\` +
  \`callbackToken\` from \`planner.sessions\` (set at /new time when the
  user came from the journal), POSTs to the journal server-to-server
  with the Bearer, and forwards the response.

- **\`SaveToJournalButton\`**: drops the \`callbackUrl\` + \`callbackToken\`
  props. Takes \`sessionId\` only and POSTs to the planner action.

- **\`session.\$id.tsx\` loader**: stops returning \`callbackUrl\` /
  \`callbackToken\` to the client. Returns a single \`hasJournalCallback\`
  boolean so the button still knows whether to render.

- **\`SessionView\`**: same prop simplification.

Trust model is unchanged: the same \`sessionId\` that grants Yjs
membership grants save authority. Knowing the URL = ability to act.
The action only adds a server-side hop so the JWT never reaches
browser JS.

Phase B (jti single-use enforcement on the journal side) follows in
a separate PR — needs a journal DB column + verifier change.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:29:24 +02:00
Ullrich Schäfer
3dcc17152b
Merge pull request #441 from trails-cool/fix/planner-ws-session-existence-check
fix(planner): require session row before accepting Yjs WebSocket upgrade
2026-05-26 00:27:35 +02:00
Ullrich Schäfer
4e2c5f7d6b
fix(planner): require session row before accepting Yjs WebSocket upgrade
Addresses planner-audit #1 (CRITICAL — WebSocket joins unauthenticated)
with the narrow fix. The upgrade handler used to immediately
\`handleUpgrade\` for any \`/sync/<id>\` path, then \`getOrLoadDoc\` *created*
a Y.Doc on demand. An attacker connecting to random sessionIds could:

  - exhaust process memory by spinning up Y.Doc objects for sessions
    that don't exist (no DB row backed them), and
  - resurrect closed/expired sessions by reconnecting (closed rows
    were still cacheable to \`docs\`).

Now: the upgrade handler calls \`getSession(sessionId)\` first
(\`SELECT \\* FROM planner.sessions WHERE id = ? AND closed = false\`).
Missing or closed → \`socket.destroy()\` before handshake. DB unreachable
also closes — fail closed; the client reconnects after backoff.

Costs one DB roundtrip per upgrade. Upgrades are rare vs message
volume, so the overhead is negligible.

Note: we are NOT adding a join token. The planner is anonymous-by-design
(see CLAUDE.md \"sessions are anonymous and ephemeral\") — knowing the
URL still equals membership. The check here only guards against
attacker-supplied sessionIds with no backing row.

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:24:16 +02:00
Ullrich Schäfer
643266e619
Merge pull request #440 from trails-cool/fix/planner-yjs-doc-size-cap
fix(planner): cap per-WS-frame + per-session Yjs doc size
2026-05-26 00:16:40 +02:00
Ullrich Schäfer
2b0717fe21
fix(planner): cap per-WS-frame + per-session Yjs doc size
Addresses planner audit #5 — a connected client could feed unlimited
waypoints / no-go / notes into the Yjs doc, growing the in-memory map
and the persisted \`sessions.yjs_state\` blob until OOM or DB blowout.

Two guards:

1. **Per-frame**: \`MAX_MESSAGE_BYTES = 256 KB\`. Anything bigger arrives
   from a buggy or hostile client — a single waypoint add is hundreds
   of bytes. Oversized frame → \`ws.close(1008)\` and drop the message.

2. **Per-doc**: \`MAX_DOC_BYTES = 5 MB\`. After a sync-message apply
   succeeds, recompute \`Y.encodeStateAsUpdate(doc).byteLength\`. If it
   crossed the cap, mark the session \"quarantined\": close every
   connected client (\`1008 policy violation\`), and short-circuit
   subsequent handleMessage calls so no further bytes can be applied
   and the debounced save can't write an oversized blob to Postgres.

The 5 MB limit is generous — a typical multi-day route's serialized
state is well under 100 KB. The cap exists to make abuse expensive,
not to constrain real use.

Exports \`MAX_MESSAGE_BYTES\`, \`MAX_DOC_BYTES\`, and \`docByteSize\`
for testing. Tests cover:
- constants are positive and ordered
- docByteSize is 0 for unknown sessions
- a realistic 500-waypoint route stays well under the cap
- ~6MB of garbage in a Y.Text trips the cap (smoke test for the guard)

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:13:01 +02:00
Ullrich Schäfer
078e68feaf
Merge pull request #439 from trails-cool/fix/planner-url-validation
fix(planner): validate callback/returnUrl + cap session URL-param payloads
2026-05-26 00:09:39 +02:00
Ullrich Schäfer
51e6b8a0d7
fix(planner): validate callback/returnUrl + cap session URL-param payloads
Addresses planner audit #3 (SSRF via callbackUrl) and #7 (URL-param
size). Two attack surfaces hardened:

1. /new loader — \`callback\`, \`token\`, \`returnUrl\`, \`gpx\` query
   params now validated:
   - callbackUrl: must be a valid absolute http(s) URL ≤ 2048 chars.
     If \`PLANNER_CALLBACK_ALLOWED_HOSTS\` is set (comma-separated),
     the host must match — defense-in-depth SSRF guard for self-
     hosted instances. Unset = no allowlist (dev / open self-host).
   - token: max 2048 chars.
   - returnUrl: must be a same-origin path or absolute http(s) URL
     ≤ 2048 chars. Rejects \`javascript:\`, \`data:\`, and
     protocol-relative \`//host\` (which would resolve to a remote
     origin on HTTPS pages).
   - gpx: ≤ 2 MB encoded.
   Invalid input throws 400 from the loader.

2. /session/:id default-export component — \`waypoints\`, \`noGoAreas\`,
   \`notes\`, \`returnUrl\` URL params now bounded before
   \`JSON.parse\` / use:
   - waypoints / noGoAreas: ≤ 50KB each; over-cap returns undefined
     (component starts with empty initial state, same as malformed).
   - notes: ≤ 10KB.
   - returnUrl: ≤ 2KB + same scheme rules as #1.

Pulled the URL validation into \`lib/url-validation.server.ts\` so
both routes (and any future caller) share the same rules.

Tests: \`url-validation.server.test.ts\` (14 cases — schemes,
allowlist, length caps, protocol-relative guards, env parsing).

Full repo: pnpm typecheck / lint / test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:05:47 +02:00
Ullrich Schäfer
e35a8e27c8
Merge pull request #438 from trails-cool/fix/planner-sessions-lastactivity-idx
fix(planner): add index on sessions.last_activity for the expire job
2026-05-26 00:03:04 +02:00
Ullrich Schäfer
d2b28a1164
fix(planner): add index on sessions.last_activity for the expire job
The hourly `expireSessions()` cron runs `DELETE FROM planner.sessions
WHERE last_activity < cutoff`. Without an index on `last_activity`,
that's a full table scan growing linearly with the total sessions
ever created (planner sessions are never user-deleted; expiry is the
only churn path).

`drizzle-kit push` picks this up on next deploy.
2026-05-25 23:59:10 +02:00
Ullrich Schäfer
d07207464e
Merge pull request #437 from trails-cool/fix/planner-brouter-timeout
fix(planner/brouter): add 30s timeout to outbound fetch
2026-05-25 23:57:04 +02:00
Ullrich Schäfer
d70d6ee8a8
fix(planner/brouter): add 30s timeout to outbound fetch
Mirrors journal PR #5. `fetchSegment()` and `computeSegmentGpx()` in
lib/brouter.ts called `fetch()` with no AbortSignal — a hung or slow
BRouter would stall the request handler indefinitely.

New `lib/http.server.ts::fetchWithTimeout()` (same as the journal's
helper) wraps fetch with `AbortSignal.timeout(30_000)`, composable
with a caller-supplied signal via `AbortSignal.any()`.

Tests: lib/http.server.test.ts (2 cases — timeout abort, happy path).
2026-05-25 23:53:19 +02:00
Ullrich Schäfer
e647a29633
Merge pull request #436 from trails-cool/fix/e2e-planner-coloring-flake
test(e2e/planner-coloring): bump canvas timeout 10s→20s to absorb cold-CI lazy load
2026-05-25 23:48:38 +02:00
Ullrich Schäfer
d38b808a11
test(e2e/planner-coloring): bump canvas timeout 10s→20s
The three planner-coloring tests wait for the elevation chart's
\`<canvas>\` after seeing the Yjs \"Connected\" text. \`ElevationChart\`
returns \`null\` until \`points.length >= 2\` — i.e. until the BRouter
response (even mocked) has flowed through the Yjs round-trip and
updated \`yjs.routeData\`. The chart module is also lazy-imported
(\`Suspense\`) — cold CI runs need to fetch the chunk before mounting.

Saw the failure on PR #424's CI run: canvas \"element(s) not found\"
after the 10s window, both initial run and retry. Other planner
suites that wait on \`.leaflet-container\` (which renders
unconditionally and isn't behind a lazy boundary) don't see this.

Bumped \`CANVAS_TIMEOUT\` to 20s. Not masking a real regression — the
lazy chunk + Yjs apply is a real serial cost that the test budget
didn't reflect.
2026-05-25 23:44:53 +02:00
Ullrich Schäfer
f02edd346e
Merge pull request #435 from trails-cool/fix/ci-run-integration-tests
ci: wire integration tests into the CI E2E job
2026-05-25 23:31:48 +02:00
Ullrich Schäfer
ded70a5404
ci: wire integration tests into the CI E2E job
The five \`*.integration.test.ts\` files in apps/journal (explore, follow,
demo-bot, notifications, notifications-fanout — 31 tests total) were
gated behind \`EXPLORE_INTEGRATION=1\` / \`FOLLOW_INTEGRATION=1\` /
\`DEMO_BOT_INTEGRATION=1\` / \`NOTIFICATIONS_INTEGRATION=1\`. The unit-test
job doesn't have Postgres, so they correctly skipped there — but no
CI job set the env vars, so they were effectively dead code.

Added a step in the E2E job (which already has Postgres + schema
pushed) that flips all four gates and runs the integration files
serially (\`--no-file-parallelism\` — they share the schema and trip FK
constraints if run in parallel; ~2.5s sequential anyway).

Wiring them up surfaced two real issues, both fixed here:

1. **\`createRoute\` silently dropped \`input.visibility\`** — every
   caller passing \`visibility: \"public\"\` (including the demo-bot)
   was getting the column's \`private\` default. Spread now mirrors
   \`createActivity\`'s pattern. Demo-bot routes have actually been
   private in production all this time — they were rendering on the
   home feed only via the \`activity_published\` fan-out from the
   *activity*, not as visible *routes*.

2. **The demo-bot test's fetch stub was incomplete** — it stubbed
   \`.text()\` but the planner-session preflight calls \`.json()\`. The
   stub now branches on URL: \`/api/sessions\` returns
   \`{ sessionId: 'test-session' }\` JSON, the BRouter call returns the
   stub GPX text.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green
(177 unit-test pass + 31 integration pass = 208 total, no skips).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:27:44 +02:00
Ullrich Schäfer
1263372eef
Merge pull request #434 from trails-cool/fix/sentry-dsn-no-hardcoded-fallback
fix(sentry): remove hardcoded DSN fallbacks; supply via env in CI
2026-05-25 23:12:55 +02:00
Ullrich Schäfer
edb618fe40
fix(sentry): remove hardcoded DSN fallbacks; supply via env in CI
Self-hosted instances no longer inherit the trails.cool flagship Sentry
DSNs. Code paths now read DSN strictly from env — unset = no Sentry
init, no events sent.

Flagship continues to report unchanged: cd-apps.yml and cd-staging.yml
inject the public DSNs as workflow env vars, which feed into:
- runtime via \`infrastructure/app.env\` / \`staging.env\`
  (\`SENTRY_DSN_JOURNAL\` / \`SENTRY_DSN_PLANNER\` → compose env per service)
- build time via docker build-arg \`VITE_SENTRY_DSN\` (journal only;
  planner has no client Sentry init).

Sentry DSNs are public-by-design (transmitted unencrypted from the
client JS bundle), so embedding them as plaintext workflow env vars
is no worse than the runtime exposure. Forks should replace these with
their own DSNs or remove the workflow env lines to ship Sentry-free.

Files changed:
- apps/journal/server.ts: \`process.env.SENTRY_DSN\` only, no fallback
- apps/planner/server.ts: same
- apps/journal/app/lib/sentry.client.ts: \`import.meta.env.VITE_SENTRY_DSN\`
  only; falsy = skip init
- apps/journal/Dockerfile: new \`ARG VITE_SENTRY_DSN\` baked into build
- infrastructure/docker-compose.yml: pass \`SENTRY_DSN\` per service
- infrastructure/docker-compose.staging.yml: same
- .github/workflows/cd-apps.yml: workflow env + build-arg + app.env echo
- .github/workflows/cd-staging.yml: same

Full repo: pnpm typecheck, pnpm lint, pnpm test, journal build all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:07:07 +02:00
Ullrich Schäfer
f239b4adee
Merge pull request #433 from trails-cool/fix/infra-compose-secret-defaults
fix(infra): refuse compose up when production secrets are missing
2026-05-25 23:03:06 +02:00
Ullrich Schäfer
e2bf3ddb94
fix(infra): refuse compose up when production secrets are missing
infrastructure/docker-compose.yml had \`\${VAR:-default}\` fallbacks for
JWT_SECRET, SESSION_SECRET, and POSTGRES_PASSWORD that silently
substituted known-weak values (\`change-me-in-production\`, \`trails\`)
when the env wasn't set. A misconfigured prod deploy would happily come
up with these defaults — JWT/session forgery + a guessable Postgres
password.

Switched the four critical substitutions to \`\${VAR:?message}\` so
\`docker compose up\` fails loud instead. Mirrors the existing pattern
already used for BROUTER_URL/BROUTER_AUTH_TOKEN and (in staging) for
JWT_SECRET/SESSION_SECRET.

Sites fixed:
- journal:   DATABASE_URL (POSTGRES_PASSWORD), JWT_SECRET, SESSION_SECRET
- planner:   DATABASE_URL (POSTGRES_PASSWORD)
- postgres:  POSTGRES_PASSWORD (container itself)
- exporter:  DATA_SOURCE_NAME (POSTGRES_PASSWORD)
- staging:   both DATABASE_URLs

Production already provides all three via SOPS-encrypted
secrets.app.env, so this is a defense-in-depth change with no behavior
change on a properly-configured deploy.

Updated infrastructure/.env.example to make SESSION_SECRET explicit
(was missing) and call out the trio as required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:59:09 +02:00
Ullrich Schäfer
c85c757066
Merge pull request #432 from trails-cool/fix/planner-db-url-and-health-pool
fix(planner): fail-loud DATABASE_URL + dedicated /health pool
2026-05-25 22:52:47 +02:00
Ullrich Schäfer
4f902accd7
fix(planner): fail-loud DATABASE_URL + dedicated /health pool
Mirrors #422 (fail-loud DB URL) and #430 (health-check pool reuse) for
the planner app, which still had:

- two \`process.env.DATABASE_URL ?? \"postgres://trails:trails@localhost:5432/trails\"\`
  call sites that would silently boot a misconfigured prod against
  localhost
- a /health handler that opened a fresh postgres client + connection on
  every probe (no pool, per-call TCP/TLS handshake)

Both now route through @trails-cool/db's \`getDatabaseUrl()\` (refuses to
start in production if unset / matches the dev default; E2E=true is the
opt-out) and a module-level singleton client (max: 2, idle_timeout: 30).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:49:16 +02:00
Ullrich Schäfer
ca2388e41c
Merge pull request #431 from trails-cool/fix/planner-request-id-tracing
feat(planner): per-request requestId propagated through logs
2026-05-25 22:47:33 +02:00
Ullrich Schäfer
4ba98fa8f2
feat(planner): per-request requestId propagated through logs
Mirrors PR #429 for the planner app. Each HTTP request now gets a
requestId (inbound X-Request-Id honored, otherwise a fresh UUID),
echoed on the response, and propagated to every downstream log call via
AsyncLocalStorage + pino's \`mixin\`.

Tests: planner logger.server.test.ts (2 cases, same shape as the
journal version).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:43:58 +02:00
Ullrich Schäfer
59687162b8
Merge pull request #423 from trails-cool/deps/expo-sdk-56-packages
Bump Expo SDK 56 packages
2026-05-25 22:38:31 +02:00
Ullrich Schäfer
fb5bdff579
Upgrade TypeScript to 6.0.3 across the entire monorepo
Replaces the i18next peer dep override workaround with a proper single
TypeScript version in the workspace catalog. All 15 packages typecheck
cleanly with TypeScript 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:34:23 +02:00
Ullrich Schäfer
7cf554f85a
Fix i18n singleton split caused by TypeScript peer dep duplication
Mobile's TypeScript 6 devDep created a second peer-resolution variant of
i18next (i18next(typescript@6)) alongside the web apps' (i18next(typescript@5)).
Under pnpm's hoisted nodeLinker the server and client ended up with different
instances of the i18n singleton, so SSR rendered raw keys and hydration
mismatched.

Pin i18next and react-i18next TypeScript peer deps to 5.9.3 in root overrides
so the entire workspace resolves a single i18next instance regardless of which
app's TypeScript version is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:05:23 +02:00
Ullrich Schäfer
c3509b064a
ci: trigger CI after merging Copilot fix 2026-05-25 21:40:30 +02:00
copilot-swe-agent[bot]
e2460374de
fix(i18n): normalize html language tags in client detection
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/7546ba18-5628-4f50-bc8b-104fa7ba6afe

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-05-25 13:24:11 +00:00
Ullrich Schäfer
aa3afeeaec
Merge branch 'main' into deps/expo-sdk-56-packages 2026-05-24 21:11:19 +02:00
copilot-swe-agent[bot]
dafa5634e0
chore: deduplicate pnpm lockfile
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/05d47928-38a7-43b0-a06b-034302981e77

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-05-24 15:49:35 +00:00
Ullrich Schäfer
d014edb64c
Merge pull request #430 from trails-cool/fix/journal-health-pool
fix(journal): reuse a dedicated pool for /api/health instead of per-call connect
2026-05-24 12:41:11 +02:00
Ullrich Schäfer
8675c1f7c3
fix(journal): reuse a dedicated pool for /api/health instead of per-call connect
The previous handler opened a fresh postgres client (max: 1) on every
call to /api/health and tore it down in the finally block. Under the
prod monitoring cadence (probes every few seconds), that's a fresh
TCP + TLS + auth handshake on every probe, plus connection-table
churn on the Postgres side — fine for the trickle of curl-ish manual
checks, slow-bleed under blackbox monitoring.

Now we cache a module-level singleton postgres client dedicated to
/api/health (max: 2, idle_timeout: 30) and reuse it across calls.
Separate from the app's main DB pool (via @trails-cool/db's createDb)
on purpose — so a starvation event on the main pool doesn't fail the
liveness check and trigger a restart loop.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:37:09 +02:00
Ullrich Schäfer
dfdb8b6daf
Merge pull request #429 from trails-cool/fix/journal-request-id-tracing
feat(journal): per-request requestId propagated through logs
2026-05-24 12:35:13 +02:00
Ullrich Schäfer
f070914362
feat(journal): per-request requestId propagated through logs
Every HTTP request now gets a requestId (inbound X-Request-Id header is
honored, otherwise a fresh UUID is minted) and the value is echoed on
the response. The server wraps the request in
\`requestContext.run({ requestId }, ...)\` — an AsyncLocalStorage scope —
so pino's \`mixin\` callback can read it on every log call without the
caller threading it through.

Net effect: \`logger.info({ ... }, \"db error\")\` from a loader, action,
or downstream lib now lands in JSON with a \`requestId\` field, making
cross-handler debugging trivial (\`grep requestId=abc-123\` returns the
full request trace).

Out of scope here:
- Planner gets the same treatment (separate, smaller PR after this lands).
- BRouter / Fedify outbound calls don't propagate the requestId yet —
  those are HTTP boundaries where we'd add it as a header, but the
  audit value was the in-process trace.

Tests:
- logger.server.test.ts (2 cases — als-bound info tags requestId; no
  context = no tag).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:30 +02:00
Ullrich Schäfer
3d34e215b9
Merge pull request #428 from trails-cool/fix/sentry-dsn-env-driven
fix(sentry): make DSN env-driven so self-hosters can opt out
2026-05-24 12:28:58 +02:00
Ullrich Schäfer
f05165c594
fix(sentry): make DSN env-driven so self-hosters can opt out
The Sentry DSNs were hardcoded in journal/server.ts, planner/server.ts,
and journal/app/lib/sentry.client.ts. Self-hosted instances inheriting
the trails.cool flagship DSN would silently ship their errors to our
Sentry account.

Now each init site reads its DSN from env:

- journal/server.ts: SENTRY_DSN (server runtime env)
- planner/server.ts: SENTRY_DSN (server runtime env)
- journal/app/lib/sentry.client.ts: VITE_SENTRY_DSN (build-time bake)

The flagship DSN is kept as the fallback so the production deploy keeps
reporting without an infra change — but self-hosters can:
- set SENTRY_DSN=\"\" / VITE_SENTRY_DSN=\"\" to ship their own builds
  without Sentry, or
- set SENTRY_DISABLED=true to skip init entirely at runtime, or
- set SENTRY_DSN=/VITE_SENTRY_DSN= to their own DSN.

Follow-up: a future PR can remove the hardcoded fallbacks once
infrastructure/docker-compose.yml + the cd-apps.yml workflow are wired
to pass SENTRY_DSN explicitly. That requires SOPS edits + workflow
changes I want isolated from this purely-code change.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:24:54 +02:00
Ullrich Schäfer
9614b68a13
Merge pull request #427 from trails-cool/fix/journal-magic-token-toctou
fix(journal/auth): atomic magic-token consume to close TOCTOU
2026-05-24 12:21:25 +02:00
Ullrich Schäfer
f22bec5a13
fix(journal/auth): atomic magic-token consume to close TOCTOU
verifyLoginCode, verifyMagicToken, and verifyEmailChange previously did
SELECT WHERE used_at IS NULL → UPDATE … SET used_at = now. Two concurrent
verifications could both pass the SELECT and both succeed, accepting the
same single-use token twice.

Collapsed each to a single UPDATE … WHERE … RETURNING * statement.
Postgres serializes row-level locks within an UPDATE, so exactly one
concurrent caller observes a returned row; the rest see an empty array
and get \"Invalid or expired\". The token is also marked used as part of
the same statement — no second write needed.

verifyEmailChange's tertiary email-availability check now runs *after*
the consume; we keep the original semantics where the token is burned
on a clash (the previous code explicitly did the same with a separate
UPDATE).

No behavior change on the happy path. Closes a credential-reuse
window that mattered most for the 6-digit login codes (small search
space, more likely to race).

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:17:42 +02:00
Ullrich Schäfer
6113b66846
Merge pull request #426 from trails-cool/fix/journal-wahoo-importone-paginate
fix(journal/wahoo): paginate importOne instead of giving up after page 1
2026-05-24 12:15:19 +02:00
Ullrich Schäfer
c43737526e
fix(journal/wahoo): paginate importOne instead of giving up after page 1
The previous \`importOne\` only fetched page 1 of /v1/workouts and errored
with \"not found on page 1\" if the workout wasn't there — silently
breaking import for any workout older than roughly the most recent 30
entries (per_page default). Webhook-driven imports happen to land on
page 1 by definition, so this only bit on user-initiated catch-up
imports of older workouts.

Now we paginate forward, using the \`total / per_page\` returned by page 1
to compute a stop condition, with a \`MAX_PAGES=100\` ceiling so a
misbehaving API can't loop us. We also stop early on an empty page.

Tests:
- new pagination case (workout on page 2, expect 2 fetch calls)
- new \"not found on any page\" case

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:11:33 +02:00
Ullrich Schäfer
8729ad03c7
Merge pull request #425 from trails-cool/fix/journal-dynamic-import-warnings
fix(journal): remove ineffective dynamic imports
2026-05-24 12:08:45 +02:00
Ullrich Schäfer
9c6407423a
fix(journal): remove ineffective dynamic imports
Rollup was warning on 5 modules that were both dynamically and statically
imported. With static importers in the same chunk, the dynamic forms
buy no chunking benefit — they were leftovers from earlier
cycle-avoidance workarounds that no longer apply.

Converted to static:
- @trails-cool/gpx (routes.\$id.server.ts, sync.import.\$provider.server.ts)
- logger.server (boss.server.ts — comment claimed test cycles, but tests pass)
- boss.server (activities.server.ts at two sites)
- connected-services/manager (komoot/importer.ts, wahoo/importer.ts)
- notifications.server (root.tsx)

Kept dynamic: fit-file-parser in sync.import.\$provider.server.ts — it's
heavy and only the FIT ingestion path needs it, no other static
importers exist, so the dynamic actually does chunk-split it.

Build is now warning-free.

Full repo: pnpm typecheck, pnpm lint, pnpm test, pnpm build all green.
192 tests passed (up from 181 — rate-limit test from #424 + others).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:08 +02:00
Ullrich Schäfer
9d99a8a3c1
Merge branch 'main' into deps/expo-sdk-56-packages 2026-05-24 12:00:36 +02:00
Ullrich Schäfer
c6d135945a
Merge pull request #424 from trails-cool/fix/journal-rate-limit-auth
fix(journal): rate-limit auth endpoints
2026-05-24 12:00:09 +02:00
Ullrich Schäfer
5fef45fb68
fix: bypass rate limiter when E2E=true
E2E suite drives registrations from one IP at parallel volume;
production limits trip and flake tests. Same opt-out pattern as the
fail-loud secret/DB-URL guards.
2026-05-24 11:57:01 +02:00
Ullrich Schäfer
81c40f0c6d
Add Jest mock for expo-crypto
expo-crypto@56 introduced a native AES class that throws in Jest's Node.js
environment on import. Mock the functions actually used (getRandomBytes,
digestStringAsync) so the api-client test suite can run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:54:50 +02:00
Ullrich Schäfer
532b22c5c0
Complete Expo SDK 56 upgrade
- Upgrade expo, expo-router, expo-dev-client, expo-crypto, expo-localization,
  expo-location, expo-navigation-bar, expo-notifications, expo-secure-store,
  expo-status-bar, expo-system-ui, expo-web-browser to SDK 56 versions
- Upgrade react-native 0.83.4 → 0.85.3
- Upgrade TypeScript 5.9 → 6.0.3 (required by SDK 56)
- Add react-native-worklets (required peer dep for react-native-reanimated)
- Remove expo-dev-menu as a direct dependency (transitive, managed by Expo)
- Move splash config from top-level ExpoConfig to expo-splash-screen plugin
  (ExpoConfig.splash was removed in SDK 56 types)
- Add expo-localization and expo-splash-screen to plugins array
- Fix metro.config.js watchFolders to merge with Expo defaults instead of
  overwriting them (fixes expo-doctor Metro config check)
- Add types: ["jest"] to tsconfig.json (required for jest globals in TS 6)
- Exclude @sentry/react-native from Expo version validation (bundledNativeModules
  has a stale entry; Sentry 8.x officially supports Expo 51+ and RN 0.73+)

expo-doctor: 18/18 checks passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:52:11 +02:00
Ullrich Schäfer
6afd996e19
fix(journal): rate-limit auth endpoints
Adds per-process in-memory fixed-window rate limiting (lib/rate-limit.server.ts)
and applies it across /api/auth/login and /api/auth/register:

- All login steps: 30 attempts per IP per minute (defense against
  step-fanout flooding).
- finish-passkey: 10 per IP per minute (assertions don't usefully retry
  faster).
- magic-link generation: 3 per email per 5 min + 10 per IP per 5 min
  (defeats inbox-spam-the-victim + cross-email IP fanout).
- verify-code: 10 per email per 15 min — makes 6-digit code brute force
  (10^6 search) infeasible before code expiry.
- /api/auth/register: 10 per IP per hour (legitimate signup completes
  in 2-3 requests; sustained churn from one IP is account spam).

Single-instance is fine for the flagship's current topology (one journal
container). When we horizontally scale we revisit with a Postgres- or
Redis-backed store. Buckets self-clean on next read and a 5-minute
background sweep drops stale entries.

Client IP: honors X-Forwarded-For first entry (Caddy in front of the
journal sets it), falls back to a stable "unknown" bucket so the
limiter still bites when the header is missing.

Tests: rate-limit.server.test.ts (8 cases — exhaustion, independent
scopes/keys, remaining countdown, reset window, XFF parsing, fallback,
trimming). Existing auth tests still pass; limits sized to not trip
during the ~7 test cases that all share the "unknown" IP bucket.

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:51:43 +02:00
Ullrich Schäfer
84babc3ec7
Merge pull request #422 from trails-cool/fix/journal-fail-loud-secrets
fix(journal): fail loud in production when secrets are unset
2026-05-24 11:48:46 +02:00
Ullrich Schäfer
ebedfa257b
fix: add E2E opt-out for fail-loud secret/DB-URL guards
Playwright runs the server via `react-router serve` with
NODE_ENV=production but against a local dev Postgres and local cookie
secrets. The guards added in 5a7bb76 refused to start under that
configuration. `E2E=true` (already set by the CI E2E job) is now the
explicit opt-out: in real production this env var is never set, so the
guard still bites.
2026-05-24 11:45:09 +02:00
Ullrich Schäfer
9d48d26a6e
ci: supply throwaway JWT/SESSION secrets for E2E (NODE_ENV=production)
pnpm test:e2e runs via react-router serve which boots with
NODE_ENV=production; the new requireSecret() guard refuses to start
without explicit values. CI now supplies CI-only throwaway secrets so
the guard still bites in real prod deploys without breaking the test
job.
2026-05-24 11:40:24 +02:00
Ullrich Schäfer
afd7bf09f8
Bump Expo SDK 56 packages (combined dependabot PRs)
Closes #408, #409, #410, #411, #412, #413, #414, #415, #416

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:39:47 +02:00
Ullrich Schäfer
5a7bb76ff1
fix(journal): fail loud in production when secrets are unset
Adds `requireSecret(name, devFallback)` in lib/config.server.ts and
`getDatabaseUrl()` in @trails-cool/db. Both:
- return the env var when set,
- fall back to the dev default in non-production,
- throw at boot in production if the env var is missing OR matches the
  known dev fallback (which would otherwise silently ship a public
  secret / point at localhost).

Applied to:
- JWT_SECRET (lib/jwt.server.ts) — was `?? "dev-jwt-secret-change-in-production"`
- SESSION_SECRET (lib/auth/session.server.ts) — was `?? "dev-secret-change-in-production"`
- DATABASE_URL (server.ts health + boss; packages/db migrate-data) — was
  `?? "postgres://trails:trails@localhost:5432/trails"`

Why: these strings are in the repo and known to attackers. A
misconfigured prod deploy that forgot to set them would either run with
guessable signing keys (full session/JWT forgery) or connect to a
non-existent localhost DB. Better to refuse to start than to silently
operate insecurely.

Tests:
- packages/db/src/get-database-url.test.ts (5 cases)
- lib/config.server.test.ts gains `requireSecret` cases (4 new)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:37:20 +02:00
Ullrich Schäfer
742065c319
Merge pull request #420 from trails-cool/spec-drift/high-severity
Fix high-severity spec drift (10 specs)
2026-05-24 11:31:13 +02:00
Ullrich Schäfer
dd0098e35c
Merge branch 'main' into spec-drift/high-severity 2026-05-24 11:27:33 +02:00
Ullrich Schäfer
0448a58e19
Fix remaining OpenSpec validation failures (medium-severity specs)
- komoot-import: add SHALL to Credential storage requirement body
- local-dev-environment: add SHALL to Mobile app dev requirement body
- multi-day-routes: demote ### Note heading to plain paragraph

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:26:32 +02:00
Ullrich Schäfer
6186ffa062
Merge pull request #407 from trails-cool/dependabot/npm_and_yarn/production-05de8b6350
Bump the production group with 14 updates
2026-05-24 11:26:03 +02:00
Ullrich Schäfer
bbb729ffdd
Fix OpenSpec validation failures on high-severity specs
- road-type-coloring: add proper ## Purpose/## Requirements structure (redirect file)
- planner-journal-handoff: add inline #### Scenario: blocks to each requirement; add SHALL to JWT token requirement
- osm-tile-overlays: add SHALL keyword to profile-aware requirement body
- shared-packages: add #### Scenario: blocks to ui, api, db, jobs, sentry-config requirements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:20:14 +02:00
Ullrich Schäfer
2b48e2a8e1
Merge pull request #421 from trails-cool/spec-drift/medium-severity
Fix medium-severity spec drift (17 specs)
2026-05-24 11:12:02 +02:00
Ullrich Schäfer
0cf87b72ab
Fix medium-severity spec drift across 17 specs
- authentication-methods: document completeAuth mode param ("redirect"|"json");
  clarify add-passkey nudge (no dismiss mechanism, disappears on passkey add)
- journal-auth: session maxAge is 30 days; terms allow-list uses /legal/ prefix
  matching (broader than fixed list of paths)
- session-notes: mark awareness isolation and UndoManager isolation as not yet
  implemented (shared instances in current code)
- activity-feed: add fan-out scenario for visibility change to public
- explore: note that ?perPage is not yet implemented (hardcoded page size)
- multi-day-routes: add per-day GPX track split scenario (splitByDays option);
  document overnight vs isDayBreak naming gap
- osm-poi-overlays: debounce is 800ms + 2000ms min interval (not 500ms);
  retry is not automatic (fires on next viewport change)
- brouter-integration: rate limit corrected to 300/hour; add segment-cache
  requirement (client caches per-pair segments)
- wahoo-route-push: OAuth state shape uses camelCase (returnTo, pushAfter
  object) not snake_case with push_after boolean
- komoot-import: document noop adapter / ConnectedServiceManager bypass;
  note four Komoot-specific routes that bypass the generic OAuth framework
- background-jobs: exponential backoff not wired (retryLimit only); add SIGINT
- connected-services: add revoked status; name ConnectionNotActiveError
- infrastructure: add INTEGRATION_SECRET and SENTRY_DSN to env var lists;
  split secret decryption scenario by workflow (cd-apps vs cd-infra)
- secret-management: correct CD decryption — cd-apps only decrypts app.env;
  cd-infra decrypts both
- transactional-emails: welcome email is async (pg-boss job); magic-link email
  includes 6-digit numeric code
- journal-route-detail: websites are https: links (not mailto:); opening_hours
  is also displayed
- local-dev-environment: add mobile app (Expo) dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:11:31 +02:00
Ullrich Schäfer
ebf77b9b17
Merge pull request #419 from trails-cool/fix/journal-audit-server-split-all
fix(journal): extract loaders/actions for the remaining 21 mixed routes
2026-05-24 11:09:09 +02:00
Ullrich Schäfer
47eb2615ec
Fix high-severity spec drift across 10 specs
- rate-limiting: correct BRouter limit to 300/hour (was 60); add Overpass
  rate-limit requirement (120/min per IP)
- security-hardening: BROUTER_AUTH_TOKEN lives in secrets.app.env, not infra.env
- account-management: email-change verification does not re-auth; existing
  session stays valid
- planner-journal-handoff: full rewrite — documents the actual JWT callback
  architecture (edit-in-planner → POST /api/sessions → callback endpoint),
  token claims, notes round-trip via GPX <metadata><desc>, session lifecycle
- route-drag-reshape: rewrite to describe permanent segment midpoint handles
  (not proximity hover ghost marker); click-to-insert + waypoint drag model
- route-splitting: rewrite to match midpoint handle model; notes geometric
  midpoint placement (not cursor-snapped)
- road-type-coloring: redirect to route-coloring (all requirements already
  covered there)
- osm-tile-overlays: mark profile-aware auto-enable as not yet implemented
  (profileOverlayDefaults exported but not wired)
- osm-poi-overlays: zoom threshold is 10 not 12; user override persistence
  marked as not yet implemented
- shared-packages: add all 7 missing packages (map-core, fit, api, db, jobs,
  sentry-config, correct map description); document map vs map-core boundary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:07:03 +02:00
Ullrich Schäfer
df562742e1
fix(journal): extract loaders/actions for the remaining 21 mixed routes
Completes the .server.ts split started in #418. Every route that mixes
a default-export component with a server-only loader/action now has a
sibling <route>.server.ts holding the data-fetching helpers; the route
.tsx is a thin delegator.

Routes converted (21):
  activities._index, activities.$id, activities.new, auth.accept-terms,
  auth.verify, explore, feed, notifications, routes._index, routes.$id,
  routes.$id.edit, routes.new, settings, settings.account,
  settings.connections.komoot, settings.profile, settings.security,
  sync.import.$provider, sync.import.komoot, users.$username.followers,
  users.$username.following

Pattern (same as home.tsx / users.$username.tsx / settings.connections.tsx):
- loader → `return data(await loadX(request, params?))`
- action → `return await xAction(request, params?)`
- All `getDb` / Drizzle schema / `~/lib/*.server` imports move to the
  .server.ts sibling.
- `throw redirect(...)` and `throw data(...)` propagate through the
  delegator unchanged.

No behavior changes — pure module-graph cleanup. Component modules no
longer transitively import the DB client; Vite's tree-shake of
server-only code is now backed by an explicit, file-local contract.

Verified:
- pnpm typecheck — green
- pnpm lint — green
- pnpm test — 181 passed, 31 integration-gated skipped
- pnpm --filter @trails-cool/journal build — succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:05:40 +02:00
Ullrich Schäfer
a57637868b
Merge pull request #418 from trails-cool/fix/journal-audit-7-8-helpers
fix(journal): centralize auth helpers + extract .server.ts siblings
2026-05-24 10:52:13 +02:00
Ullrich Schäfer
951d2a507a
Merge branch 'main' into fix/journal-audit-7-8-helpers 2026-05-24 10:48:53 +02:00
Ullrich Schäfer
588447efd3
Merge pull request #417 from trails-cool/roadmap
Add roadmap and fix CLAUDE.md drift
2026-05-24 10:47:25 +02:00
Ullrich Schäfer
8eba5b2d9e
fix(journal): centralize session-auth helpers + extract .server.ts siblings
Follow-up to PR #406 — addresses the two items deferred from the audit:

#7 — Centralize auth helpers
- New `requireSessionUser(request)` in lib/auth/session.server.ts that
  returns the user or throws a redirect to /auth/login.
- New `requireSessionUserJson(request)` companion that throws a 401 JSON
  response (for fetcher/JSON endpoints).
- Replace the repeated
    const user = await getSessionUser(request);
    if (!user) return redirect("/auth/login");
  pattern across 18 route loaders/actions. Removes the duplicated guard
  preamble and gives a single chokepoint to evolve later (e.g., for
  terms-version gating).

#8 — Extract heavy loaders into .server.ts siblings
- routes/home.tsx → home.server.ts (DB count query + listActivities +
  listRecentPublicActivities)
- routes/users.$username.tsx → users.$username.server.ts (user lookup +
  follow state + counts + listPublicRoutes/Activities + persona check)
- routes/settings.connections.tsx → settings.connections.server.ts
  (connected_services join + manifest merge)

Each route file shrinks to a thin delegator: `loader` calls
`loadXxx(request)`. The component module no longer transitively pulls
`getDb` and Drizzle schema into its import graph — Vite's tree-shake
already strips server-only code from the client bundle, but the
explicit `.server.ts` suffix makes that contract local and auditable.

Other 17 routes that mix loader/action with components are left as-is
for now: they're each small enough that the split adds churn without
buying much clarity. The pattern is documented by the three examples;
the rest can convert opportunistically when they grow.

Tests:
- lib/auth/session.server.test.ts (4 cases — redirect for missing
  cookie, redirect for ghost userId, success path, JSON 401 variant)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green
(181 passed | 31 integration-gated skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:44:33 +02:00
Ullrich Schäfer
12f6e6be51
Update self-host-overpass README with accurate OVERPASS_URLS env var
The proxy now supports OVERPASS_URLS (comma-separated, round-robin fallback)
with OVERPASS_URL as a single-entry backward-compat alias; update the switch
path note to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:43:03 +02:00
Ullrich Schäfer
d20ec30bce
Add docs/roadmap.md and update CLAUDE.md
- Add docs/roadmap.md: strategic four-phase launch plan (feature complete →
  polish → beta → announce) with launch-blocking changes, post-launch backlog,
  and links to ideas/
- Update CLAUDE.md: fix stale phase-1-mvp OpenSpec reference, remove "Phase 2"
  label from Federation, add roadmap + ideas pointers, expand packages list
  with all 11 packages and accurate descriptions (including map vs. map-core
  split rationale)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:41:51 +02:00
Ullrich Schäfer
265cf34dc1
Merge pull request #406 from trails-cool/fix/journal-audit-omnibus
fix(journal): architectural audit omnibus (8 fixes)
2026-05-24 10:32:20 +02:00
dependabot[bot]
095dd744a5 [github-actions] pnpm dedupe 2026-05-24 08:31:54 +00:00
dependabot[bot]
01d8f433f1
Bump the production group with 14 updates
Bumps the production group with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.16.7` | `0.19.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.59.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.6` | `4.1.7` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.7` | `8.0.8` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.2` | `3.4.3` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.11.1` | `8.12.0` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.7.0` | `5.8.0` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.25.0` | `4.25.2` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.14` | `19.2.15` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.6` | `4.1.7` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.6` | `4.1.7` |
| [@garmin/fitsdk](https://github.com/garmin/fit-javascript-sdk) | `21.202.0` | `21.205.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.53.1` |


Updates `@expo/fingerprint` from 0.16.7 to 0.19.2
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `typescript-eslint` from 8.59.3 to 8.59.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/typescript-eslint)

Updates `vitest` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

Updates `nodemailer` from 8.0.7 to 8.0.8
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.8)

Updates `@sentry/cli` from 3.4.2 to 3.4.3
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.2...3.4.3)

Updates `@sentry/react-native` from 8.11.1 to 8.12.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.11.1...8.12.0)

Updates `react-native-safe-area-context` from 5.7.0 to 5.8.0
- [Release notes](https://github.com/AppAndFlow/react-native-safe-area-context/releases)
- [Commits](https://github.com/AppAndFlow/react-native-safe-area-context/compare/v5.7.0...v5.8.0)

Updates `react-native-screens` from 4.25.0 to 4.25.2
- [Release notes](https://github.com/software-mansion/react-native-screens/releases)
- [Commits](https://github.com/software-mansion/react-native-screens/compare/4.25.0...4.25.2)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

Updates `@vitest/browser` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/browser-playwright)

Updates `@garmin/fitsdk` from 21.202.0 to 21.205.0
- [Release notes](https://github.com/garmin/fit-javascript-sdk/releases)
- [Commits](https://github.com/garmin/fit-javascript-sdk/compare/21.202.0...21.205.0)

Updates `@sentry/react` from 10.51.0 to 10.53.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.51.0...10.53.1)

---
updated-dependencies:
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.19.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.25.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@garmin/fitsdk"
  dependency-version: 21.205.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-24 08:31:11 +00:00
Ullrich Schäfer
4de6c86d41
fix(journal): architectural audit omnibus
Addresses 8 issues from the Journal architecture audit:

1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
   these tables were full table scans; adds composite indexes matching
   the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
   destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
   in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
   a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
   retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
    lib/config.server.ts::getOrigin() across 14 call sites.

Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.

Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
  caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
  the new welcome-email enqueue assertion)

Full repo: pnpm typecheck, pnpm lint, pnpm test all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:33 +02:00
Ullrich Schäfer
a91085ab52
Merge pull request #405 from trails-cool/stigi/openspec-archive-cleanup
Clean up komoot-import openspec archive and SOPS secrets
2026-05-23 21:37:35 +02:00
Ullrich Schäfer
771ae57931
Archive komoot-import openspec change and update SOPS secrets
- Stage deletions of openspec/changes/komoot-import/ (files were moved
  to archive via shell mv, not git mv, so deletions weren't staged)
- Include SOPS secrets.app.env update adding INTEGRATION_SECRET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:33:53 +02:00
Ullrich Schäfer
579b1477f2
Merge pull request #404 from trails-cool/stigi/integration-secret-wiring
Wire INTEGRATION_SECRET into docker-compose and CI
2026-05-23 21:13:44 +02:00
Ullrich Schäfer
ae23d31a5f
Wire INTEGRATION_SECRET into docker-compose and CI; archive komoot-import
- Add INTEGRATION_SECRET to journal service in docker-compose.yml with
  :? guard so a missing value fails loudly at compose-up time
- Add INTEGRATION_SECRET to E2E test step in ci.yml via GitHub secret
  (unit tests already set their own value in the test file)
- Archive openspec/changes/komoot-import → archive/2026-05-23-komoot-import
- Sync delta specs: new openspec/specs/komoot-import/spec.md,
  updated openspec/specs/route-management/spec.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 21:10:08 +02:00
Ullrich Schäfer
5158298424
Merge pull request #403 from trails-cool/stigi/disconnect-redirect-fix
Return to originating settings page after disconnecting a service
2026-05-23 20:52:36 +02:00
Ullrich Schäfer
ec371ac400
Simplify: typed status, skip-write guard, remove redundant comment
- Use ImportBatchStatus type instead of raw string literals in sweep job
- Add a COUNT pre-check so the sweep UPDATE is skipped when no stale
  batches exist (avoids an unconditional write every minute)
- Remove comment in disconnect route that explained what the code does

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:49:09 +02:00
Ullrich Schäfer
da3659e07a
Also redirect to /settings/connections when disconnecting from a provider page 2026-05-23 20:46:52 +02:00
Ullrich Schäfer
12371d8c89
Return to the settings page the user came from after disconnecting
Previously always redirected to /settings (which resolves to /settings/profile).
Now reads the Referer header and redirects back to the originating /settings/*
page, defaulting to /settings/connections if no valid referer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:46:29 +02:00
Ullrich Schäfer
20821f3153
Merge pull request #402 from trails-cool/stigi/import-batches-sweep
Add cron sweep for stale import batches
2026-05-23 19:12:32 +02:00
Ullrich Schäfer
a181dfe6b8
Add cron job to sweep stale import batches every minute
Batches stuck in pending or running for more than 10 minutes (server
restart mid-import, pg-boss job dropped) are marked failed with a
user-visible message. Runs every minute via pg-boss cron with a 55s
expiry so overlapping runs are dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 19:08:49 +02:00
Ullrich Schäfer
64624cb13c
Merge pull request #401 from trails-cool/sorting
Add activity sort toggle on activities page
2026-05-23 18:26:06 +02:00
Ullrich Schäfer
22ff86c585
Merge pull request #400 from trails-cool/stigi/komoot-bulk-import
Add background bulk import for Komoot
2026-05-23 18:22:50 +02:00
Ullrich Schäfer
88078090e6
Add activity sort toggle on activities page
Same sort toggle as the user profile page (#399): default is activity
date (startedAt), switchable to "Date added" (createdAt) via ?sort=addedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:21:04 +02:00
Ullrich Schäfer
5ff7af8a81
Add background bulk import for Komoot
Replace the per-tour import UI with a fire-and-forget background job:

- Add `import_batches` DB table tracking status, found/imported/duplicate counts
- Add `runKomootBulkImport` server function that pages all Komoot tours,
  fetches GPX, creates activities, and deduplicates via sync_imports
- Add `komoot-bulk-import` pg-boss job registered at server startup
- Add POST /api/sync/komoot/import to enqueue the job
- Add GET /api/sync/komoot/import-status to return the latest batch
- Replace the Komoot import page with a progress UI that polls every 2s
  while a batch is running and shows found/imported/skipped counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:19:10 +02:00
Ullrich Schäfer
386867ef6a
Merge pull request #399 from trails-cool/stigi/activity-sort-toggle
Add activity sort toggle on user profile
2026-05-23 18:09:41 +02:00
Ullrich Schäfer
8641b0ad90
Add activity sort toggle on user profile page
Default sort is by activity date (startedAt); users can switch to
"Date added" (createdAt) via a URL query param (?sort=addedAt).
Activities without a startedAt fall to the bottom when sorted by date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:06:06 +02:00
Ullrich Schäfer
3f1d043377
Merge pull request #398 from trails-cool/stigi/komoot-import
Fix import-all skipping every other workout
2026-05-23 13:08:09 +02:00
Ullrich Schäfer
b25c5a9505
Fix import-all skipping every other workout
When a workout is imported and the loader revalidates, importableWorkouts
shrinks. The snapshot-update useEffect was overwriting the ref with the
shorter list, causing the index to point to the wrong item and skip every
alternate workout. Remove the snapshot update so the original list is used
throughout the import-all session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:00:43 +02:00
Ullrich Schäfer
45b61342be
Merge pull request #397 from trails-cool/stigi/komoot-import
Add Komoot import (public bio verification + authenticated)
2026-05-23 11:12:39 +02:00
Ullrich Schäfer
45c40ecea3
Make listImportable resilient to Komoot API errors; fix import E2E test
Return empty list instead of throwing when Komoot API is unavailable (e.g.
in CI with a synthetic user ID). Replaces direct API call in the import
test with a page-context test that verifies the page loads correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 11:05:15 +02:00
Ullrich Schäfer
4a5319fa72
Rewrite komoot E2E tests to use server-side seed endpoint
page.route() only intercepts browser requests, not server-side fetch calls.
Add /api/e2e/komoot seed endpoint that creates a Komoot connection directly
and returns a session cookie, so tests can verify UI state without mocking
the Komoot API at the network layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:57:16 +02:00
Ullrich Schäfer
25f7d35e03
Fix E2E: associate labels with inputs via htmlFor/id; increase test timeouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:49:18 +02:00
Ullrich Schäfer
9959ce90e6
Fix E2E: accept Terms of Service checkbox before passkey registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:34:26 +02:00
Ullrich Schäfer
5c299ab9f3
Mark all komoot-import tasks complete 2026-05-23 10:19:28 +02:00
Ullrich Schäfer
03304c354b
Add Komoot import with public (bio verification) and authenticated modes
Two-mode import: public mode verifies Komoot account ownership by checking
that the user's trails.cool profile URL appears in their Komoot bio — no
credentials stored. Authenticated mode uses email + password (AES-256-GCM
encrypted) to import private tours as well.

Includes unit tests for crypto/komoot client and E2E tests for the full
connect + import flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:18:46 +02:00
Ullrich Schäfer
b63fd1a303
Merge pull request #396 from trails-cool/stigi/komoot-import-two-mode-spec
Komoot import: add public mode via bio verification
2026-05-23 10:00:21 +02:00
Ullrich Schäfer
fef4051838
Add public import mode to Komoot import spec
Extends the Komoot import change to support two connection modes:

- Public mode: user places their trails.cool profile URL in their Komoot
  bio field; trails.cool verifies ownership via the unauthenticated public
  API (content_text field), then imports public tours with no credentials stored
- Authenticated mode: existing email + password flow, imports all tours
  including private ones

The profile URL verification doubles as cross-platform discovery — the
link stays in the Komoot bio permanently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:59:51 +02:00
Ullrich Schäfer
08449b01c9
Merge pull request #395 from trails-cool/stigi/e2e-planner-split
Split planner E2E tests into focused feature files
2026-05-22 22:05:41 +02:00
Ullrich Schäfer
d1a3701720
Fix e2e-testing spec: add missing Purpose section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:01:33 +02:00
Ullrich Schäfer
0b146f9b32
Split planner E2E tests into focused feature files
Breaks the monolithic planner.test.ts (581 lines, 25 tests) into five
focused files grouped by feature area, with BRouter mocked by default
via test.beforeEach in files that need routing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:58:10 +02:00
Ullrich Schäfer
72c01eb18c
Merge pull request #394 from trails-cool/stigi/waypoint-notes-journal
Propagate waypoint notes and POI data through full round-trip
2026-05-18 21:07:27 +02:00
Ullrich Schäfer
9e2ca5595e
Centralize waypoint Yjs serialization in waypointFromYMap/waypointToYMap
Introduce waypoint-ymap.ts with typed helpers so all Yjs↔Waypoint
conversions go through one place. New Waypoint fields now only need
to be added once rather than in every consumer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:02:27 +02:00
Ullrich Schäfer
02f8a8be44
Propagate waypoint notes through session init, save-to-journal, and Journal display
- use-yjs: seed note from initialWaypoints on session open
- api.sessions: include note in initialWaypoints type
- SaveToJournalButton: include note in GPX waypoints on save
- Journal routes.: map and display waypoint notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:52:12 +02:00
Ullrich Schäfer
353dcb2c13
Merge pull request #393 from trails-cool/stigi/waypoint-notes-e2e
Add E2E tests for waypoint notes + nearby POI snap; archive waypoint-notes
2026-05-18 07:42:25 +02:00
Ullrich Schäfer
aef67aab7a
Include waypoint note in GPX export and extractWaypoints type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:38:55 +02:00
Ullrich Schäfer
845301f0ae
Persist waypoint note from GPX drag-drop import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:30:31 +02:00
Ullrich Schäfer
112a8ab714
Rewrite note E2E: test GPX roundtrip via drag-drop instead of UI blur
The controlled textarea blur via el.blur() leaves noteEditValue stale
in the React closure. Instead, import a GPX with <desc> on a <wpt>
via drag-drop and verify it roundtrips correctly through export —
this tests the actual guarantee (parse+generate) more directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:12:48 +02:00
Ullrich Schäfer
e9e80c4e54
Fix E2E note save: use el.blur() and wait for textarea to disappear
sidebar h2 click doesn't reliably blur the textarea in headless Chrome.
el.blur() directly fires the native blur event; waiting for textarea
to disappear confirms onBlur executed and the note was saved to Yjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:30:38 +02:00
Ullrich Schäfer
1ad5c6be52
Fix E2E: click ▾ chevron to open export dropdown, not Export GPX button
The Export GPX text button directly downloads a route-only GPX.
The ▾ chevron opens the dropdown with Export Plan (includes waypoints).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 +02:00
Ullrich Schäfer
9cd08f0ebb
Fix E2E: wait for Export dropdown before clicking Export Plan
The dropdown renders asynchronously after clicking Export GPX;
wait for 'Export Plan' text to be visible before clicking it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:20:48 +02:00
Ullrich Schäfer
5d61553e02
Fix E2E: click Export Plan (not Export Route) for GPX with waypoints
The Export GPX button shows a dropdown; "Export Route" omits waypoints
while "Export Plan" includes them with notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:14:47 +02:00
Ullrich Schäfer
b4dd01db41
Fix spec format and E2E test robustness
- Fix openspec/specs/waypoint-notes/spec.md: replace delta-spec header
  with proper ## Purpose + ## Requirements structure so validation passes
- Fix GPX export E2E assertion: check gpxText directly instead of regex
  matching on lat coordinate; use click-elsewhere-to-blur instead of
  .blur() for more reliable note save timing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:10:34 +02:00
Ullrich Schäfer
41217ef658
Add E2E tests for waypoint notes and nearby POI snap; archive waypoint-notes
- E2E: type a note, blur, verify it persists and appears in GPX <desc> on <wpt>
- E2E: mock Overpass, select waypoint, verify Nearby list, snap to POI,
  verify note prefix prepended
- Sync waypoint-notes delta spec → openspec/specs/waypoint-notes/spec.md
- Archive waypoint-notes change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:06:07 +02:00
Ullrich Schäfer
763aa475ea
Merge pull request #392 from trails-cool/stigi/waypoint-notes
Add per-waypoint notes and nearby POI snap to Planner
2026-05-18 00:01:45 +02:00
Ullrich Schäfer
c2abb64ee0
Add per-waypoint notes and nearby POI snap to Planner
- `note` field on Waypoint type, stored in Yjs Y.Map and exported as
  `<desc>` inside `<wpt>` in GPX
- Inline textarea note editing in WaypointSidebar with auto-resize,
  character counter (500 max), save-on-blur, Escape cancel
- Note indicator dot on map markers; note tooltip on hover
- `useNearbyPois` hook: fetches POIs within 500m of selected waypoint
  via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit
  suppression
- NearbyPoiMarkers component: renders POI markers on map for selected
  waypoint with snap-to-POI on click
- Nearby section in WaypointSidebar: list with snap buttons, spinner,
  empty/rate-limited states, "Show more" toggle (5 → all)
- Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs
  transaction behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:58:21 +02:00
Ullrich Schäfer
bcf551cd27
Merge pull request #391 from trails-cool/stigi/journal-poi-details
Show POI details on Journal route detail page
2026-05-17 23:38:37 +02:00
Ullrich Schäfer
d55981d1bf
Add docs/gpx-extensions.md documenting the trails: GPX namespace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:35:06 +02:00
Ullrich Schäfer
faf2227896
Archive journal-poi-details change; sync journal-route-detail spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:33:37 +02:00
Ullrich Schäfer
861701e881
Show POI details (phone, website, opening hours) on Journal route detail page
Waypoints snapped to OSM POIs in the Planner now carry their metadata all
the way through to the Journal:

- Extend Waypoint type with osmId and poiTags fields
- Extract osmId/poiTags from Yjs Y.Map in ExportButton and SaveToJournalButton
- Encode POI metadata as <trails:poi> extensions in GPX <wpt> elements
- Parse <trails:poi> extensions back in the GPX parser
- Display phone, website, opening hours, address on Journal route detail
- E2E test for the full roundtrip; seed endpoint now defaults to public visibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:32:38 +02:00
Ullrich Schäfer
f1a314a70d
Merge pull request #390 from trails-cool/stigi/update-vite
Update vite to 8.0.13
2026-05-17 23:21:33 +02:00
Ullrich Schäfer
cff5b20f0f
Fix catalog key ordering in pnpm-workspace.yaml after pnpm install
pnpm reordered catalog keys alphabetically; also locks vite to ^8.0.13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:17:18 +02:00
Ullrich Schäfer
6c7bf6baeb
Merge pull request #389 from trails-cool/stigi/local-dev-stack
Local dev stack improvements
2026-05-17 23:15:39 +02:00
Ullrich Schäfer
c3c0ffeeab
Update vite to 8.0.13
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:14:14 +02:00
Ullrich Schäfer
970e0a0755
Archive local-dev-stack change; sync delta spec to main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:24 +02:00
Ullrich Schäfer
03791e981d
Mark verification tasks complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:24 +02:00
Ullrich Schäfer
d2a0b6e398
Fix BRouter segment cache key: include version to avoid format mismatch
The segment format changed between BRouter 1.7.8 and 1.7.9. The old
cache key 'brouter-segment-E10_N50' served the v1.7.8 segment to the
v1.7.9 binary, causing 'lookup version mismatch (old rd5?)' errors on
every routing request.

Include the BRouter version in the cache key so a fresh segment is
downloaded whenever the binary version changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
66ce852e96
Add BRouter routing wait diagnostic output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
1dcc518242
Pre-seed BRouter volume in CI instead of bind mount override
The bind mount override (docker-compose.ci.yml) failed because file
permissions on the cached segment prevented the entrypoint from seeing
the file, causing a fresh download on every CI run.

Instead: create the named volume and copy the cached segment into it
before compose starts, using alpine with explicit chmod. The entrypoint
then finds the segment and skips the download, so BRouter starts in ~4s
instead of ~2min.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
bea391c9fd
Increase BRouter routing wait to 120s in CI
BRouter needs time to parse the segment file into memory before it can
serve routing requests. 90s was insufficient; 120s matches the margin
the old explicit startup loop provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
134cf4ccf1
Simplify BRouter healthcheck to port liveness; add routing wait in CI
The routing probe healthcheck was too strict — BRouter is up but may
not have the segment loaded yet, causing all probes to fail. Use a
simple liveness check (curl exit 0 when server responds on any code)
so --wait unblocks as soon as the server is up. Add an explicit
routing readiness wait step in CI that actually confirms a route can
be computed before running E2E tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
4d0a865437
Fix BRouter healthcheck: use literal | and increase retry window
Percent-encoding the | may confuse older curl; use a literal | inside
single quotes instead. Also bump retries from 12 to 18 (total window
30s start + 180s probing) to cover slow first-run segment downloads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
8fd3dda8c6
Add BRouter healthcheck so compose --wait blocks until routing is ready
Without a healthcheck, --wait considers BRouter healthy as soon as the
container starts, before it has loaded segments and opened its port.
The healthcheck probes the routing endpoint directly, matching the old
manual curl loop in CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
a2d125922e
Fix Grafana dev provisioning: mount only datasources+dashboards
Mounting the full production provisioning dir pulled in the alerting
config which requires Pushover secrets. Mount only the two subdirs
that make sense locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:23 +02:00
Ullrich Schäfer
10e0a8d41a
Local dev stack improvements
- Extend docker-compose.dev.yml with optional monitoring profile
  (Prometheus, Grafana, Loki) via --profile monitoring
- Align dev PostgreSQL with production: pg_stat_statements + init scripts
- Add scripts/seed.ts with idempotent Berlin test data (user, route, activity)
- Add pnpm db:seed and pnpm dev:reset scripts
- Simplify CI e2e job: replace manual docker run + BRouter setup with
  docker compose up --wait; add db:seed step
- Improve scripts/dev.sh: Docker check, --wait health checks, --monitoring
  flag, seed step
- Add scripts/reset-dev.sh to wipe and restart the local stack
- Add .env.development.example with documented local defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:12:22 +02:00
Ullrich Schäfer
d7b3c7c1a5
Merge pull request #385 from trails-cool/dependabot/github_actions/stefanzweifel/git-auto-commit-action-7
Bump stefanzweifel/git-auto-commit-action from 5 to 7
2026-05-17 20:53:52 +02:00
Ullrich Schäfer
9926b5ccd6
Merge pull request #386 from trails-cool/dependabot/npm_and_yarn/production-dec2a9cf1b
Bump the production group with 40 updates
2026-05-17 20:52:21 +02:00
Ullrich Schäfer
6c3f8a3c38
Merge pull request #388 from trails-cool/stigi/archive-staging-environments-spec
Archive staging-environments change; update local-dev spec
2026-05-17 20:46:07 +02:00
Ullrich Schäfer
61b787ca84
Fix staging-environment spec: add missing Purpose section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:43:00 +02:00
Ullrich Schäfer
0e77ac7831
Update local-dev-stack proposal: staging now exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:41:03 +02:00
Ullrich Schäfer
9d4c7d2b41
Archive staging-environments change; update local-dev spec
Staging environments are live and fully operational. Marks the remaining
verification tasks complete, syncs the delta spec to main specs, and
archives the change.

Also adds a "When to Use Local vs. Staging" reference table to the
local-dev-environment spec so the boundary between the two environments
is documented in one place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 20:38:07 +02:00
dependabot[bot]
f04e9995ed [github-actions] pnpm dedupe 2026-05-17 08:46:41 +00:00
dependabot[bot]
78a986b946
Bump the production group with 40 updates
Bumps the production group with 40 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.23` | `55.0.24` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [@sentry/vite-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) | `5.2.1` | `5.3.0` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.0` |
| [i18next](https://github.com/i18next/i18next) | `26.0.10` | `26.2.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.7` | `17.0.8` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.12` | `2.9.14` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.2` | `8.59.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.1.1` | `11.2.1` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.1` | `3.4.2` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.11.0` | `8.11.1` |
| [expo-crypto](https://github.com/expo/expo/tree/HEAD/packages/expo-crypto) | `55.0.14` | `55.0.15` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.32` | `55.0.34` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.27` | `55.0.29` |
| [expo-device](https://github.com/expo/expo/tree/HEAD/packages/expo-device) | `55.0.16` | `55.0.17` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.19` | `55.0.20` |
| [expo-localization](https://github.com/expo/expo/tree/HEAD/packages/expo-localization) | `55.0.13` | `55.0.14` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `55.1.9` | `55.1.10` |
| [expo-navigation-bar](https://github.com/expo/expo/tree/HEAD/packages/expo-navigation-bar) | `55.0.12` | `55.0.13` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.22` | `55.0.23` |
| [expo-secure-store](https://github.com/expo/expo/tree/HEAD/packages/expo-secure-store) | `55.0.13` | `55.0.14` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.20` | `55.0.21` |
| [expo-sqlite](https://github.com/expo/expo/tree/HEAD/packages/expo-sqlite) | `55.0.15` | `55.0.16` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.17` | `55.0.18` |
| [expo-web-browser](https://github.com/expo/expo/tree/HEAD/packages/expo-web-browser) | `55.0.15` | `55.0.16` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.24.0` | `4.25.0` |
| [use-latest-callback](https://github.com/satya164/use-latest-callback) | `0.3.3` | `0.3.4` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.42.1` | `6.43.0` |
| [ws](https://github.com/websockets/ws) | `8.20.0` | `8.20.1` |
| [@vitest/browser](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser) | `4.1.5` | `4.1.6` |
| [@vitest/browser-playwright](https://github.com/vitest-dev/vitest/tree/HEAD/packages/browser-playwright) | `4.1.5` | `4.1.6` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.15.0` | `7.15.1` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.15.0` | `7.15.1` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.15.0` | `7.15.1` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.52.0` | `10.53.1` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.53.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.18` | `22.19.19` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.15.0` | `7.15.1` |


Updates `expo` from 55.0.23 to 55.0.24
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `@playwright/test` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `@sentry/vite-plugin` from 5.2.1 to 5.3.0
- [Release notes](https://github.com/getsentry/sentry-javascript-bundler-plugins/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/5.2.1...5.3.0)

Updates `eslint` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.0)

Updates `i18next` from 26.0.10 to 26.2.0
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.10...v26.2.0)

Updates `playwright` from 1.59.1 to 1.60.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.59.1...v1.60.0)

Updates `react-i18next` from 17.0.7 to 17.0.8
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.7...v17.0.8)

Updates `turbo` from 2.9.12 to 2.9.14
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.12...v2.9.14)

Updates `typescript-eslint` from 8.59.2 to 8.59.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.3/packages/typescript-eslint)

Updates `vitest` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/vitest)

Updates `@maplibre/maplibre-react-native` from 11.1.1 to 11.2.1
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.1.1...v11.2.1)

Updates `@sentry/cli` from 3.4.1 to 3.4.2
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.1...3.4.2)

Updates `@sentry/react-native` from 8.11.0 to 8.11.1
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.11.0...8.11.1)

Updates `expo-crypto` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-crypto/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-crypto)

Updates `expo-dev-client` from 55.0.32 to 55.0.34
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.27 to 55.0.29
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-menu/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-device` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-device/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-device)

Updates `expo-file-system` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-localization` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-localization/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-localization)

Updates `expo-location` from 55.1.9 to 55.1.10
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-navigation-bar` from 55.0.12 to 55.0.13
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-navigation-bar/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-navigation-bar)

Updates `expo-notifications` from 55.0.22 to 55.0.23
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-notifications/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-secure-store` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-secure-store/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-secure-store)

Updates `expo-splash-screen` from 55.0.20 to 55.0.21
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-splash-screen/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-sqlite` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-sqlite/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-sqlite)

Updates `expo-system-ui` from 55.0.17 to 55.0.18
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-system-ui/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `expo-web-browser` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-web-browser/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-web-browser)

Updates `react-native-screens` from 4.24.0 to 4.25.0
- [Release notes](https://github.com/software-mansion/react-native-screens/releases)
- [Commits](https://github.com/software-mansion/react-native-screens/compare/4.24.0...4.25.0)

Updates `use-latest-callback` from 0.3.3 to 0.3.4
- [Release notes](https://github.com/satya164/use-latest-callback/releases)
- [Changelog](https://github.com/satya164/use-latest-callback/blob/main/CHANGELOG.md)
- [Commits](https://github.com/satya164/use-latest-callback/compare/v0.3.3...v0.3.4)

Updates `@codemirror/view` from 6.42.1 to 6.43.0
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `ws` from 8.20.0 to 8.20.1
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

Updates `@vitest/browser` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/browser)

Updates `@vitest/browser-playwright` from 4.1.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/browser-playwright)

Updates `@react-router/dev` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.15.1/packages/react-router-dev)

Updates `@react-router/node` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.15.1/packages/react-router-node)

Updates `@react-router/serve` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.15.1/packages/react-router-serve)

Updates `@sentry/node` from 10.52.0 to 10.53.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.52.0...10.53.1)

Updates `@sentry/react` from 10.51.0 to 10.53.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.51.0...10.53.1)

Updates `@types/node` from 22.19.18 to 22.19.19
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-router` from 7.15.0 to 7.15.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.1/packages/react-router)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/vite-plugin"
  dependency-version: 5.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: playwright
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-crypto
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-device
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-localization
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 55.1.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-navigation-bar
  dependency-version: 55.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-secure-store
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-sqlite
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-web-browser
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: use-latest-callback
  dependency-version: 0.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@vitest/browser-playwright"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.53.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 08:45:54 +00:00
dependabot[bot]
7bf8a918af
Bump stefanzweifel/git-auto-commit-action from 5 to 7
Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-17 08:22:36 +00:00
Ullrich Schäfer
264276d1de
Merge pull request #384 from trails-cool/stigi/visual-diff-pr-comment
Post visual diff comment and job summary on visual test failure
2026-05-10 21:56:43 +02:00
Ullrich Schäfer
e059f36db9
Remove broken data URI images from job summary 2026-05-10 21:53:17 +02:00
Ullrich Schäfer
e662212dca
Post visual diff comment and job summary on visual test failure
When visual tests fail on a PR:
- Writes diff images (base64 data URIs) to the job summary for inline viewing
- Posts a PR comment listing failing tests with a link to the job summary
- Uploads .vitest-attachments/ as an artifact (include-hidden-files: true)

The job now has pull-requests: write permission for posting the comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 21:51:28 +02:00
Ullrich Schäfer
81334e977c
Merge pull request #383 from trails-cool/stigi/visual-diff-pr-comment
Post visual diff images as PR comment on failure
2026-05-10 20:13:30 +02:00
Ullrich Schäfer
7c74626464
Post visual diff images as PR comment on failure
When visual tests fail on a PR, upload each diff PNG to GitHub's CDN
via the issue assets endpoint and post a comment with the images
embedded inline. Also fixes the artifact upload path to point at
.vitest-attachments/ where the actual/diff files live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 20:10:03 +02:00
Ullrich Schäfer
2829ec2137
Merge pull request #381 from trails-cool/stigi/planner-gitignore
Ignore .vitest-attachments in planner
2026-05-10 19:57:18 +02:00
Ullrich Schäfer
eb30dddbfc
Ignore .vitest-attachments in planner
Vitest browser mode writes ephemeral screenshot attachments here during
test runs; only the __screenshots__/ reference snapshots belong in the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:53:42 +02:00
Ullrich Schäfer
e1e9fc6527
Merge pull request #380 from trails-cool/stigi/visual-tests-ci
Add visual-tests job to CI
2026-05-10 19:27:04 +02:00
Ullrich Schäfer
e5cb10fadc
Merge branch 'main' into stigi/visual-tests-ci 2026-05-10 19:23:13 +02:00
stigi
78a5a37214 chore: update visual snapshots [skip ci] 2026-05-10 17:15:46 +00:00
Ullrich Schäfer
4f8af21550
Fix update-visual-snapshots workflow: action versions + node version
- Switch checkout/setup-node/setup-pnpm to @v6 to match CI
- Replace node-version-file (.nvmrc doesn't exist) with node-version: 24
- Switch upload-artifact to @v7 to match CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:14:33 +02:00
Ullrich Schäfer
7c924827c9
Add initial visual snapshots and fix update script flag
Generate baseline screenshots (macOS/Chromium) for the elevation-chart
visual regression suite. The CI update-visual-snapshots workflow will
overwrite these with Linux variants on the first run.

Also fix the test:visual:update script: --update-snapshots is not a
valid Vitest 4 flag; the correct short form is -u.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:09:00 +02:00
Ullrich Schäfer
76028d277d
Remove invalid oxc.transform config from browser vitest config
OxcOptions doesn't have a transform key — Vite 8 handles TSX automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:05:15 +02:00
Ullrich Schäfer
fcc4f88379
Fix Vitest browser provider setup and test cleanup
- Add @vitest/browser-playwright; use playwright() factory (not string)
- Import page from vitest/browser (not deprecated @vitest/browser/context)
- Add afterEach(cleanup) so each test gets a fresh DOM
- Use oxc transform for JSX instead of esbuild (avoids Vite 8 warning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:01:24 +02:00
Ullrich Schäfer
49c0b2d1f4
Add visual-tests job to CI
Runs Vitest browser visual regression tests (drawElevationChart) on every
PR and push to main. Uses the same Playwright/Chromium cache as the e2e job.

On first run (no committed snapshots yet) the tests create the snapshots and
pass. On subsequent runs they compare against committed snapshots and fail on
visual regression. Failed runs upload a visual-snapshots-diff artifact so the
diff is visible in the Actions UI.

To update snapshots after an intentional visual change:
  Actions → "Update visual snapshots" → Run workflow (or add the
  `update-snapshots` label to the PR).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:56:57 +02:00
Ullrich Schäfer
01ab34c8d7
Merge pull request #379 from trails-cool/stigi/planner-component-split
Split ElevationChart + PlannerMap into deep modules; add visual regression testing
2026-05-10 16:45:15 +02:00
Ullrich Schäfer
34529a9432
Split ElevationChart + PlannerMap into deep modules; add visual regression testing
ElevationChart (1013 lines) split into:
- elevation-chart-draw.ts — pure drawElevationChart(ctx, w, h, params) function
- use-elevation-data.ts — useElevationData(routeData) hook
- ElevationChart.tsx — ~300 lines, interaction + render only

PlannerMap (869 lines) split into:
- MapHelpers.tsx — 7 Leaflet sub-components (MapExposer, RouteFitter, MapClickHandler,
  CursorTracker, NoGoAreaButton, OverlaySync, PoiRefresher)
- use-waypoint-manager.ts — all waypoint CRUD + route data sync
- use-gpx-drop.ts — GPX drag-and-drop hook
- PlannerMap.tsx — ~200 lines, orchestration only

Add Vitest browser visual regression tests for drawElevationChart via
@vitest/browser + Playwright (toMatchScreenshot). Tests cover plain, grade,
elevation, surface color modes plus hover and drag-select states.

Add update-visual-snapshots.yml workflow: triggered by workflow_dispatch or
the `update-snapshots` PR label; commits snapshots back to the branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:41:23 +02:00
Ullrich Schäfer
f843915c5a
Merge pull request #378 from trails-cool/stigi/spec-drift-catchup
Spec drift catch-up: FIT location, notification payload fields, shared-packages
2026-05-10 16:05:09 +02:00
Ullrich Schäfer
6c5fb6df0e
Spec drift catch-up: FIT location, notification payload fields, shared-packages index
- wahoo-import: clarify fitToGpx lives at connected-services/fit.ts (shared
  across providers) not inside the wahoo directory
- notifications: correct follow payload field names to followerUsername,
  followerDisplayName, targetUsername, targetDisplayName (matches code)
- shared-packages: add @trails-cool/fit package entry + CAPABILITIES.md index

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:02:13 +02:00
Ullrich Schäfer
e6babd012a
Merge pull request #377 from trails-cool/stigi/arch-deepening-3-1-6
Deepen three architectural seams: FIT consolidation, host election, injectable db
2026-05-10 15:55:43 +02:00
Ullrich Schäfer
e387e1f798
Deepen three architectural seams: FIT consolidation, host election extraction, injectable db
- Extract shared fitToGpx into connected-services/fit.ts (FIT is a Garmin
  open standard used by Wahoo, Coros, Garmin — not provider-specific)
- Add importActivity() to sync/imports.server.ts so providers call one
  function instead of createActivity + recordImport separately; eliminates
  direct dependency on activities.server.ts from provider adapters
- Update wahoo importer + webhook to use both shared helpers
- Extract useHostElection(yjs) hook from useRouting so host election is
  independently testable without mounting the full routing stack
- Add setDb() to journal db.ts for module-level injection in unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:52:31 +02:00
Ullrich Schäfer
48d97be8f1
Merge pull request #376 from trails-cool/stigi/callback-e2e-test
Add e2e tests for Planner callback → geometry stored atomically
2026-05-10 15:27:59 +02:00
Ullrich Schäfer
557244ee87
Fix unused import lint error in api.e2e.route.$id.ts 2026-05-10 15:24:31 +02:00
Ullrich Schäfer
e7a0c132b9
Add e2e tests for Planner callback → geometry stored atomically
Introduces two E2E=true-gated test endpoints:
- POST /api/e2e/seed — creates a test user + bare route, returns routeId + JWT
- GET /api/e2e/route/:id — returns { hasGeom } for post-callback assertions

Three new integration tests:
- valid GPX via callback stores geometry (hasGeom = true)
- invalid GPX (< 2 track points) returns 400, geometry not stored
- missing token returns 401

CI: E2E=true added to the "Run E2E tests" step so the seed endpoints
are enabled when react-router-serve runs during the Playwright job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:18:33 +02:00
Ullrich Schäfer
4189187dd4
Merge pull request #375 from trails-cool/stigi/atomic-gpx-save
Atomic GPX save: validate + persist row + geometry in one transaction
2026-05-10 15:15:05 +02:00
Ullrich Schäfer
abc3fbaa5b
Archive atomic-gpx-save + sync gpx-save spec to main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:09:15 +02:00
Ullrich Schäfer
b17f8eb02a
Revert archive of atomic-gpx-save (will redo via skill) 2026-05-10 15:07:54 +02:00
Ullrich Schäfer
b4ef8b0e0e
Archive atomic-gpx-save change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:06:55 +02:00
Ullrich Schäfer
78b8b8f55f
Atomic GPX save: validate + persist row + geometry in one transaction
Eliminates the silent-failure pattern where a route/activity row could
be committed with gpx IS NOT NULL but geom IS NULL if the PostGIS write
failed after the row insert.

- New gpx-save.server.ts owns all GPX validation (GpxValidationError,
  validateGpx) and PostGIS geometry writes (writeGeom, tx-aware)
- createRoute, updateRoute, createActivity, createRouteFromActivity all
  wrapped in db.transaction() covering row + geom + version snapshot
- demo-bot uses createRoute/createActivity instead of raw inserts;
  errors propagate loudly
- Callback endpoint returns 400 for GpxValidationError instead of 401
- ADR-0006 documents the invariant for future explorers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:02:56 +02:00
Ullrich Schäfer
8ae58a2d26
Merge pull request #373 from trails-cool/dependabot/npm_and_yarn/development-c6c1c67e4a
Bump fit-file-parser from 2.3.3 to 3.0.0 in the development group across 1 directory
2026-05-10 13:27:13 +02:00
Ullrich Schäfer
890e1f3655
Merge branch 'main' into dependabot/npm_and_yarn/development-c6c1c67e4a 2026-05-10 13:24:03 +02:00
Ullrich Schäfer
b3003e8e80
Merge pull request #371 from trails-cool/dependabot/github_actions/peter-evans/find-comment-4
Bump peter-evans/find-comment from 3 to 4
2026-05-10 13:23:44 +02:00
Ullrich Schäfer
0aba220212
Merge branch 'main' into dependabot/github_actions/peter-evans/find-comment-4 2026-05-10 13:19:57 +02:00
Ullrich Schäfer
2ba43767db
Merge pull request #370 from trails-cool/dependabot/github_actions/peter-evans/create-or-update-comment-5
Bump peter-evans/create-or-update-comment from 4 to 5
2026-05-10 13:19:37 +02:00
dependabot[bot]
0e8978452a
Bump peter-evans/find-comment from 3 to 4
Bumps [peter-evans/find-comment](https://github.com/peter-evans/find-comment) from 3 to 4.
- [Release notes](https://github.com/peter-evans/find-comment/releases)
- [Commits](https://github.com/peter-evans/find-comment/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peter-evans/find-comment
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:11:47 +00:00
dependabot[bot]
8f635fd5d1
Bump peter-evans/create-or-update-comment from 4 to 5
Bumps [peter-evans/create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) from 4 to 5.
- [Release notes](https://github.com/peter-evans/create-or-update-comment/releases)
- [Commits](https://github.com/peter-evans/create-or-update-comment/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/create-or-update-comment
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:11:45 +00:00
Ullrich Schäfer
984b67963a
Merge pull request #374 from trails-cool/fix/preview-deploy-race
Fix concurrent preview deploy race: per-PR env files + server flock
2026-05-10 13:10:46 +02:00
dependabot[bot]
9c5cd7d6e8
Bump fit-file-parser in the development group across 1 directory
Bumps the development group with 1 update in the / directory: [fit-file-parser](https://github.com/jimmykane/fit-parser).


Updates `fit-file-parser` from 2.3.3 to 3.0.0
- [Changelog](https://github.com/jimmykane/fit-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jimmykane/fit-parser/commits)

---
updated-dependencies:
- dependency-name: fit-file-parser
  dependency-version: 3.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 11:09:33 +00:00
Ullrich Schäfer
be69abc0ee
Merge branch 'main' into fix/preview-deploy-race 2026-05-10 13:06:48 +02:00
Ullrich Schäfer
c42dff7f37
Merge pull request #372 from trails-cool/dependabot/npm_and_yarn/production-69e9f3c6a7
Bump the production group with 41 updates
2026-05-10 13:06:38 +02:00
Ullrich Schäfer
e23751f29e
Fix concurrent preview deploy race: per-PR env files + server flock
Three changes:

1. Skip GH-Actions-only Dependabot PRs (dependabot/github_actions/*) in
   deploy-preview — there is no app image to preview, and these PRs were
   landing phantom containers with mismatched ports.

2. Use per-PR env files (staging-pr-{N}.env) instead of the shared
   staging.env for preview deploys and teardowns. Concurrent SCP transfers
   to the same filename were overwriting each other, causing wrong
   JOURNAL_HOST_PORT / JOURNAL_IMAGE_TAG values to be used.

3. Serialize server-side deploy operations with a flock on
   /tmp/trails-preview-deploy.lock (300s timeout). Eviction + compose up
   must be atomic; without the lock, two simultaneous jobs could both see
   "3 active previews" and both evict different projects, or one could
   start compose up against a just-evicted env.

Triggered by a Dependabot batch today (PRs 371-373) that opened
simultaneously and produced a phantom trails-pr-371 container running
the pr-370 image on port 3940 while the Caddyfile expected 3942,
causing sustained 502s on pr-371.staging.trails.cool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:03:52 +02:00
Ullrich Schäfer
6cd779ae02
Merge branch 'main' into dependabot/npm_and_yarn/production-69e9f3c6a7 2026-05-10 13:03:21 +02:00
Ullrich Schäfer
952480e077
Merge pull request #367 from trails-cool/architecture/unify-auth-completion
Centralize web auth completion in completeAuth
2026-05-10 12:55:21 +02:00
Ullrich Schäfer
c86b444ece
Merge branch 'main' into architecture/unify-auth-completion 2026-05-10 12:51:23 +02:00
dependabot[bot]
9c985d908c [github-actions] pnpm dedupe 2026-05-10 08:43:29 +00:00
dependabot[bot]
9cc08a725a
Bump the production group with 41 updates
Bumps the production group with 41 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.19` | `55.0.23` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.5` | `19.2.0` |
| [@expo/fingerprint](https://github.com/expo/expo/tree/HEAD/packages/@expo/fingerprint) | `0.16.6` | `0.16.7` |
| [i18next](https://github.com/i18next/i18next) | `26.0.8` | `26.0.10` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.6` | `17.0.7` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.8` | `2.9.12` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.1` | `8.59.2` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.39` | `5.1.40` |
| [zod](https://github.com/colinhacks/zod) | `4.4.2` | `4.4.3` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `55.0.10` | `55.0.11` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.13` | `5.2.14` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.0.2` | `11.1.1` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.10.0` | `8.11.0` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `55.0.15` | `55.0.16` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.30` | `55.0.32` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.26` | `55.0.27` |
| [expo-device](https://github.com/expo/expo/tree/HEAD/packages/expo-device) | `55.0.15` | `55.0.16` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.17` | `55.0.19` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `55.0.14` | `55.0.15` |
| [expo-location](https://github.com/expo/expo/tree/HEAD/packages/expo-location) | `55.1.8` | `55.1.9` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `55.0.13` | `55.0.14` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.19` | `55.0.20` |
| [expo-status-bar](https://github.com/expo/expo/tree/HEAD/packages/expo-status-bar) | `55.0.5` | `55.0.6` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.16` | `55.0.17` |
| [expo-web-browser](https://github.com/expo/expo/tree/HEAD/packages/expo-web-browser) | `55.0.14` | `55.0.15` |
| [react-native-gesture-handler](https://github.com/software-mansion/react-native-gesture-handler) | `2.31.1` | `2.31.2` |
| [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated/tree/HEAD/packages/react-native-reanimated) | `4.3.0` | `4.3.1` |
| [jest-expo](https://github.com/expo/expo/tree/HEAD/packages/jest-expo) | `55.0.16` | `55.0.17` |
| [react-test-renderer](https://github.com/facebook/react/tree/HEAD/packages/react-test-renderer) | `19.2.5` | `19.2.6` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.41.1` | `6.42.1` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.14.2` | `7.15.0` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.14.2` | `7.15.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.14.2` | `7.15.0` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.52.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.51.0` | `10.52.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.4` | `4.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.17` | `22.19.18` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.5` | `19.2.6` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.2` | `7.15.0` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.4` | `4.3.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.2` | `7.3.3` |


Updates `expo` from 55.0.19 to 55.0.23
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `react` from 19.2.5 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react)

Updates `@expo/fingerprint` from 0.16.6 to 0.16.7
- [Changelog](https://github.com/expo/expo/blob/main/packages/@expo/fingerprint/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/@expo/fingerprint)

Updates `i18next` from 26.0.8 to 26.0.10
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.8...v26.0.10)

Updates `react-i18next` from 17.0.6 to 17.0.7
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.6...v17.0.7)

Updates `turbo` from 2.9.8 to 2.9.12
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.8...v2.9.12)

Updates `typescript-eslint` from 8.59.1 to 8.59.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.2/packages/typescript-eslint)

Updates `isbot` from 5.1.39 to 5.1.40
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.39...v5.1.40)

Updates `zod` from 4.4.2 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.2...v4.4.3)

Updates `@expo/metro-runtime` from 55.0.10 to 55.0.11
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@gorhom/bottom-sheet` from 5.2.13 to 5.2.14
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.13...v5.2.14)

Updates `@maplibre/maplibre-react-native` from 11.0.2 to 11.1.1
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.0.2...v11.1.1)

Updates `@sentry/react-native` from 8.10.0 to 8.11.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.10.0...8.11.0)

Updates `expo-constants` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-constants/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 55.0.30 to 55.0.32
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-client/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.26 to 55.0.27
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-dev-menu/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-device` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-device/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-device)

Updates `expo-file-system` from 55.0.17 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-file-system/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-linking/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-location` from 55.1.8 to 55.1.9
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-location/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-location)

Updates `expo-router` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-router/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-splash-screen` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-splash-screen/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-status-bar` from 55.0.5 to 55.0.6
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-status-bar/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-status-bar)

Updates `expo-system-ui` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-system-ui/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `expo-web-browser` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/packages/expo-web-browser/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-web-browser)

Updates `react-native-gesture-handler` from 2.31.1 to 2.31.2
- [Release notes](https://github.com/software-mansion/react-native-gesture-handler/releases)
- [Commits](https://github.com/software-mansion/react-native-gesture-handler/compare/v2.31.1...v2.31.2)

Updates `react-native-reanimated` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/software-mansion/react-native-reanimated/releases)
- [Changelog](https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-reanimated/RELEASE.md)
- [Commits](https://github.com/software-mansion/react-native-reanimated/commits/4.3.1/packages/react-native-reanimated)

Updates `jest-expo` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/packages/jest-expo/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/jest-expo)

Updates `react-test-renderer` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-test-renderer)

Updates `@codemirror/view` from 6.41.1 to 6.42.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@react-router/dev` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.15.0/packages/react-router-dev)

Updates `@react-router/node` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.15.0/packages/react-router-node)

Updates `@react-router/serve` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.15.0/packages/react-router-serve)

Updates `@sentry/node` from 10.51.0 to 10.52.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.51.0...10.52.0)

Updates `@sentry/react` from 10.51.0 to 10.52.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.51.0...10.52.0)

Updates `@tailwindcss/vite` from 4.2.4 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/@tailwindcss-vite)

Updates `@types/node` from 22.19.17 to 22.19.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-dom` from 19.2.5 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react-dom)

Updates `react-router` from 7.14.2 to 7.15.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.15.0/packages/react-router)

Updates `tailwindcss` from 4.2.4 to 4.3.0
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.0/packages/tailwindcss)

Updates `vite` from 7.3.2 to 7.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/fingerprint"
  dependency-version: 0.16.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.40
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: zod
  dependency-version: 4.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 55.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-device
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-location
  dependency-version: 55.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-status-bar
  dependency-version: 55.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-web-browser
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-gesture-handler
  dependency-version: 2.31.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-reanimated
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: jest-expo
  dependency-version: 55.0.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-test-renderer
  dependency-version: 19.2.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.42.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@types/node"
  dependency-version: 22.19.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vite
  dependency-version: 7.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-10 08:42:40 +00:00
Ullrich Schäfer
e268aeec10
Archive unify-auth-completion + sync spec delta (task 5.1)
Applies the ADDED requirement from
openspec/changes/unify-auth-completion/specs/authentication-methods/
into openspec/specs/authentication-methods/spec.md:

- Single web auth completion chokepoint (completeAuth at
  apps/journal/app/lib/auth/completion.server.ts) with five scenarios
  covering passkey register/login finish, magic-link verify-code,
  magic-link click-through, and returnTo sanitization.

Path in the synced requirement is .server.ts (the post-rename name),
not the .ts the delta originally captured.

Change moved to openspec/changes/archive/2026-05-08-unify-auth-completion/.
15/15 tasks complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:02:51 +02:00
Ullrich Schäfer
b9aac2859a
Drop auth.server.ts re-exports + rename to .server.ts convention (task 5.2)
Two cleanups in one pass:

1. Update import paths app-wide from `~/lib/auth.server` to
   `~/lib/auth/session.server` for the four session helpers
   (sessionStorage, createSession, getSessionUser, destroySession).
   ~40 files: 33 simple path swaps where the file imported only session
   symbols, 5 splits where it also imported per-method auth functions
   (auth.verify.tsx, api.settings.email.ts, activities.\$id.tsx,
   routes.\$id.tsx, auth.accept-terms.tsx) — those keep one import
   from auth.server (for verifyMagicToken, canView, recordTermsAcceptance,
   etc.) and gain a second import from auth/session.server.
   Two more files used relative paths and were missed by the first
   grep pass (lib/oauth.server.ts and routes/oauth.authorize.tsx) —
   migrated too.
   The @deprecated re-exports block in auth.server.ts is gone.

2. Rename the new auth files to follow the project's `.server.ts`
   convention so Vite/React Router treat them as server-only (they
   read process.env.SESSION_SECRET, hit the DB, etc. — must NOT enter
   the client bundle):
   - auth/session.ts → auth/session.server.ts
   - auth/completion.ts → auth/completion.server.ts
   - auth/completion.test.ts → auth/completion.server.test.ts
   Done with `git mv` so blame is preserved.

Verified: typecheck + lint green; 126 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 03:01:30 +02:00
Ullrich Schäfer
fc2f938cf5
Mark task 4.3 (manual smoke) complete
All four flows verified locally over plain HTTP: passkey register,
passkey login, logout, magic-link 6-digit verify-code, magic-link
click-through. Session cookie set + correct redirect every time.

13/14 tasks done. Only 5.1 (spec delta sync at /opsx:archive time)
and 5.2 (optional follow-up to drop the auth.server.ts re-exports)
remain — neither blocks merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:55:19 +02:00
Ullrich Schäfer
2fdbe5281d
Merge pull request #368 from trails-cool/ci-openspec-validate
ci: validate openspec specs and changes
2026-05-08 02:40:38 +02:00
Ullrich Schäfer
d64c47614d
Implement completeAuth chokepoint + caller migration
Implements all of unify-auth-completion (12/14 tasks done; manual
smoke + archive-time spec sync remain).

Design refinement during implementation: completeAuth supports two
response shapes via a `mode` parameter:
- mode: 'redirect' (loaders / direct browser navigation; auth.verify.tsx)
- mode: 'json' (action handlers called by imperative fetch from
  client forms; api.auth.login, api.auth.register)

Both modes share createSession + safeReturnTo + Set-Cookie. JSON mode
carries `{ ok: true, step: "done", redirectTo }` (the `step` field
preserves the existing client-form check).

Why two modes: passkey ceremonies are inherently imperative
(start → browser WebAuthn API → finish), so action handlers can't
move to <Form>/useFetcher. Picking option (B) from the design grill —
the chokepoint owns destination selection while clients navigate —
required this dual shape. The 3 hardcoded client-side targets
(returnTo ?? "/", "/", "/?add-passkey=1") collapse into 1 server-side
sanitization pass (safeReturnTo) inside completeAuth.

New module:
- apps/journal/app/lib/auth/session.ts: cookie session storage
  (sessionStorage, createSession, getSessionUser, destroySession)
  moved from auth.server.ts. Legacy import path kept via re-exports
  with @deprecated JSDoc.
- apps/journal/app/lib/auth/completion.ts: completeAuth + safeReturnTo.
- apps/journal/app/lib/auth/completion.test.ts: 10 contract tests
  covering both modes, returnTo sanitization (path-relative, protocol-
  relative, absolute-URL, malformed), Set-Cookie attachment, redirect
  status, JSON shape.

Caller migration:
- api.auth.register.ts passkey-finish → completeAuth(json)
- api.auth.login.ts finish-passkey → completeAuth(json)
- api.auth.login.ts verify-code → completeAuth(json)
- auth.verify.tsx magic-link consumer → completeAuth(redirect)

Client form updates:
- auth.login.tsx: pass returnTo in fetch body, read result.redirectTo
  on done.
- auth.register.tsx: pass returnTo: "/?add-passkey=1" for the magic-
  link verify-code path (preserves the post-register passkey prompt
  via the chokepoint's safeReturnTo, instead of hardcoding it
  client-side).

Verified:
- pnpm typecheck && pnpm lint: green across all 15 workspaces.
- pnpm --filter @trails-cool/journal test: 126 passed.
- pnpm test:e2e auth: 4/4 passed without modification — confirms the
  refactor is behaviour-preserving for the user-facing flows that
  matter most (passkey register + login).

Spec delta in openspec/changes/unify-auth-completion/specs/ applies at
/opsx:archive time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:38:15 +02:00
Ullrich Schäfer
9cf6398a76
ci: pin openspec via devDependency
Adds @fission-ai/openspec ^1.3.1 as a root devDependency so local and CI
run the same lockfile-pinned version, and dependabot can bump it. CI now
runs `pnpm openspec validate --all --strict` instead of npx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:36:53 +02:00
Ullrich Schäfer
5b6fecf416
ci: fix openspec package name
The npm package `openspec` is an unrelated 0.0.0 placeholder; the real
CLI ships as `@fission-ai/openspec`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:35:10 +02:00
Ullrich Schäfer
b78c7b77e0
ci: validate openspec specs and changes
Adds a fast standalone job that runs `openspec validate --all --strict`
via npx, gating PRs on spec/change well-formedness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:33:39 +02:00
Ullrich Schäfer
7e22f1260b
Add unify-auth-completion architecture artifacts
Extract the post-verify orchestration shared across passkey
register-finish, passkey login-finish, magic-link verify-code, and
magic-link click-through into a single completeAuth function. Two
ADRs record the decision:

- ADR-0004: centralize web auth completion (record terms + create
  session + redirect) in apps/journal/app/lib/auth/completion.ts.
- ADR-0005: explicitly no AuthMethod polymorphism. Passkey + magic-
  link is the entire identity surface; OAuth2/PKCE is session
  transport, not a peer method. Recorded as a negative decision so
  future architecture passes don't re-suggest extracting the
  interface.

CONTEXT.md gains an Authentication section covering completeAuth, the
two methods, the OAuth2-as-transport distinction, and where the Terms
gate enforcement lives (root loader for web, requireApiUser for API
per the just-merged mobile-terms-gate).

OpenSpec change unify-auth-completion captures the proposal, design
(5 decisions including the negative-scope choices), spec delta on
authentication-methods, and 14 tasks. Implementation follows on this
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:18:06 +02:00
Ullrich Schäfer
1a0a212139
Merge pull request #366 from trails-cool/fix/mobile-terms-gate
fix: enforce Terms gate on bearer-token API requests
2026-05-08 02:06:19 +02:00
Ullrich Schäfer
1e43e96732
Archive mobile-terms-gate + sync spec delta
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:02:24 +02:00
Ullrich Schäfer
067e2ebd0b
fix: enforce Terms gate on bearer-token API requests
Mobile API requests authenticated via OAuth2 bearer tokens bypassed the
Terms gate that the root loader applies to web cookie sessions. Extend
requireApiUser to compare the user's termsVersion with TERMS_VERSION
and return a structured 403 { code: "TERMS_OUTDATED", currentTermsVersion }
on mismatch so mobile clients can surface their own re-acceptance UI.

Spec delta on journal-auth captures the new requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:59:28 +02:00
Ullrich Schäfer
eee5689a75
Merge pull request #361 from trails-cool/architecture/deepen-connected-services
Deepen connected-services architecture (sync provider seam)
2026-05-08 01:39:44 +02:00
Ullrich Schäfer
7cd785e937
Archive deepen-connected-services + sync spec deltas
Task 7.3: applies the MODIFIED + ADDED requirements from
changes/deepen-connected-services/specs/ into openspec/specs/:

- connected-services/spec.md:
  - MODIFIED: OAuth token storage (renamed sync_connections → connected_services
    with credential_kind discriminator + JSONB credentials shape).
  - ADDED: Capability seams for providers (Importer / RoutePusher /
    WebhookReceiver per ADR-0002, no unified SyncProvider).
  - ADDED: Centralized credential lifecycle via ConnectedServiceManager.

- wahoo-import/spec.md: Provider-agnostic framework rewritten to reference
  capability seams + per-provider manifest. Token refresh now goes through
  withFreshCredentials. Renames sync_connections → connected_services
  throughout.

- wahoo-route-push/spec.md: Renames sync_connections → connected_services.
  ADDED: RoutePusher capability seam — shape (service, route) →
  {remoteId, version} per ADR-0003; Wahoo workarounds stay inside the
  adapter.

Change directory moved to openspec/changes/archive/2026-05-08-deepen-connected-services/.

29/29 tasks complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:36:19 +02:00
Ullrich Schäfer
14da6e6b53
Mark manual smoke test complete (task 6.3)
Verified live against local dev with HTTPS:
- Wahoo OAuth connect flow
- Import list (Importer.listImportable + withFreshCredentials)
- Single workout import
- Route push (POST then PUT after edit)
- Disconnect + reconnect (unique constraint upsert)

28/29 tasks complete. Only 7.3 (spec deltas at archive) remains —
applied automatically by /opsx:archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:33:12 +02:00
Ullrich Schäfer
e6212ad6fc
Annotate komoot-import design with deepen-connected-services supersession
Task 7.2: when komoot-import is implemented, it must use the
connected_services + web-login credential kind shape, not a separate
journal.integrations table. Note added to komoot-import/design.md
referencing ADR-0001 and CONTEXT.md.

Also marks tasks 6.2 (no Wahoo e2e tests exist; nothing to run) and
7.1 (no CONTEXT.md term changes during impl) complete in tasks.md.

Remaining: 6.3 (manual smoke), 6.4 (staging migration test), 7.3
(spec deltas at archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:26:21 +02:00
Ullrich Schäfer
8e5b6d6fe9
Wahoo capability adapters + caller migration (groups 3-5)
Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of
deepen-connected-services. Built TDD: contract test red, adapter green
for each capability seam.

Capability adapters (providers/wahoo/):
- importer.ts: Importer seam — listImportable + importOne against
  Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't
  share third-party data). 2 contract tests green.
- pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId,
  version}. FIT-Course conversion, route:<id> external_id, PUT-vs-POST
  decision, PUT->POST-on-404 fallback all internal to the adapter
  (per ADR-0003). Idempotency via sync_pushes preserved. 4 contract
  tests green.
- webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes
  events to local users via provider_user_id; unknown user returns
  silently. 6 contract tests green.
- manifest.ts: declares credential_kind=oauth, OAuth config, scopes,
  buildAuthUrl, exchangeCode, and references each capability adapter.

Module shape:
- connected-services/index.ts: side-effect imports providers/index.ts
  to register manifests, then re-exports manager + registry + types.
- connected-services/providers/index.ts: barrel that calls
  registerManifest(wahooManifest).
- connected-services/oauth-state.server.ts: OAuth state encode/decode
  (extracted from legacy pushes.server.ts).
- connected-services/push-action.server.ts: orchestration above the
  RoutePusher seam — load route, ownership check, scope check, build
  RoutePushInput, invoke pusher, return PushOutcome union. Replaces
  the legacy pushRouteToProvider in lib/sync/pushes.server.ts.

Caller migration (group 5):
- api.sync.connect.$provider.ts -> manifest.buildAuthUrl
- api.sync.callback.$provider.ts -> manifest.exchangeCode + link()
- api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls
  best-effort revoke via the credential adapter, then deletes locally)
- api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch
- api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider
- sync.import.$provider.tsx -> manifest.importer.listImportable +
  inline FIT->GPX in the action (form-supplied metadata bypasses the
  Importer seam which is reserved for automatic / webhook imports)
- routes.$id.tsx -> getService instead of getConnection
- settings.connections.tsx -> getAllManifests instead of legacy registry

Legacy lib/sync/ deleted except imports.server.ts (which manages the
sync_imports table, untouched by this change).

DB migration verified locally (task 1.3): pnpm db:migrate-data renamed
the table and backfilled credentials JSONB; pnpm db:push then dropped
the legacy access_token/refresh_token/expires_at columns. Final shape
matches the schema; check constraints + unique index in place.

Test status (task 6.1):
- pnpm typecheck: green across all 15 workspaces
- pnpm lint: green
- @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than
  before because pushes.server.test.ts and the legacy wahoo.test.ts
  were deleted. Their coverage is replaced by the new contract tests
  (importer/pusher/webhook) plus manager.test.ts + oauth.test.ts.

Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test),
7.1-7.3 (followups + spec deltas at archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:25:33 +02:00
Ullrich Schäfer
5dda69ab49
Lint fix: drop unused ProviderOAuthConfig type import
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
6de516718d
Schema rename + ConnectedServiceManager foundation (groups 1-2)
Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:

DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
  credentials JSONB (shape per kind), status column, and a unique index
  on (user_id, provider) lifting the previously app-only invariant
  into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
  Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.

Code:
- New apps/journal/app/lib/connected-services/ module:
  - types.ts: ConnectedService, CredentialKind, OAuthCredentials,
    NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
  - credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
    revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
  - manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
    markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
  - registry.ts: ProviderManifest type + capability seam interfaces
    (Importer, RoutePusher, WebhookReceiver). Manifests register
    themselves at import time.

Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
  ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
  refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.

Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
  translating the legacy TokenSet API onto the JSONB-shaped table so
  existing callers (routes, pushes.server.ts) keep working until tasks
  5.x migrate them to the manager. To be deleted in task 5.6.

Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
cfba3146e2
Add deepen-connected-services architecture artifacts
Reshape the sync-providers seam before Komoot (web-login) and Apple
Health (device) adapters land. Captures the decisions in three ADRs,
seeds CONTEXT.md with Connected Services vocabulary, and proposes the
OpenSpec change covering schema rename + ConnectedServiceManager +
capability seams (Importer / RoutePusher / WebhookReceiver).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00
Ullrich Schäfer
ede480e652
Merge pull request #365 from trails-cool/e2e-hydration-and-workers
Stabilize e2e against Vite dev hydration race
2026-05-08 01:13:33 +02:00
Ullrich Schäfer
e6dc809e08
Drop workers=1 — hydration helper is sufficient
The hydration helper alone fixes the flake; restoring CPU-count
workers locally cuts the suite from 55s to 22s. Cover the two
remaining /auth/login navigations the prior commit missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:10:15 +02:00
Ullrich Schäfer
b7ed54ba72
Stabilize e2e against Vite dev hydration race
Two issues caused the same class of flake locally:

1. Default workers were CPU-count, but the journal/planner are served
   by Vite dev (not the production build CI uses). Cold-compiling
   `/api/auth/register` under N parallel hits produced 30s timeouts
   on a quarter of runs. Set workers to 1 in both environments for
   parity with CI.
2. Even sequentially, a button is clickable per Playwright's
   actionability check before React has hydrated its `onClick`. So
   the first click after a navigation could fire native form submit
   (or do nothing), which manifested as "URL never changed to /" or
   "menuitem Log Out never appeared". Add a `waitForHydration` helper
   that polls for React fibers (`__reactProps$<id>`) attached to a
   DOM node and call it after each cross-page navigation that ends
   in an interactive form or dropdown.

CI is unaffected (production builds hydrate fast and didn't expose
either bug), but the helper is harmless there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:06:27 +02:00
Ullrich Schäfer
fc2d0a02d5
Merge pull request #364 from trails-cool/e2e-silent-drop-sentry
Silently drop Sentry envelope requests in e2e fixture
2026-05-08 00:59:18 +02:00
Ullrich Schäfer
796dbea804
Silently drop Sentry envelope requests in e2e fixture
Two e2e tests intermittently failed with "unmocked external request"
when the Sentry browser SDK emitted an envelope to the real ingest
endpoint despite `enabled: false`. The BrowserTracing integration
captures the page-load transaction before init's enabled flag fully
propagates, so a single envelope leaks on cold start.

Add a SILENT_DROP list that aborts matching requests without recording
them as blocked, so legitimate missing-mock failures still surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:53:41 +02:00
Ullrich Schäfer
ddbf95f6bf
Merge pull request #363 from trails-cool/brouter-auto-segments
Auto-download BRouter segments on first container start
2026-05-08 00:47:17 +02:00
Ullrich Schäfer
46743a91db
Auto-download BRouter segments on first container start
Fresh `pnpm dev:services` checkouts came up with an empty
brouter_segments volume, so every routing request returned
"datafile not found" and the e2e suite's BRouter tests failed.

Add an entrypoint that runs download-segments.sh when /data/segments
has no rd5 files, then exec's the existing server command. Subsequent
starts find the populated volume and skip straight to the server.
Keep wget in the final image (previously stripped) so the entrypoint
can fetch segments at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:43:43 +02:00
Ullrich Schäfer
f1963aab7a
Merge pull request #362 from trails-cool/fit-typecheck
Add typescript to root devDependencies
2026-05-08 00:41:29 +02:00
Ullrich Schäfer
588323730f
Add typescript to root devDependencies
Workspace packages run `tsc` in their typecheck scripts but don't declare
typescript as a dep — they relied on it being hoisted. On a clean local
install the binary wasn't present at node_modules/.bin/tsc, so every
package failed with `tsc: command not found`. Declaring typescript at the
root makes the dependency explicit and removes the latent fragility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:37:42 +02:00
Ullrich Schäfer
946cfe7e44
Merge pull request #360 from trails-cool/stigi/preview-comment-update
Update single PR comment across preview deploy + teardown
2026-05-04 07:21:24 +02:00
Ullrich Schäfer
bdf9270f88
Update single PR comment across preview deploy + teardown
The deploy job was creating a fresh comment on every push, so PRs
accumulated a wall of preview-URL comments. Now we look up the existing
comment by an HTML marker (`<!-- cd-staging:preview -->`) and edit it
in place — both on push and on teardown. Comment also gains a link to
the GitHub Actions run that produced the preview, plus the head SHA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 07:17:57 +02:00
Ullrich Schäfer
fbe9882455
Merge pull request #359 from trails-cool/stigi/caddyfile-staging-ports
Update Caddyfile staging upstreams to ports 3110/3111
2026-05-03 22:52:14 +02:00
Ullrich Schäfer
8f5de34e50
Update Caddyfile staging upstreams to ports 3110/3111
Companion to PR #358 which moved the staging compose ports off 3100/3101
(Loki conflict on the vSwitch). The Caddyfile staging blocks have the
upstream ports baked in so they need bumping too — already applied
manually on the flagship to unblock staging; this lands it in-repo so it
survives the next cd-infra deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:48:53 +02:00
Ullrich Schäfer
32ff7c81ec
Merge pull request #358 from trails-cool/stigi/staging-port-fix
Move persistent staging off port 3100 to avoid Loki conflict
2026-05-03 22:42:29 +02:00
Ullrich Schäfer
e93cfd2adb
Trigger cd-staging on its own config changes + workflow_dispatch
So a port bump or compose edit doesn't sit unapplied until the next
apps/ push, and so persistent staging can be redeployed manually
without forcing a no-op apps/ change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:39:16 +02:00
Ullrich Schäfer
34711550fc
Move persistent staging off port 3100 to avoid Loki conflict
Loki binds 10.0.0.2:3100 on the vSwitch interface so the BRouter host can
ship logs in. Persistent staging was trying to publish 0.0.0.0:3100 which
conflicts because Linux refuses 0.0.0.0:<P> when any specific-interface
:<P> is already in use. Move staging journal to 3110, planner to 3111.

PR previews are unaffected — they're already on 3200+2N.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:38:38 +02:00
Ullrich Schäfer
c703bf4c76
Merge pull request #357 from trails-cool/stigi/preview-test
Test cd-staging preview lifecycle
2026-05-03 22:33:09 +02:00
Ullrich Schäfer
25d21161c5
Fix staging port binding + diagnostic ps env file
Two bugs in the staging-environments rollout:

1. Staging containers published on 127.0.0.1 are unreachable from the
   production Caddy container, which connects via the Docker bridge IP
   (host.docker.internal:host-gateway resolves to the bridge, not
   loopback). Bind to 0.0.0.0 instead — Hetzner Cloud firewall blocks
   ports 3000+ from the public internet, so it stays internal-only.

2. The trailing 'docker compose ps' diagnostic in deploy-staging /
   deploy-preview was missing --env-file staging.env, so compose
   failed env interpolation and the job exited non-zero even when the
   actual deploy succeeded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:30:05 +02:00
Ullrich Schäfer
7615ecb6e7
Test PR for staging preview lifecycle
Adds a one-line clarifying comment near TERMS_GATE_ALLOWLIST. The point
of this PR is to exercise cd-staging.yml end-to-end (preview deploy on
open, update on push, teardown on close).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:18:06 +02:00
Ullrich Schäfer
de5c96351c
Merge pull request #356 from trails-cool/stigi/staging-envs
Add staging + PR-preview environments on the flagship
2026-05-03 22:13:55 +02:00
Ullrich Schäfer
4c62c0b5aa
Merge branch 'main' into stigi/staging-envs 2026-05-03 22:10:25 +02:00
Ullrich Schäfer
c8a7a0b253
Add staging + PR-preview environments on the flagship
Implements the staging-environments OpenSpec change. Persistent staging at
staging.trails.cool / planner.staging.trails.cool deploys from main; PR
opens get a journal-only preview at pr-<N>.staging.trails.cool that shares
the persistent planner. cd-staging.yml builds tagged images, manages
per-PR Postgres databases and Caddyfile snippets, evicts the oldest
preview at the cap of 3, and tears everything down on PR close.
staging-cleanup.yml runs weekly to sweep orphaned previews.

DNS records (staging + *.staging A/AAAA) already applied to production
via tofu.

Caddy approach: per-PR Caddyfile snippets imported from /etc/caddy/sites/
and reloaded on each PR event — no wildcard / on-demand TLS, no router
service. Production compose gains a trails-shared network for the staging
project to reach Postgres, and host.docker.internal on Caddy so it can
reverse-proxy staging containers published on the host loopback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 22:03:38 +02:00
Ullrich Schäfer
de96c85172
Merge pull request #355 from trails-cool/stigi/index-capabilities
Index wahoo-route-push, demo-activity-bot, background-jobs
2026-05-03 21:43:35 +02:00
Ullrich Schäfer
cc1d5194b8
Index wahoo-route-push, demo-activity-bot, background-jobs in CAPABILITIES
Catch-up entries: wahoo-route-push (added in this PR's archive),
demo-activity-bot and background-jobs (created by PR #353 but missed
in the index). Renamed "Imports" group to "Imports & exports" so the
push capability has a sensible home.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:40:10 +02:00
Ullrich Schäfer
795ec2215c
Merge pull request #354 from trails-cool/stigi/archive-wahoo-route
Archive wahoo-route-update
2026-05-03 21:39:36 +02:00
Ullrich Schäfer
6f6010cb51
Archive wahoo-route-update
Fold the wahoo-route-update delta into openspec/specs/wahoo-route-push/spec.md
(POST→PUT logic with 404 fallback, stable external_id, push-status UI) and
move the change directory to openspec/changes/archive/. Task 4.3 (Playwright
E2E) skipped — contract is fully covered by unit/integration tests in
wahoo.test.ts and pushes.server.test.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:36:15 +02:00
Ullrich Schäfer
a62b249aed
Merge pull request #352 from trails-cool/dependabot/npm_and_yarn/production-6f66162dcb
Bump the production group with 19 updates
2026-05-03 21:30:01 +02:00
Ullrich Schäfer
84cf0490d2
Merge branch 'main' into dependabot/npm_and_yarn/production-6f66162dcb 2026-05-03 21:26:49 +02:00
Ullrich Schäfer
16b82c6348
Merge pull request #353 from trails-cool/stigi/archivable-specs
Archive demo-activity-bot, pg-boss-background-jobs, configurable-demo-persona
2026-05-03 21:20:50 +02:00
Ullrich Schäfer
91e80ace36
Archive demo-activity-bot, pg-boss-background-jobs, configurable-demo-persona
Fold completed deltas into main specs (activity-feed, route-management,
infrastructure, planner-session), add new background-jobs and
demo-activity-bot capability specs, and move the three change dirs to
openspec/changes/archive/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:17:39 +02:00
dependabot[bot]
30ea93ae35 [github-actions] pnpm dedupe 2026-05-03 08:34:27 +00:00
dependabot[bot]
eec904971d
Bump the production group with 19 updates
Bumps the production group with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.17` | `55.0.19` |
| [@sentry/vite-plugin](https://github.com/getsentry/sentry-javascript-bundler-plugins) | `5.2.0` | `5.2.1` |
| [eslint](https://github.com/eslint/eslint) | `10.2.1` | `10.3.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `29.0.2` | `29.1.1` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.4` | `17.0.6` |
| [turbo](https://github.com/vercel/turborepo) | `2.9.6` | `2.9.8` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [jose](https://github.com/panva/jose) | `6.2.2` | `6.2.3` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.6` | `8.0.7` |
| [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.4.2` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.10` | `5.2.13` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.4.0` | `3.4.1` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.9.1` | `8.10.0` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.28` | `55.0.30` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.24` | `55.0.26` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.20` | `55.0.22` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.18.0` | `12.18.2` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.50.0` | `10.51.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.49.0` | `10.51.0` |


Updates `expo` from 55.0.17 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `@sentry/vite-plugin` from 5.2.0 to 5.2.1
- [Release notes](https://github.com/getsentry/sentry-javascript-bundler-plugins/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript-bundler-plugins/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript-bundler-plugins/compare/5.2.0...5.2.1)

Updates `eslint` from 10.2.1 to 10.3.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.1...v10.3.0)

Updates `jsdom` from 29.0.2 to 29.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.0.2...v29.1.1)

Updates `react-i18next` from 17.0.4 to 17.0.6
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.4...v17.0.6)

Updates `turbo` from 2.9.6 to 2.9.8
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.9.6...v2.9.8)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.1/packages/typescript-eslint)

Updates `jose` from 6.2.2 to 6.2.3
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.2...v6.2.3)

Updates `nodemailer` from 8.0.6 to 8.0.7
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.6...v8.0.7)

Updates `zod` from 4.3.6 to 4.4.2
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.2)

Updates `@gorhom/bottom-sheet` from 5.2.10 to 5.2.13
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.10...v5.2.13)

Updates `@sentry/cli` from 3.4.0 to 3.4.1
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.4.0...3.4.1)

Updates `@sentry/react-native` from 8.9.1 to 8.10.0
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.9.1...8.10.0)

Updates `expo-dev-client` from 55.0.28 to 55.0.30
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.24 to 55.0.26
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-notifications` from 55.0.20 to 55.0.22
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `pg-boss` from 12.18.0 to 12.18.2
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.0...12.18.2)

Updates `@sentry/node` from 10.50.0 to 10.51.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.50.0...10.51.0)

Updates `@sentry/react` from 10.49.0 to 10.51.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.49.0...10.51.0)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/vite-plugin"
  dependency-version: 5.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: eslint
  dependency-version: 10.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: jose
  dependency-version: 6.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: zod
  dependency-version: 4.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-03 08:33:39 +00:00
Ullrich Schäfer
0254b2afd7
Merge pull request #351 from trails-cool/stigi/wahoo-route-update
Apply wahoo-route-update: PUT on re-push
2026-05-01 12:12:21 +02:00
Ullrich Schäfer
db3eeed60f
Apply wahoo-route-update: PUT on re-push instead of duplicate POST
Re-pushing an edited route to Wahoo now updates the existing remote
route via PUT against the stored remote_id instead of POSTing a new
copy. external_id drops the version suffix and identifies the logical
route. sync_pushes is keyed by (user, route, provider) and tracks
last_pushed_version for the "local newer" UI state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 12:08:16 +02:00
Ullrich Schäfer
4853a116ff
Merge pull request #350 from trails-cool/stigi/wahoo-openspec
Add openspec changes for Wahoo production cutover and route updates
2026-05-01 11:52:50 +02:00
Ullrich Schäfer
6a752e4933
Add openspec changes for Wahoo production cutover and route updates
- wahoo-production-cutover: ops checklist for moving the Wahoo Cloud
  API integration from sandbox to production tier (new app
  registration, logo upload, forced reauthorization).
- wahoo-route-update: switch the route push pipeline from POST-per-
  version to POST-then-PUT so re-pushing an edited route updates the
  existing Wahoo route instead of creating a duplicate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 11:37:42 +02:00
Ullrich Schäfer
7752cea7d0
Merge pull request #349 from trails-cool/stigi/wahoo-data-uri 2026-05-01 10:57:43 +02:00
Ullrich Schäfer
5ea7342f4e
Send Wahoo route file as data URI, not raw base64
Wahoo's POST /v1/routes expects route[file] as
'data:application/vnd.fit;base64,<base64>'. We were sending raw
base64, which Wahoo silently discarded — the route would appear in
the user's list with metadata (distance, ascent come from request
fields) but file.url stayed null and the Wahoo app rendered no track.
Confirmed by re-fetching route 50197876 via GET /v1/routes/:id.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:29:43 +02:00
Ullrich Schäfer
8349b9492b
Merge pull request #348 from trails-cool/stigi/fit-course-capabilities
Fix FIT Course capabilities and lap duration
2026-05-01 10:28:26 +02:00
Ullrich Schäfer
dbba1c1607
Fix FIT Course capabilities and lap duration
The Course message advertised capabilities=0x04 (time only), telling
consumers the course had no position data. Wahoo accepted the route
(distance/elevation come from request fields) but rendered an empty
map. Set capabilities to valid|distance|position (0x1A).

Also write a real lap totalElapsedTime/totalTimerTime derived from the
1Hz record timestamps; a zero-duration lap can be rejected as malformed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:24:50 +02:00
Ullrich Schäfer
2865ec748f
Merge pull request #347 from trails-cool/stigi/wahoo-routes-read
Request routes_read Wahoo scope
2026-05-01 10:18:55 +02:00
Ullrich Schäfer
8b1d473902
Request routes_read Wahoo scope
GET /v1/routes/:id needs routes_read; routes_write only covers writes.
Existing connections will need to reauthorize to gain the new scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:18:13 +02:00
Ullrich Schäfer
8da3001e64
Merge pull request #346 from trails-cool/stigi/wahoo-revoke
Revoke Wahoo tokens on disconnect; surface OAuth errors
2026-05-01 10:17:05 +02:00
Ullrich Schäfer
b60cd96736
Revoke Wahoo tokens on disconnect; surface OAuth errors
Disconnecting a sync provider now calls the provider's revoke endpoint
(DELETE /v1/permissions for Wahoo) before dropping the local row, so
tokens don't accumulate against Wahoo's per-(app,user) cap. The token
exchange now classifies Wahoo's "Too many unrevoked access tokens" 400
as a distinct OAuthError code, and the connections settings page shows
a localized banner for that and other connect failures instead of a
silent ?error=sync_failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 10:03:50 +02:00
Ullrich Schäfer
d673860fb5
Merge pull request #345 from trails-cool/stigi/wahoo-form-encoded
Use form-encoded bodies for Wahoo OAuth token requests
2026-05-01 09:30:33 +02:00
Ullrich Schäfer
fee27f1cd6
Use form-encoded bodies for Wahoo OAuth token requests
Wahoo's /oauth/token endpoint returns 400 for JSON bodies. OAuth 2.0
requires application/x-www-form-urlencoded for token requests; switch
exchangeCode and refreshToken to URLSearchParams. Also include the
response body in the thrown error so future failures are diagnosable
without scraping container logs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:29:56 +02:00
Ullrich Schäfer
357606ea21
Merge pull request #344 from trails-cool/stigi/fix-oauth-return
Fix OAuth callback returnTo after settings split
2026-05-01 09:23:20 +02:00
Ullrich Schäfer
70a1387998
Fix OAuth callback returnTo after settings split
The connect loader was setting state to a raw user.id, but the callback
parses state as base64-JSON via decodeOAuthState and falls back to {}
when the parse fails. That sent users back to /settings, which now
redirects to /settings/profile, hiding the freshly saved connection on
a different tab.

Encode the state as a proper PushOAuthState with returnTo set to
/settings/connections so the callback lands on the page that initiated
the flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 09:20:19 +02:00
Ullrich Schäfer
b0f8be2765
Merge pull request #341 from trails-cool/stigi/archive-wahoo-push
Archive wahoo-route-push change
2026-05-01 08:17:04 +02:00
Ullrich Schäfer
add16c398f
Merge branch 'main' into stigi/archive-wahoo-push 2026-05-01 08:13:47 +02:00
Ullrich Schäfer
e7b00e920a
Merge pull request #342 from trails-cool/stigi/fix-dockerfile-fit
Add packages/fit to journal and planner Dockerfiles
2026-05-01 08:13:32 +02:00
Ullrich Schäfer
c89c5798bb
Add packages/fit to journal and planner Dockerfiles
The deps stage was missing the COPY for packages/fit/package.json, so
@trails-cool/fit was absent from the workspace at install time and
@garmin/fitsdk could not be resolved during the journal build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:10:15 +02:00
Ullrich Schäfer
b9c559469a
Archive wahoo-route-push change
Move completed wahoo-route-push change to archive and sync delta specs:
update wahoo-import for routes_write scope, add new wahoo-route-push capability.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 07:52:34 +02:00
Ullrich Schäfer
405f434d1a
Merge pull request #340 from trails-cool/stigi/wahoo-push-ui
Add Send to Wahoo UI, i18n, and privacy disclosure
2026-05-01 07:47:53 +02:00
Ullrich Schäfer
c607ceaf1f
Merge pull request #339 from trails-cool/stigi/wahoo-push-action
Add Wahoo push action route + OAuth callback resume
2026-05-01 07:47:39 +02:00
Ullrich Schäfer
ba007104eb
Merge pull request #338 from trails-cool/stigi/wahoo-push-provider
Add Wahoo pushRoute and SyncProvider push interface
2026-05-01 07:47:16 +02:00
Ullrich Schäfer
77bca46c5f
Merge pull request #337 from trails-cool/stigi/wahoo-push-db
Add sync_pushes table + granted_scopes column
2026-05-01 07:47:03 +02:00
Ullrich Schäfer
76c3e49de2
Add Send to Wahoo UI, i18n, and privacy disclosure
Route detail page now renders one of three states for the owner of a
route with a Wahoo connection:

- "Send to Wahoo" button + privacy tooltip when no successful push
  exists for the current version, plus the inline last-error blurb
  if the previous attempt failed
- "Sent to Wahoo on <date>" pill when sync_pushes has a pushedAt
  for the current version
- Banner row at the top mapping the ?push= and ?code= query params
  the action route appends to user-facing copy

i18n strings cover all 8 banner states (success, needs_permission,
no_connection, no_geometry, validation, rate_limit, token_expired,
generic) in English and German.

Privacy disclosure lives at /legal/privacy (apps/journal/app/routes/
legal.privacy.tsx), the in-app surface this repo uses instead of a
docs/privacy.md manifest. The new bullet declares that route
geometry, name, and description are transmitted to Wahoo on opt-in
via the Send to Wahoo button.

E2E tests for the push and re-auth flows (tasks 9.4/9.5) are
deferred — they need a server-side Wahoo mock harness that doesn't
exist yet. Slice-4 unit tests cover the pipeline against the
provider mock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:44:50 +02:00
Ullrich Schäfer
a9c0093877
Add Wahoo push action route + OAuth callback resume
Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:

- resolves the latest route_versions row and uses that GPX (not
  routes.gpx) so the bytes Wahoo gets match the snapshot the user
  sees
- short-circuits on (user, route, version, provider) idempotency:
  a successful prior push returns the existing remote_id without
  re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
  getAuthUrl with a base64url-encoded state carrying pushAfter +
  returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
  retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
  update on retry) so the UI can show success/failure state

The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.

Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:41:24 +02:00
Ullrich Schäfer
5b0bf40b97
Add Wahoo pushRoute and SyncProvider push interface
Extends the SyncProvider interface with an optional pushRoute method
plus PushRoutePayload, PushRouteResult, and a typed PushError so the
slice-4 action route can map error codes to user-facing copy without
parsing HTTP statuses itself.

Wahoo provider:
- adds routes_write to the requested OAuth scope set
- implements pushRoute: base64-encodes the FIT, builds the form body
  Wahoo's POST /v1/routes expects, parses the remote id out of the
  response, and throws typed PushError on 401/403/422/429/5xx
- saveConnection now persists the requested scopes as grantedScopes
  on exchangeCode (Wahoo doesn't return a scope field, so the
  requested set is the granted set)

Token refresh on 401 is deferred to the slice-4 action route — same
pattern as the existing webhook handler in this repo, which does
inline refresh rather than wrapping every call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:36:45 +02:00
Ullrich Schäfer
19f4c45c74
Merge pull request #336 from trails-cool/stigi/wahoo-push-apply
Add @trails-cool/fit GPX→FIT Course encoder
2026-04-30 22:35:31 +02:00
Ullrich Schäfer
c7a09e865d
Add sync_pushes table and granted_scopes column
sync_pushes tracks outbound route pushes to providers, keyed by
(user_id, route_id, route_version, provider). Successful rows hold
the remote_id; failed rows hold the error and can be retried in
place. The unique index makes idempotent push trivial.

granted_scopes records the OAuth scope set we requested at
exchangeCode time. Wahoo doesn't return a scope field in token
responses and grants scopes all-or-nothing, so the requested set is
the source of truth. Defaults to an empty array, which means any
pre-existing connection will be flagged as scope-mismatched on the
first routes_write push — the intended UX for the slice 3 re-auth
flow.

Adjusts tasks 2.3/2.4 in the spec to match repo reality: this
project runs `drizzle-kit push --force` schema-first, with no
checked-in migrations directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:33:43 +02:00
Ullrich Schäfer
8ba5554a67
Add @trails-cool/fit package with GPX→FIT Course encoder
Wraps @garmin/fitsdk to emit FIT Course files from GPX, the binary
format Wahoo's POST /v1/routes API requires. Server-side only — the
~1 MB SDK never ships to the planner browser bundle.

Round-trip tests use fit-file-parser as an independent oracle and
assert lat/lon parity within 1e-4 deg and altitude within 0.5 m
across short flat, alpine, multi-day, and single-point fixtures.

Updates design.md decision #1: the original hand-rolled-encoder plan
was justified largely by ESM friction in the Garmin SDK, but as of
v21.202.0 the SDK is pure ESM with zero deps. Wrapping it saves us
~400 LOC of binary plumbing and ongoing maintenance against future
FIT spec updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:31:37 +02:00
Ullrich Schäfer
a45a3808d9
Merge pull request #335 from trails-cool/stigi/wahoo-route-sync
Propose wahoo-route-push: send planned routes to Wahoo head units
2026-04-30 22:08:28 +02:00
Ullrich Schäfer
4a97ef6a10
Fix wahoo-route-push spec: correct schema path and id types
Update sync_pushes schema to use text ids matching the existing journal
tables, and fix the schema path references to packages/db/src/schema/journal.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 22:03:28 +02:00
Ullrich Schäfer
efe70c2c3a Propose wahoo-route-push: send planned routes to a user's Wahoo account
Wahoo's Cloud API exposes POST /v1/routes (scope routes_write), which
syncs to the Wahoo App and to ELEMNT/BOLT/ROAM head units. This change
proposes the push pipeline: GPX -> FIT Course (new @trails-cool/fit
package) -> Wahoo, with sync_pushes for idempotency, an explicit
re-auth flow for the new scope, and a "Send to Wahoo" affordance on
the route detail page. Read-only Wahoo import is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:38:14 +02:00
Ullrich Schäfer
2b1ab3f019
Merge pull request #334 from trails-cool/expand-bruno-flavor
Expand Bruno's content pools for more variety
2026-04-26 21:57:06 +02:00
Ullrich Schäfer
e9b073c9f9 Expand Bruno's content pools for more variety
Roughly tripled the demo persona's name + description pools so the
demo bot's daily output doesn't feel canned within a few weeks of
watching it. Same Bruno: dog-park-inspector deadpan, Berlin-set,
absurd bureaucratic tone. New entries grouped into clear categories
inside the file (more Berlin neighborhoods, time-of-day variants,
bureaucratic-deadpan one-liners, specific events, wildlife
encounters, stick + tennis-ball lore, self-aware moments, Berlin
weather) so it's easy to add to one bucket without rewriting the
whole list.

Counts before → after:
- en names:        12 → 35
- de names:        12 → 35
- en descriptions: 10 → 30
- de descriptions: 10 → 30

No schema or behavior change. Tests unchanged — the persona schema
still parses; the existing pool-shape-agnostic tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:53:50 +02:00
Ullrich Schäfer
86e75cb991
Merge pull request #333 from trails-cool/spec-drift-catchup
Spec drift catch-up: URLs, settings split aftermath, navbar shape
2026-04-26 21:22:30 +02:00
Ullrich Schäfer
acb92b32fa Spec drift catch-up: URLs, settings split, navbar shape
Outcome of today's `/spec-drift-review`. Concentrated on the
high-and-medium-severity items; low-severity wording left for the
next per-feature change to pick up naturally.

High-severity URL fixes (specs were actively misleading):

- account-management — email-change verification URL was specced as
  `/auth/verify-email-change?token=...`; the actual route is
  `/auth/verify?email-change=1&token=...` (see auth.verify.tsx:8).
  A reader implementing against the old wording would build a link
  that 404s.
- observability — "Both apps SHALL expose a /metrics endpoint" was
  half right: the planner is at /metrics, but the journal exposes
  /api/metrics. The infrastructure spec already had the right URLs;
  the observability spec disagreed with itself. Now both correct,
  with a one-line note explaining the per-app split.

Medium-severity wording drift (Stream E aftermath):

- profile-settings, account-management, connected-services — all
  three said "the settings page SHALL include a [...] section",
  which described the old single-scrollable settings page. Stream E
  (PR #323) split /settings into four sub-pages
  (/settings/{profile,account,security,connections}); rewording each
  spec to point at its specific sub-page. The API endpoints
  (/api/settings/*) and behavior are unchanged.
- authentication-methods — passkey add/delete previously said "via
  the settings page"; now specifically /settings/security.

Code drift fixed inline:

- apps/journal/app/routes/auth.verify.tsx — after a successful
  email change, the redirect was going to `/settings#account` (an
  anchor on the OLD single-scrollable settings page). Stream E
  retired that page; the right destination now is
  `/settings/account`. Without this the user would land on
  /settings/profile (which is what /settings redirects to) instead
  of the page that just changed.

sse-broker spec wording:

- The "no buffering tweaks in the Caddy reverse_proxy block"
  scenario asserted the entry was "a plain `reverse_proxy
  journal:3000`". After PR #329 the journal block has
  `lb_try_duration 30s` / `lb_try_interval 250ms` — neither affects
  streaming, so SSE still works, but the spec's "plain" language
  was no longer literally true. Reworded to forbid only
  buffering-related directives; explicitly call out that
  retry-on-restart directives like lb_try_duration are fine.

Navbar consolidation (journal-landing):

- The shipped navbar's full shape was scattered: notifications said
  "navbar has a bell," explore said "navbar has an Explore entry,"
  but no spec described the avatar dropdown, the primary-nav
  cluster (Feed/Routes/Activities), or the mobile drawer (Stream
  C / PR #324). Added a "Top navbar shape" requirement to
  journal-landing covering all of it — anonymous vs signed-in,
  desktop vs mobile, dropdown contents, drawer behavior. The
  per-feature specs (notifications, explore) still own their own
  badges/entries; this requirement just says what the whole
  cluster looks like.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:19:02 +02:00
Ullrich Schäfer
201342bd57
Merge pull request #332 from trails-cool/cd-apps-reload-caddy
cd-apps: reload Caddy after deploy so Caddyfile changes take effect
2026-04-26 20:13:09 +02:00
Ullrich Schäfer
7e28e72c29 cd-apps: reload Caddy after deploy so Caddyfile changes take effect
cd-apps already scp's `infrastructure/Caddyfile` to the server
alongside docker-compose.yml — but the running Caddy container
doesn't auto-pick-up config changes. cd-infra is the workflow that
calls `caddy reload`, and it only triggers on `infrastructure/`
paths. So any Caddyfile edit shipped through cd-apps (e.g. the
`lb_try_duration` block in PR #329, deployed as part of an apps
push) sits on disk unapplied until the next cd-infra run.

This was real today: PR #329 added `lb_try_duration 30s` to silence
deploy-time 502s. PR #331 (a vite.config edit, apps path) merged
right after and triggered cd-apps, which copied the new Caddyfile
but didn't reload Caddy. The very next planner restart in that
deploy promptly produced three 502s — the exact thing #329 was
supposed to prevent. We applied the reload manually via SSH after
the fact.

This commit makes that reload happen on every cd-apps deploy. The
operation is idempotent (Caddy validates first, swaps live, no
downtime) so doing it even when Caddyfile is unchanged costs
nothing. `|| true` keeps the deploy from failing if Caddy is
unhealthy at deploy time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:09:43 +02:00
Ullrich Schäfer
1583028d95
Merge pull request #331 from trails-cool/warmup-vite-deps-for-e2e
Pre-warm Vite dep discovery on the journal dev server
2026-04-26 19:55:05 +02:00
Ullrich Schäfer
97cc759dca Pre-warm Vite dep discovery for the journal dev server
Adds `server.warmup.clientFiles` so Vite eagerly transforms the
journal entry + every page route at server startup. Without it,
the first visit to a previously-unseen route triggers Vite's
"new dependencies optimized → reloading" path, which is a full
page reload — fine as a one-time blip in everyday dev, painful
when an e2e run is hitting many routes back-to-back on a fresh
server (each new route reload races with whatever the test was
doing).

This is a partial improvement, not a complete fix, for the local
auth e2e flakiness investigated earlier today. The deeper issue
there is a separate React-hydration-vs-Playwright-click race in
dev mode (production CI doesn't see it because production JS
hydrates faster). That needs a different fix at the test/fixture
layer; this commit just removes one source of noise from the
investigation by eliminating the dep-discovery reload.

Glob picks up `.tsx` files only — `.ts` route handlers under
app/routes/ are server-only API endpoints and Vite refuses to
transform them for the client (errors "Server-only module
referenced by client"). Page components alone drive the
dep-discovery we care about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:51:39 +02:00
Ullrich Schäfer
ba3a619160
Merge pull request #330 from trails-cool/document-https-dev-special-cases
Document why HTTPS=1 dev exists, and the one case that still needs it
2026-04-26 12:30:12 +02:00
Ullrich Schäfer
1f15330961 Document why HTTPS=1 dev exists, and the one case that still needs it
Most contributors don't need HTTPS=1 locally — plain HTTP is the
default and the right choice for everything except Wahoo OAuth
callback testing. WebAuthn (passkeys), magic links, sessions, the
Terms gate, SSE all work over HTTP because the WebAuthn spec treats
localhost as a secure context regardless of scheme. CI proves the
point: the e2e suite runs over plain HTTP and passes cleanly.

The original HTTPS=1 plumbing landed in 20b91ef (2026-04-05) bundled
into a Wahoo import fix, with no inline rationale. Once you've
forgotten the reason it tends to leak into the default workflow,
which then breaks the local e2e suite (Playwright always uses HTTP
baseURL; an https ORIGIN env mismatches what it sends) and creates
unnecessary divergence from CI.

Documenting the single legitimate use case so the next contributor
(or future-me) doesn't have to re-derive it from git blame:

- apps/journal/vite.config.ts — expanded the comment near the
  basic-ssl plugin to spell out:
  * What works on HTTP (everything except Wahoo)
  * What needs HTTPS=1 (Wahoo OAuth specifically)
  * The norm: don't add new HTTPS-only paths without writing them
    down here too, so the assumption stays auditable
- apps/journal/.env.example — new tracked file showing the env vars
  the journal app reads, with `ORIGIN=https://localhost:3000`
  marked as HTTPS-only and accompanied by an explanation of the
  ORIGIN/Playwright mismatch trap. Future contributors don't have
  to discover this through a failing e2e run.
- CLAUDE.md — new "Local HTTPS dev (rare)" subsection under
  Development Commands. Includes the exact command for Wahoo testing
  (`HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter
  @trails-cool/journal dev`), the turbo-doesn't-pass-HTTPS gotcha,
  and the rule of thumb: don't set ORIGIN in your .env unless you
  also always run with HTTPS=1.

No code/behavior changes; documentation only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:26:58 +02:00
Ullrich Schäfer
99ca7b0ab4
Merge pull request #327 from trails-cool/dependabot/npm_and_yarn/vite-8.0.10
Bump vite from 7.3.2 to 8.0.10
2026-04-26 12:09:08 +02:00
dependabot[bot]
b891029069
Bump vite from 7.3.2 to 8.0.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.0.10.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.10/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 10:02:46 +00:00
Ullrich Schäfer
f1548c2df9
Merge pull request #326 from trails-cool/dependabot/npm_and_yarn/production-6dcdd079e8
Bump the production group with 28 updates
2026-04-26 12:00:52 +02:00
Ullrich Schäfer
fdb2baada0
Merge branch 'main' into dependabot/npm_and_yarn/production-6dcdd079e8 2026-04-26 11:57:54 +02:00
Ullrich Schäfer
c0d1dafdff
Merge pull request #329 from trails-cool/deploy-no-502-and-fix-annotation
Stop caddy-502-rate alert firing on every deploy
2026-04-26 11:57:16 +02:00
Ullrich Schäfer
55c9154f05 Revert cd-apps annotation path: GRAFANA_SERVICE_TOKEN is in .env, not app.env
The token lives in `secrets.infra.env`, which `cd-infra.yml` merges
together with `secrets.app.env` into the server's
`/opt/trails-cool/.env`. The cd-apps workflow's own `app.env`
intentionally does NOT carry the token (apps don't need it at
runtime), so the original `grep ... .env` was correct. My earlier
edit in this branch swapped the path to `app.env` and would have
broken the annotation hook the moment it actually worked.

Restored `.env` and updated the inline comment to make the file
ownership explicit (cd-infra populates it; cd-apps reads it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:53:55 +02:00
Ullrich Schäfer
5c4b6fd9af Stop the caddy-502-rate alert firing on every deploy
The journal/planner deploy in cd-apps.yml does `docker compose up -d
journal planner`, which stops the old container and starts the new
one — Caddy keeps forwarding requests during the ~10–30s gap and
returns 502s. The caddy-502-rate alert (threshold > 0 for 2m)
correctly trips, every time.

Two production changes plus a long-broken workflow detail:

- infrastructure/Caddyfile — add `lb_try_duration 30s` /
  `lb_try_interval 250ms` to the journal and planner reverse_proxy
  blocks. Caddy now holds and retries the upstream for up to 30s
  during a restart instead of 502'ing immediately. Real outages
  (upstream unreachable longer than 30s) still 502 and the alert
  still fires for those.
- infrastructure/grafana/provisioning/alerting/alerts.yml — add a
  comment documenting why caddy-502-rate stays at threshold > 0:
  with lb_try_duration in front of it, the alert no longer
  conflates "deploy in flight" with "real outage."
- .github/workflows/cd-apps.yml — fix a long-silent bug: the
  Grafana deploy-annotation step was reading
  GRAFANA_SERVICE_TOKEN from `.env`, but the secrets file we scp
  to /opt/trails-cool is named `app.env`. The token check failed
  silently and the curl was being skipped on every deploy.
  Switching to `app.env` so deploys actually annotate Grafana.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:51:13 +02:00
Ullrich Schäfer
153f133093
Merge pull request #328 from trails-cool/explore-include-demo-bot
Include demo persona on /explore
2026-04-26 11:46:39 +02:00
Ullrich Schäfer
8d7c48d8c1 Include the demo persona on /explore so users can follow it
The directory was filtering out the demo persona on the rationale
that "the demo bot is not a real user and should not appear in real
discovery." That's exactly backwards — the whole point of having a
demo persona is to give new users a follow target so the platform
doesn't feel empty when they arrive. Hiding the bot from the
discovery surface defeats its purpose.

Concretely on flagship: only one local user (ullrich) was visible
on /explore today, even though Bruno (the demo persona) is
public-by-default and posting public activities. After this change
both appear; Bruno carries a small "🐕 Demo account" badge next to
his display name so viewers know what they're following.

- apps/journal/app/lib/explore.server.ts — drop the
  ne(users.username, persona.username) clause from exclusionFilters.
  The demo persona is now treated like any other public user. Banned/
  suspended scaffolding stays for forward-compat.
- apps/journal/app/routes/explore.tsx — loader computes isDemoUser
  per row (cheap, just username comparison against
  loadPersona().username). DirectoryRow renders the demo badge inline
  with the display name, matching the existing pattern on
  /users/:username.
- openspec/specs/explore/spec.md — updated the "Excluded users"
  requirement to remove the demo persona, replaced the "demo
  excluded" scenario with "demo appears with badge", and updated
  the "Active recently" requirement + scenarios accordingly.
- apps/journal/app/lib/explore.integration.test.ts — flipped the
  demo-persona test from "is excluded" to "is included".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:43:24 +02:00
dependabot[bot]
4e32189250 [github-actions] pnpm dedupe 2026-04-26 08:38:21 +00:00
dependabot[bot]
c65b909aef
Bump the production group with 28 updates
Bumps the production group with 28 updates:

| Package | From | To |
| --- | --- | --- |
| [expo](https://github.com/expo/expo/tree/HEAD/packages/expo) | `55.0.15` | `55.0.17` |
| [i18next](https://github.com/i18next/i18next) | `26.0.6` | `26.0.8` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.2` | `8.59.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.5` | `8.0.6` |
| [@expo/metro-runtime](https://github.com/expo/expo) | `55.0.9` | `55.0.10` |
| [@gorhom/bottom-sheet](https://github.com/gorhom/react-native-bottom-sheet) | `5.2.9` | `5.2.10` |
| [@maplibre/maplibre-react-native](https://github.com/maplibre/maplibre-react-native) | `11.0.0` | `11.0.2` |
| [@sentry/cli](https://github.com/getsentry/sentry-cli) | `3.3.5` | `3.4.0` |
| [@sentry/react-native](https://github.com/getsentry/sentry-react-native) | `8.8.0` | `8.9.1` |
| [expo-constants](https://github.com/expo/expo/tree/HEAD/packages/expo-constants) | `55.0.14` | `55.0.15` |
| [expo-dev-client](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-client) | `55.0.27` | `55.0.28` |
| [expo-dev-menu](https://github.com/expo/expo/tree/HEAD/packages/expo-dev-menu) | `55.0.23` | `55.0.24` |
| [expo-file-system](https://github.com/expo/expo/tree/HEAD/packages/expo-file-system) | `55.0.16` | `55.0.17` |
| [expo-linking](https://github.com/expo/expo/tree/HEAD/packages/expo-linking) | `55.0.13` | `55.0.14` |
| [expo-notifications](https://github.com/expo/expo/tree/HEAD/packages/expo-notifications) | `55.0.19` | `55.0.20` |
| [expo-router](https://github.com/expo/expo/tree/HEAD/packages/expo-router) | `55.0.12` | `55.0.13` |
| [expo-splash-screen](https://github.com/expo/expo/tree/HEAD/packages/expo-splash-screen) | `55.0.18` | `55.0.19` |
| [expo-system-ui](https://github.com/expo/expo/tree/HEAD/packages/expo-system-ui) | `55.0.15` | `55.0.16` |
| [pg-boss](https://github.com/timgit/pg-boss) | `12.15.0` | `12.18.0` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.14.1` | `7.14.2` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.14.1` | `7.14.2` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.14.1` | `7.14.2` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.49.0` | `10.50.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.48.0` | `10.49.0` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.2.2` | `4.2.4` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.14.1` | `7.14.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.2.2` | `4.2.4` |


Updates `expo` from 55.0.15 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo)

Updates `i18next` from 26.0.6 to 26.0.8
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.6...v26.0.8)

Updates `typescript-eslint` from 8.58.2 to 8.59.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint)

Updates `vitest` from 4.1.4 to 4.1.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

Updates `nodemailer` from 8.0.5 to 8.0.6
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.5...v8.0.6)

Updates `@expo/metro-runtime` from 55.0.9 to 55.0.10
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits)

Updates `@gorhom/bottom-sheet` from 5.2.9 to 5.2.10
- [Release notes](https://github.com/gorhom/react-native-bottom-sheet/releases)
- [Changelog](https://github.com/gorhom/react-native-bottom-sheet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gorhom/react-native-bottom-sheet/compare/v5.2.9...v5.2.10)

Updates `@maplibre/maplibre-react-native` from 11.0.0 to 11.0.2
- [Release notes](https://github.com/maplibre/maplibre-react-native/releases)
- [Changelog](https://github.com/maplibre/maplibre-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-react-native/compare/v11.0.0...v11.0.2)

Updates `@sentry/cli` from 3.3.5 to 3.4.0
- [Release notes](https://github.com/getsentry/sentry-cli/releases)
- [Changelog](https://github.com/getsentry/sentry-cli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-cli/compare/3.3.5...3.4.0)

Updates `@sentry/react-native` from 8.8.0 to 8.9.1
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/8.8.0...8.9.1)

Updates `expo-constants` from 55.0.14 to 55.0.15
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-constants)

Updates `expo-dev-client` from 55.0.27 to 55.0.28
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-client)

Updates `expo-dev-menu` from 55.0.23 to 55.0.24
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-dev-menu)

Updates `expo-file-system` from 55.0.16 to 55.0.17
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-file-system)

Updates `expo-linking` from 55.0.13 to 55.0.14
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-linking)

Updates `expo-notifications` from 55.0.19 to 55.0.20
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-notifications)

Updates `expo-router` from 55.0.12 to 55.0.13
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-router)

Updates `expo-splash-screen` from 55.0.18 to 55.0.19
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-splash-screen)

Updates `expo-system-ui` from 55.0.15 to 55.0.16
- [Changelog](https://github.com/expo/expo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expo/expo/commits/HEAD/packages/expo-system-ui)

Updates `pg-boss` from 12.15.0 to 12.18.0
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.15.0...12.18.0)

Updates `@react-router/dev` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.14.2/packages/react-router-dev)

Updates `@react-router/node` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.14.2/packages/react-router-node)

Updates `@react-router/serve` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.14.2/packages/react-router-serve)

Updates `@sentry/node` from 10.49.0 to 10.50.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.49.0...10.50.0)

Updates `@sentry/react` from 10.48.0 to 10.49.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.48.0...10.49.0)

Updates `@tailwindcss/vite` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-vite)

Updates `react-router` from 7.14.1 to 7.14.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.2/packages/react-router)

Updates `tailwindcss` from 4.2.2 to 4.2.4
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: expo
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: nodemailer
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@expo/metro-runtime"
  dependency-version: 55.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@gorhom/bottom-sheet"
  dependency-version: 5.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@maplibre/maplibre-react-native"
  dependency-version: 11.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/cli"
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react-native"
  dependency-version: 8.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: expo-constants
  dependency-version: 55.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-client
  dependency-version: 55.0.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-dev-menu
  dependency-version: 55.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-file-system
  dependency-version: 55.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-linking
  dependency-version: 55.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-notifications
  dependency-version: 55.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-router
  dependency-version: 55.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-splash-screen
  dependency-version: 55.0.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: expo-system-ui
  dependency-version: 55.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: pg-boss
  dependency-version: 12.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@react-router/dev"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 08:37:27 +00:00
Ullrich Schäfer
b647c493d7
Merge pull request #325 from trails-cool/refresh-ia-doc-after-streams
Refresh IA doc — all six streams shipped
2026-04-26 10:06:31 +02:00
Ullrich Schäfer
c7331ed056 Refresh IA doc — all six streams shipped on 2026-04-26
Streams A, B, C, D, E, F all landed today. This refresh marks them
shipped with their PR numbers, brings the sitemap and navbar
diagrams in sync with the deployed state, and rewrites the
"Notifications vs follow requests" and "Settings" sections to
describe the new structure rather than the historical split.

Snapshot date bumped to "2026-04-26 (post-streams)" so a future
re-read knows what state this snapshot reflects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:03:05 +02:00
Ullrich Schäfer
218389e9f8
Merge pull request #324 from trails-cool/navbar-redesign-avatar-dropdown
Redesign navbar: avatar dropdown + mobile drawer
2026-04-26 09:59:56 +02:00
Ullrich Schäfer
4e2bfb0bbe Redesign navbar with avatar dropdown and mobile drawer
Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.

- New apps/journal/app/components/Avatar.tsx — initials-only avatar
  (no image-upload story yet; the component is the single place to
  add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
  avatar trigger to reveal Profile / Settings / Log Out menuitems.
  Click-outside + Escape-to-close. role="menu" + role="menuitem" for
  a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
  trigger + slide-out drawer at md and below. Contains all primary
  nav (Feed, Explore, Routes, Activities, Notifications) plus the
  account cluster (Profile, Settings, Log Out). Auto-closes on
  navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
  * Brand + primary nav links hide at md and below (drawer covers
    them).
  * Right cluster: bell on every viewport; avatar dropdown on
    desktop, hamburger on mobile.
  * Loader exposes user.displayName so Avatar can compute initials.
  * Drops the now-unused Form import; Form moved into the
    dropdown/drawer components.

E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
  aria-label = displayName||username, then clicks the Log Out
  menuitem. Username assertions in nav switch from getByText to
  getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
  avatar dropdown first, then asserts the Settings menuitem.

i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:56:20 +02:00
Ullrich Schäfer
65e6699dc4
Merge pull request #323 from trails-cool/split-settings-into-pages
Split /settings into 4 sub-pages with a sidebar layout
2026-04-26 09:51:17 +02:00
Ullrich Schäfer
c0c1a53322 Split /settings into 4 sub-pages with a sidebar layout
Stream E from docs/information-architecture.md. The settings page was
a single scrollable list of five concerns; split it into four
deep-linkable sections behind a shared sidebar layout, so each
concern has a stable URL, a focused loader (only fetches what that
section needs), and a meta title that reflects the section.

Sections:
- /settings/profile — display name, bio, profile visibility
- /settings/account — email change + danger-zone account deletion
- /settings/security — passkeys
- /settings/connections — sync providers (Wahoo today)

URL pattern: nested routes under a layout. /settings itself
redirects to /settings/profile so the bare URL still lands somewhere
useful without rendering an empty container.

- apps/journal/app/routes/settings.tsx — converted from a single
  scrollable page to a layout that renders the sidebar nav + Outlet.
- apps/journal/app/routes/settings.{profile,account,security,
  connections,_index}.tsx — new files, each with its own loader and
  meta. _index redirects to /settings/profile.
- apps/journal/app/routes.ts — registers the four children + index
  under the settings layout route.
- packages/i18n/src/locales/{en,de}.ts — new settings.nav.{profile,
  account,security,connections} keys for the sidebar labels.

Specs (profile-settings, account-management, connected-services)
are unchanged — they describe behavior, not URL structure. The
journal-landing spec is also unchanged; the navbar still has a
single "Settings" link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:48:00 +02:00
Ullrich Schäfer
8d60b6d7bc
Merge pull request #322 from trails-cool/archive-explore-and-promote-specs
Archive add-explore-page and promote specs
2026-04-26 09:44:10 +02:00
Ullrich Schäfer
a3b0c6ad56 Archive add-explore-page change and promote specs
Promotes the deltas from openspec/changes/add-explore-page/ into
top-level specs after the implementation landed in #321.

- New top-level spec: openspec/specs/explore/spec.md (paginated local
  user directory at /explore, with the "Active recently" sub-section,
  exclusion rules for private profiles and the demo persona, offset
  pagination, and the navbar entry rule).
- Updated openspec/specs/journal-landing/spec.md with the new
  "Visitor home links to /explore" requirement.
- Updated openspec/CAPABILITIES.md with an entry under Social.
- Moved openspec/changes/add-explore-page/ to
  openspec/changes/archive/2026-04-26-add-explore-page/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:42 +02:00
Ullrich Schäfer
3b1d0d9456
Merge pull request #321 from trails-cool/implement-explore-page
Implement /explore page (Stream F)
2026-04-26 09:38:48 +02:00
Ullrich Schäfer
37f7a349b9 Implement /explore: local user discovery directory
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.

- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
  ordered by MAX(public-activity created_at) DESC NULLS LAST,
  tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
  days), countDirectory, batched countFollowersBatch and
  getFollowStateBatch helpers so the page issues two extra queries
  regardless of page size. Excludes private profiles and the demo
  persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
  queries in parallel; component renders an "Active recently" strip
  (hidden when empty) above the main directory; FollowButton inlines
  per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
  users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
  "Browse who's here →" link to /explore alongside the Planner
  escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
  home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
  (EXPLORE_INTEGRATION=1) integration tests covering inclusion,
  exclusion, ordering, NULLS LAST behavior, "Active recently"
  30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
  private profile is excluded from the directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:24:19 +02:00
Ullrich Schäfer
65f8313f2b
Merge pull request #320 from trails-cool/add-explore-proposal
Propose /explore page: local user discovery directory
2026-04-26 09:19:53 +02:00
Ullrich Schäfer
83df9e3312 Propose /explore page: local user discovery directory
Stream F from docs/information-architecture.md. Closes the gap between
"I want to follow someone on this instance" and "I have a username
from outside the app."

This change is the OpenSpec proposal only — the four artifacts
(proposal.md, design.md, specs/, tasks.md) plus a new top-level spec
target at openspec/specs/explore/. Implementation lands in a follow-up
PR via /opsx:apply.

Key decisions captured in design.md:

- Anonymous access is allowed (the data shown is already public, and
  it's a stronger first-visit experience).
- Directory order: MAX(activities.created_at) DESC NULLS LAST,
  tiebreaker users.id DESC. Recency-of-activity is the simplest signal
  that meaningfully reflects "who's active here right now."
- "Active recently" sub-section: same query, sliced — top 5 users
  with a public activity in the last 30 days, hidden when empty.
- Privacy filter: profile_visibility = 'public' AND not the demo
  persona AND (forward-compat) not banned/suspended.
- No search in v1 — deferred until the user count makes it actually
  useful. /users/<username> already partially solves it.
- Offset pagination (?page=, ?perPage=, capped 100). Cursor
  pagination is the right answer at ~10K users; not now.
- Navbar gets an "Explore" entry for signed-in users; anonymous
  visitors reach /explore via a link on the visitor home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:16:31 +02:00
Ullrich Schäfer
dc7a781e74
Merge pull request #319 from trails-cool/feed-followed-public-toggle
Add Followed/Public toggle to /feed for signed-in users
2026-04-26 09:07:33 +02:00
Ullrich Schäfer
b6d8c621f8 Add Followed/Public toggle to /feed for signed-in users
Stream A from docs/information-architecture.md: signed-in users now
have a single /feed destination with two views — Followed (default,
people they accepted-follow) and Public (instance-wide). Logging in no
longer hides the public instance feed; switching is a query-param flip
that's bookmarkable and SSR-rendered.

- apps/journal/app/routes/feed.tsx — loader reads ?view=, branches
  fetch (listSocialFeed vs listRecentPublicActivities, both already
  exist), passes view to the component. Component renders a tab strip
  at the top using plain <Link>s so the toggle works without JS. Per-
  view <meta> title and empty state. The "see public feed" escape
  from the empty Followed view now points at ?view=public instead of
  /, keeping the user on /feed.
- packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ },
  social.feed.public.{heading,empty}, and social.feed.seePublic
  keys; old social.feed.publicFeedLink renamed to seePublic.
- openspec/specs/social-follows/spec.md — Social activity feed
  requirement extended with the two-view structure, including the
  Public view, the toggle, and the unrecognized-value fallback.
- openspec/specs/activity-feed/spec.md — Instance-wide public
  activity feed requirement notes the Public view of /feed is now a
  consumer alongside the visitor home.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:03:38 +02:00
Ullrich Schäfer
7d1999d037
Merge pull request #318 from trails-cool/drop-feed-button-on-home
Drop redundant Feed button on logged-in home page
2026-04-26 08:47:41 +02:00
Ullrich Schäfer
11635eaead Drop redundant "Feed" button on the logged-in home page
The navbar already has a "Feed" entry that signed-in users see on every
page, including /. The page-header button next to "New Activity"
duplicated that path without adding anything — they pointed at the same
URL, with the navbar entry being the more discoverable of the two.

Stream D from docs/information-architecture.md.

- apps/journal/app/routes/home.tsx — remove the Feed anchor; "New
  Activity" stays as the only header CTA on the personal dashboard.
- openspec/specs/journal-landing/spec.md — retire the "Social feed
  link for signed-in users" requirement that prescribed the now-deleted
  button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:44:08 +02:00
Ullrich Schäfer
34f40d6744
Merge pull request #317 from trails-cool/add-ia-and-spec-drift-skills
Add /ia-review and /spec-drift-review process skills
2026-04-26 08:42:31 +02:00
Ullrich Schäfer
a847ff2e52 Add /ia-review and /spec-drift-review process skills
Captures two recurring review processes as repeatable Claude skills so
they don't have to be reinvented from scratch each time:

- /ia-review: walks the route tables + nav surfaces, builds the
  sitemap, flags drift, and writes/refreshes
  docs/information-architecture.md. Refresh mode preserves the user's
  accumulated decisions and implementation backlog while only
  rewriting the snapshot sections.

- /spec-drift-review: walks every spec in openspec/specs/, compares
  it to shipped code, and produces a categorized drift report
  (high/medium/low severity) plus code-without-spec findings and
  structural suggestions (split/merge/new specs, CAPABILITIES.md
  catch-up). Excludes specs touched by active openspec changes since
  that mismatch is intended in-flight work, not drift.

Also extends the IA doc with the items resolved/deferred during the
review (observations 1-10), three new streams (D: drop redundant Feed
button on logged-in /, E: break Settings apart, F: propose /explore
spec), and an "Open exploration" section for the routes-vs-activities
terminology question.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:41:07 +02:00
Ullrich Schäfer
ad11624c83
Merge pull request #316 from trails-cool/merge-follow-requests-into-notifications
Merge /follows/requests into /notifications as a tabbed inbox
2026-04-26 08:28:24 +02:00
Ullrich Schäfer
0306d90de8 Merge /follows/requests into /notifications as a tabbed inbox
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.

Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 08:24:59 +02:00
Ullrich Schäfer
3cd01fe9c0
Merge pull request #315 from trails-cool/chore/spec-catchup
Spec catchup: drift fixes, account-settings split, notifications archive
2026-04-26 02:06:53 +02:00
Ullrich Schäfer
37073eafd7 Spec catchup: drift fixes, account-settings split, notifications archive
Drift (specs aligned to shipped code):
- social-follows: locked-account access rule for /users/:u/followers and
  /users/:u/following (owner + accepted-follower see; non-followers of
  private get 404). Adds the follow→notification lifecycle requirement.
  Fills the placeholder Purpose.
- public-profiles: counts degrade to plain text (not anchors) for viewers
  who can't see the lists. Cross-references social-follows. Fills the
  placeholder Purpose.
- journal-auth slimmed to cookie session + Terms gate. Auth methods moved
  out (see authentication-methods).

Splits:
- account-settings (14-line stub) deleted, content split into:
  - profile-settings (display name, bio, profile_visibility)
  - account-management (email change with verification, account deletion)
  - connected-services (Wahoo + future external integrations)
- authentication-methods split out of journal-auth: passkeys
  (register/login/add/delete), magic links, 6-digit codes
  (login + register), method toggle on register/login forms,
  dev-console fallback.

New specs:
- sse-broker: /api/events, in-process broker, useUnreadNotifications
  hook, Caddy passthrough, multi-process forward-compat contract.

Archived: notifications change → openspec/changes/archive/2026-04-26-notifications.
Promoted the four delta spec files into top-level specs:
- specs/notifications/ (new capability)
- specs/activity-feed/ (added: public activity fan-out)
- specs/journal-landing/ (added: Notifications navbar entry)
- specs/social-follows/ (added: follow→notification lifecycle)

Added openspec/CAPABILITIES.md grouped index covering all 40 specs with
a Conventions section explaining cross-references, naming, and the
catch-up-vs-change rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 02:02:43 +02:00
Ullrich Schäfer
0530dd3e59
Merge pull request #314 from trails-cool/feat/notifications-cursor-pagination
Cursor-based pagination for /notifications
2026-04-26 01:45:55 +02:00
Ullrich Schäfer
b20c8cca39 Cursor-based pagination for /notifications
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.

Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.

Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:42:32 +02:00
Ullrich Schäfer
2892cf9360
Merge pull request #313 from trails-cool/feat/notifications
Implement notifications + supporting fixes
2026-04-26 01:41:49 +02:00
Ullrich Schäfer
abb754e6a5 Replace waitForLoadState("networkidle") in e2e helpers
The SSE connection to /api/events (added with notifications) keeps the
network in-flight forever, so `networkidle` never resolves. Each save
in the affected helpers timed out at 30s × 3 retries, which both broke
the run and made it suspiciously long.

Switched to explicit waits:
- "Profile saved." text after settings save (notifications + public-content + social)
- "No pending follow requests." after Approve (social.test.ts)
- toBeHidden poll for the Mark all read button (notifications)
- toBeVisible poll for the empty-state copy after Approve (notifications)

These are all the affected `networkidle` call sites in e2e. The
fetcher.Form pattern stays — once the action commits, the page
revalidates and the post-state element appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:37:37 +02:00
Ullrich Schäfer
e61179ab27 Implement notifications + supporting fixes
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.

Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
  paste-friendly), with `[Register Magic Link]` console line in dev so
  the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
  had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
  singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
  synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
  profile route (owner + accepted followers see the list; others 404).
  Profile page renders the count chips as plain spans for viewers who
  can't see the lists, so private profiles don't surface dead links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 01:28:55 +02:00
Ullrich Schäfer
10c03643a5
Merge pull request #312 from trails-cool/chore/archive-social-feed
Archive social-feed
2026-04-26 00:19:55 +02:00
Ullrich Schäfer
4b414eccd0 Archive social-feed
The social layer (local follows, /feed, profile_visibility, locked
accounts) is fully shipped via the social-feed implementation work
plus the locked-account follow-up. Closing out the change.

Pre-archive ticks:
- 1.4: schema migrated on prod via cd-apps drizzle-kit push; column
  + table verified on the running DB.
- 3.3: follower/following counts on /users/:username shipped in #310.
- 7.1: cd-apps drizzle-kit push --force ran; verified post-deploy.
- 7.2: smoke inputs verified (bruno is public on prod, has 17 public
  activities, /users/bruno returns 200). Live click-through is
  operator-discretion; the listSocialFeed query correctness is proven
  by integration tests.
- 7.3 / 7.4: forward-pointers, not deliverables for this change.

One task explicitly deferred:
- 6.2: full activity-creation E2E for the /feed assertion. Equivalent
  coverage at the integration level + the e2e Follow-button +
  visibility tests; not worth wiring an e2e activity-creation helper
  just for this one path.

Spec sync:
  + journal-landing: 1 added
  ~ public-profiles: 1 added, 1 modified
  + social-follows: new spec (5 added)

Move to openspec/changes/archive/2026-04-25-social-feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:19:15 +02:00
Ullrich Schäfer
406d2d3a61
Merge pull request #311 from trails-cool/proposal/notifications
Propose: notifications (closes the locked-account UX loop)
2026-04-26 00:14:31 +02:00
Ullrich Schäfer
95ac79b093 Propose: notifications
Closes the social loop opened by social-feed: a Pending follower has no
way to know their request was approved, and a follower has no signal at
all that someone they follow just posted. Adds a notifications surface
for three v1 event types — follow_request_approved, follow_received,
activity_published — plus a /notifications page, navbar unread count,
and mark-as-read controls.

Capabilities:
- New: notifications (table, page, badge, generation hooks)
- Modified: social-follows (approve + auto-accept emit)
- Modified: activity-feed (public create fans out)
- Modified: journal-landing (nav entry alongside follow-requests)

Design picks:
- Fan-out-on-write for activity_published (1:N) so /notifications is a
  flat single-table query and "mark read" composes trivially. 1:1
  events insert directly. Cost ceiling documented at 10k followers ×
  50 activities/day = 500k/day, still trivial; revisit only if hot.
- Single notifications table with loose subject_id (no per-type FK);
  renderer dereferences by type. Mastodon-style.
- Two distinct nav entries (Follow requests + Notifications). Pending
  is "act on this", notifications is "this happened" — different
  semantics, kept separate.
- Loader-driven unread count (no real-time channel). Real-time is
  deferred.
- 90-day retention for read rows; unread kept indefinitely.

Out of scope: per-type mute preferences, email/push, real-time,
notifications about routes/replies/mentions, federated notifications.
Each tracked as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 00:12:35 +02:00
Ullrich Schäfer
937bde3b0b
Merge pull request #310 from trails-cool/feat/locked-profiles
Locked-account profiles: private = stub + Pending follow flow
2026-04-25 23:54:16 +02:00
Ullrich Schäfer
1219b46ca3 Fix profile-visibility radio selector in e2e (strict-mode collision)
`getByLabel('Public')` matched both radios because the Private radio's
help-text label contains the substring "public" ("…followers see your
public content."). Switch to targeting the input directly by name+value,
which is unambiguous regardless of help-text wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:51:19 +02:00
Ullrich Schäfer
ff09775091 Fix e2e fallout from default-private profile_visibility
Three places assumed the old default-public model:

1. e2e/public-content.test.ts: "profile 404 when user has no public
   content" → updated to assert the new locked-account stub renders
   200 with "This profile is private" and the private route doesn't
   appear. The two follow-on tests (public-route profile, unlisted
   route) now flip the owner to public via a setProfileVisibilityPublic
   helper before the anon-visit assertions, since new users default to
   private.

2. e2e/demo-bot.test.ts: bruno is seeded with profile_visibility =
   'public' on insert (timestamped seed) and the existing-bruno path
   gets a follow-up UPDATE so the test's anon-visitor assertion
   (profile renders, public route is listed) holds regardless of which
   default he was first created under.

3. apps/journal/app/lib/demo-bot.server.ts ensureDemoUser: also pins
   profile_visibility = 'public' on insert. The demo persona is
   discoverable by design — that's the whole point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:46:56 +02:00
Ullrich Schäfer
5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00
Ullrich Schäfer
ede68712a3
Merge pull request #308 from trails-cool/feat/social-follows-and-feed
Implement social-feed: local follows + /feed + profile_visibility
2026-04-25 22:54:13 +02:00
Ullrich Schäfer
811d5f62f5 Implement social-feed: local follows + /feed + profile visibility
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.

Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
  federation forward-compat. Local IRIs look like
  `${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
  Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
  'public'). Existing users land 'public' via the default; current
  effective behavior is unchanged.

Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
  truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
  countFollowers / countFollowing / listFollowers / listFollowing.
  Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
  follows → activities WHERE visibility='public', reverse-chrono.

Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
  for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.

UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
  "New Activity".

Privacy manifest updated to document the new follows relation and
profile_visibility setting.

Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.

Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:51:18 +02:00
Ullrich Schäfer
6440631be4
Merge pull request #307 from trails-cool/proposal/social-federation
Propose: social-federation (ActivityPub via Fedify)
2026-04-25 22:33:54 +02:00
Ullrich Schäfer
aab9c35d7f
Merge pull request #306 from trails-cool/proposal/social-feed
Propose: social-feed (follows + /feed)
2026-04-25 22:33:26 +02:00
Ullrich Schäfer
bc6059ac77 Propose: social-federation (ActivityPub via Fedify)
Follow-up to social-feed. Adds real ActivityPub: per-user actor
objects, WebFinger, signed inbox/outbox, push delivery on local
activity create, signed outbox-poll for remote trails actors.

Asymmetric scope keeps v1 small:
- Inbound: anyone (Mastodon, Pleroma, …) can follow a trails user
  and receive their public activities via push delivery.
- Outbound: trails users can follow other *trails* instances only.
  Following Mastodon/Pleroma/Misskey is refused at the API layer
  with a clear "v1 limitation" message.

This avoids the engineering tar pit of robustly parsing arbitrary AP
vocabulary (Notes with attachments, polls, mentions, content warnings,
threads) while still delivering the core federation pitch.

Capabilities:
- New: social-federation
- Modified: social-follows (remote follows + Pending lifecycle)
- Modified: public-profiles (Pending button state, actor object gate)
- Modified: infrastructure (Fedify dep, key management runbook)
- Modified: security-hardening (HTTP Signatures, signed-fetch policy)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:31:28 +02:00
Ullrich Schäfer
94c4f4389e Propose: social-feed (follows + /feed)
Proposal + design + specs + tasks for the social layer. Adds a
`follows` relation (local + federated via ActivityPub), Follow buttons
on public profiles, and a `/feed` route showing public activities from
followed users.

Capabilities:
- New: social-follows (the graph + /feed)
- Modified: public-profiles (Follow button + counts)
- Modified: journal-landing (Feed link on signed-in dashboard)

Design picks:
- Pull-based feed (no fan-out-on-write) — fine at trails.cool scale
- follows keyed by actor IRI, local denorm FK for join perf
- Fedify wires Follow/Accept/Undo; we handle the DB side
- Remote activity ingestion via polling (not push) — tolerates our
  downtime without losing activities

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:26:20 +02:00
Ullrich Schäfer
172d980a24
Merge pull request #305 from trails-cool/chore/archive-public-content-visibility
Archive public-content-visibility
2026-04-24 22:00:25 +02:00
Ullrich Schäfer
ba6f09171d Archive public-content-visibility
Final tasks ticked post-merge:
- 10.2: verified on prod that journal.routes + journal.activities still
  default to 'private' NOT NULL, with the only public rows being the
  15-each demo-bot seeded content
- 10.3: demo-activity-bot already inserts with visibility='public'
  directly in demo-bot.server.ts

Syncs the three delta specs into main:
  + activity-feed: 2 added, 1 modified
  + public-profiles: new spec (1 added)
  + route-management: 2 added, 1 modified

Moves change to openspec/changes/archive/2026-04-24-public-content-visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 21:57:21 +02:00
Ullrich Schäfer
9709a0c150
Merge pull request #304 from trails-cool/feat/personal-dashboard
Personal activity dashboard for signed-in users
2026-04-24 20:20:33 +02:00
Ullrich Schäfer
19f25f8e26 Personal activity dashboard for signed-in users
Previously a signed-in user landing on `/` saw the same page as an
anonymous visitor: the visitor hero, the flagship marketing cards, and
the instance-wide public feed. "Home" should mean "your stuff."

The home route now branches on session: signed-out visitors keep the
marketing + public-feed layout from #303; signed-in users get a
personal dashboard showing their own activities reverse-chronologically
(all visibilities), a welcome line linking to their profile, and a
"New Activity" CTA. The public instance feed and marketing blurbs are
suppressed for signed-in users — they'd be redundant on a personal
landing surface.

Loader picks `listActivities(user.id)` vs `listRecentPublicActivities`
based on session, so only one feed query runs per request.

Updates the `journal-landing` spec with the new session-based split and
a "Personal dashboard for signed-in users" requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:16:56 +02:00
Ullrich Schäfer
cabfff6b22
Merge pull request #303 from trails-cool/feat/journal-home-feed
Rebuild Journal home: flagship marketing + public activity feed
2026-04-24 20:03:28 +02:00
Ullrich Schäfer
f0ef989ac3 Rebuild the Journal home around a public feed and flagship marketing
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.

The new home is one layout that serves both audiences:

- Hero: a product-describing h1 ("Federated outdoor journal") +
  tagline. The site name already lives in the top banner and nav brand,
  so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
  Planner demotes to a small "Or try the Planner without an account →"
  link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
  for Planner / Journal / Federation / Ownership, matching the Planner
  home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
  a "Powered by trails.cool — about the project" link back to the
  flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
  activities on the instance, with owner + distance + date. Empty state
  for fresh installs.

Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
  users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
  cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
  so self-hosters (who deploy without these workflows) default to off.

Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
  so the visibility contract is pinned (public in feed, unlisted +
  private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
  feed layout contract and the `IS_FLAGSHIP` toggle, so future
  instance-branding or empty-state changes have a stable anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:00:23 +02:00
Ullrich Schäfer
a9847c5cab
Merge pull request #302 from trails-cool/fix/grafana-pushover-contact-point
Provision Grafana Pushover contact point
2026-04-24 19:10:17 +02:00
Ullrich Schäfer
98341e3603 Provision Grafana Pushover contact point
The notification policy and contact points live in
`infrastructure/grafana/provisioning/alerting/alerts.yml`, so
UI-created contact points can't be attached to provisioned alerts.
Provision the Pushover integration instead.

Rather than add a second contact point and do the fan-out in the
notification tree (Grafana's tree only fires one receiver per match
without duplication workarounds), put both email + pushover under a
single `default` contact point. Every alert now fans out to both.

Secrets go to SOPS `secrets.infra.env`. The Grafana container reads
`PUSHOVER_API_TOKEN` and `PUSHOVER_USER_KEY_ULLRICH` from env; the
provisioning YAML references them via `$VAR` and Grafana substitutes
at startup, so no secret lands in git.

Priority 1 on firing (bypass quiet hours), 0 on resolve. User can
tweak in the YAML later.

Post-deploy: delete the UI-created "Pushover Ullrich" contact point
in Grafana to avoid a duplicate. It's unused (no policy references it)
but shows up in the contact-point list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:07:04 +02:00
Ullrich Schäfer
79c4e1e457
Merge pull request #301 from trails-cool/fix/cd-infra-copy-init-sql
Copy init-grafana-user.sql on every cd-infra deploy
2026-04-24 18:50:23 +02:00
Ullrich Schäfer
bddc3d7620 Copy init-grafana-user.sql on every cd-infra deploy
The postgres init script grants read permissions to `grafana_reader` on
`public`, `planner`, `journal`, and `pgboss` schemas. It's mounted into
the postgres container via bind mount from
`/opt/trails-cool/postgres/init-grafana-user.sql`, and cd-infra re-runs
it with `psql -f` after postgres comes up.

But `cd-infra.yml` only scp's `infrastructure/postgres/queries.yml` to
the flagship — not `init-grafana-user.sql`. The file on the server was
the pre-pg-boss-upgrade March version with no pgboss grants. After
pg-boss v12 landed and (re)created the `pgboss` schema, the
grafana_reader lost schema-level USAGE, the "Background job failures"
alert query started erroring with `permission denied for schema pgboss`,
and the alert fired as "Alerting (Error)" for hours.

Fix: add the init SQL to the scp source list so changes to it actually
reach the flagship. I also manually re-granted on the live postgres to
clear the firing alert; the next cd-infra deploy will now pick up future
edits to the init file too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:47:18 +02:00
Ullrich Schäfer
d93e40bda5
Merge pull request #300 from trails-cool/chore/archive-brouter-relocation
Archive relocate-brouter-to-dedicated-host
2026-04-24 18:38:58 +02:00
Ullrich Schäfer
66fb5d3044 Archive relocate-brouter-to-dedicated-host
Final step (task 9.4) of the BRouter relocation. The change is moved to
openspec/changes/archive/2026-04-24-relocate-brouter-to-dedicated-host/
and the four delta specs (brouter-integration, infrastructure,
observability, security-hardening) are merged into the main specs:

  + 6 added requirements
  ~ 4 modified requirements

The two intentionally-unchecked tasks are 2.2 (obsoleted, see crossed-out
note) and 9.4 itself (this archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:35:37 +02:00
Ullrich Schäfer
3d0371cab6
Merge pull request #299 from trails-cool/infra/healthcheck-and-orphans
Fix app healthchecks; auto-clean compose orphans
2026-04-24 18:32:02 +02:00
Ullrich Schäfer
2e5606458d Fix Planner/Journal healthchecks; auto-clean compose orphans
The compose healthcheck for both apps was `curl -sf http://localhost:.../health`,
but node:25-slim (Debian trixie-slim) ships neither curl nor wget, so
the check always reported the containers as `unhealthy` even while they
were happily serving 200s. Install curl in both Dockerfiles.

Separately, today's flagship BRouter cleanup (PR #297) left an orphan
`brouter` container behind because cd-infra's `docker compose up -d <list>`
doesn't pass `--remove-orphans`. I had to remove it by hand. Pass the
flag in both cd-infra and cd-apps so future service deletions clean up
on the next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:28:04 +02:00
Ullrich Schäfer
c0b42e4743
Merge pull request #298 from trails-cool/infra/grafana-editor-default
Default Grafana auto-assign role to Editor
2026-04-24 18:08:47 +02:00
Ullrich Schäfer
a421f8ad33
Merge pull request #297 from trails-cool/cleanup/flagship-brouter
Drop the flagship BRouter service after cutover
2026-04-24 18:07:16 +02:00
Ullrich Schäfer
360603a213 Default Grafana auto-assign role to Editor
Users land via GitHub OAuth gated by the trails-cool org allowlist, so
Viewer-only is more restrictive than we actually want — no Explore
access, no ad-hoc Loki/Prometheus queries. Promote the default role to
Editor so new sign-ins get it automatically.

Note: GF_USERS_AUTO_ASSIGN_ORG_ROLE only applies to new users on first
sign-in. Existing Viewers (including the single current user) need a
one-off promotion via the Grafana admin UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:05:11 +02:00
Ullrich Schäfer
c16c140223 Drop the flagship BRouter service after cutover
After PR #293 flipped BROUTER_URL to the dedicated host and Grafana +
manual smoke tests confirmed clean routing (US route worked, request
rate/latency/memory/logs all green), there's no reason to keep the
in-tree flagship brouter container warm any longer.

- Remove the `brouter:` service and its `./segments` bind mount from
  `infrastructure/docker-compose.yml`.
- Remove `depends_on: brouter` from the planner service.
- Tighten the BROUTER_URL and BROUTER_AUTH_TOKEN env wiring from
  `${…:-default}` to `${…:?message}` so a missing SOPS value fails the
  compose up loudly instead of silently pointing at the removed service
  (or missing auth).
- Tick tasks 5.5, 7.5, 8.4, 9.3 in the OpenSpec change; 9.4 (archive)
  is the last remaining step once this merges.

Post-merge operator step: `docker image prune -f` on the flagship to
reclaim the brouter image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:04:17 +02:00
Ullrich Schäfer
50349b0363
Merge pull request #296 from trails-cool/feat/segment-cache
Cache BRouter segments client-side, fetch only the diff
2026-04-24 18:01:33 +02:00
Ullrich Schäfer
c0c3ce98d0 Cache BRouter segments client-side, fetch only the diff
Moving one waypoint previously re-fetched all N-1 segments of the route
through /api/route every time, because the server-side computeRoute
recomputes the whole route from scratch. Under rapid edits this piles up
on BRouter's thread pool and wastes BRouter+Planner cycles on segments
the caller already has.

This change keeps a host-local LRU segment cache in the Planner tab,
keyed by (from, to, profile, noGoHash). On each computeRoute:

  - build the pair list from the current waypoints
  - split into cached vs. missing by looking up each pair key
  - if any missing, POST { pairs: missing, … } to the new
    /api/route-segments endpoint and populate the cache with its ordered
    raw BRouter responses
  - merge cache-ordered segments into an EnrichedRoute client-side via
    mergeGeoJsonSegments (moved to a pure, isomorphic route-merge.ts)
  - write to yjs.routeData exactly as before

Typical drag of one waypoint in a 10-waypoint route drops from 9 BRouter
fetches to 2. A session re-entering a cached arrangement (undo/redo)
short-circuits the server entirely. Profile/no-go changes invalidate all
keys and trigger a full refetch, matching prior behavior.

Yjs stays out of the cache: segments are bulky and CRDT sync of large
blobs hurts more than it helps. The elected host owns the cache; on host
handover the new host pays one full recompute.

Backwards-compat: /api/route is unchanged for external callers (e2e
integration tests, the Journal demo-bot's format=gpx path). Server-side
it now delegates to the shared fetchSegments helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:57:54 +02:00
Ullrich Schäfer
2d3c45e929
Merge pull request #295 from trails-cool/fix/brouter-contention
Stop BRouter contention kills under rapid editing
2026-04-24 17:46:53 +02:00
Ullrich Schäfer
f283401076 Stop BRouter contention kills under rapid editing
When a user rapidly moves waypoints in the Planner, BRouter's
thread-priority watchdog kills older in-flight requests to free a thread
for newer ones, surfacing to the user as "operation killed by
thread-priority-watchdog" errors.

Two fixes:

1. Bump the BRouter thread pool on the dedicated host from 4 to 16.
   `BROUTER_THREADS` is now a Dockerfile ENV (default 4 for
   flagship/CI/dev) overridden to 16 in `infrastructure/brouter-host/`.
   The host has 32 GB of RAM and idle cores; 4 threads was a flagship-era
   constraint that no longer applies.

2. Cancel the previous in-flight `/api/route` request when a new one
   starts. `use-routing.ts` now keeps an `AbortController` in a ref,
   aborts it at the top of each `computeRoute`, and bails quietly on
   `AbortError` so the newer call drives state.

Together these eliminate both the "real" contention (at the BRouter
level) and the wasted work (BRouter computing a route the client no
longer cares about).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:43:15 +02:00
Ullrich Schäfer
05b72e9114
Merge pull request #293 from trails-cool/cutover/brouter-url
Cut Planner over to dedicated BRouter host
2026-04-24 17:21:36 +02:00
Ullrich Schäfer
a6bc5106bc Cut Planner over to dedicated BRouter host
Adds BROUTER_URL=http://10.0.1.10:17777 (vSwitch) to SOPS so the Planner
starts routing through the dedicated host's Caddy sidecar instead of the
in-tree flagship BRouter. Flagship BRouter stays warm for the 48h
soak/rollback window.

Pre-flight from the flagship confirmed the new host answers 200 with the
auth header and 403 without.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:17:18 +02:00
Ullrich Schäfer
6bae26de6e
Update brouter deploy scp to single ssh 2026-04-24 14:41:30 +02:00
Ullrich Schäfer
b763d9350c
Merge pull request #292 from trails-cool/feat/brouter-observability
BRouter host observability + docs
2026-04-24 08:03:40 +02:00
Ullrich Schäfer
6d089275eb
Mark cutover tasks 7.1-7.3 complete
End-to-end verified on the dedicated host:
- All 4 containers up (brouter 1.7.9, caddy, cadvisor, promtail)
- Planet segments seeded (~10 GB in 1 min, not hours)
- Auth enforced: 403 without header, 200 with header from flagship
- Planner live with BROUTER_AUTH_TOKEN wired (module-level guard passed)

Remaining:
- 7.4 Flip BROUTER_URL in SOPS (the actual cutover)
- 7.5 Remove flagship brouter after 48h soak
- 7.6 Document rollback in final PR description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:26:14 +02:00
Ullrich Schäfer
3c4c1eebd8
Fix BRouter first-deploy issues discovered during cutover
Three fixes after the first cd-brouter run on the dedicated host:

1. **BRouter 1.7.8 → 1.7.9** (`docker/brouter/Dockerfile`). Planet RD5
   segments on brouter.de are now version 11; 1.7.8's `lookups.dat`
   is v10, causing `lookup version mismatch (old rd5?)
   lookups.dat=10 E10_N45.rd5=11` on every route request.

2. **cd-brouter.yml: docker login to ghcr.io before pull**.
   ghcr.io/trails-cool/brouter is private, and the dedicated host's
   Docker daemon isn't logged in by default. Extract DEPLOY_GHCR_TOKEN
   from SOPS at runner side, pass to the SSH step via envs, and
   `docker login` before `docker compose pull`. Credential is
   `::add-mask::`-ed so it doesn't show in logs.

3. **Drop custom healthcheck** on the brouter service. The image
   strips wget/curl post-build, and /bin/sh in the base doesn't
   support /dev/tcp, so there's no in-image way to do an HTTP probe.
   Real health is observed via Caddy's upstream 502 behavior on
   outage and the Planner-side `brouter_request_duration_seconds`
   metric. caddy's `depends_on` drops from service_healthy to
   service_started.

End-to-end verified on the dedicated host after applying the compose
fix manually:
- Caddy enforces auth: 403 without header, proxies with.
- BRouter 1.7.9 will resolve the segment-version error once the image
  is rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:21:46 +02:00
Ullrich Schäfer
9e598fb6a1
BRouter host observability + docs
Lands sections 6 and 8 of relocate-brouter-to-dedicated-host on top
of the host compose in #291.

## Observability (section 6)

- **Prometheus**: new `brouter-cadvisor` job scraping
  `10.0.1.10:8080` over the vSwitch, labeled `host="brouter"`.
- **cAdvisor sidecar** on the dedicated host's compose
  (`--docker_only --whitelisted_container_labels=trails.cool.service`)
  so metrics only cover trails containers, never the operator's
  unrelated workloads on the shared box.
- **Promtail sidecar** on the dedicated host, Docker SD with relabel-
  drop on missing `trails.cool.service` label, pushing to flagship
  Loki at `http://10.0.0.2:3100/loki/api/v1/push`.
- **Flagship compose**: Loki now publishes port 3100 on the vSwitch
  IP only (10.0.0.2:3100) — Hetzner Cloud firewall blocks it from
  the public internet.
- **Grafana dashboard**: `brouter.json` — scrape up/down, request
  rate (from Planner-side `brouter_request_duration_seconds`),
  p50/p95/p99, container memory/CPU, Loki logs panel.
- **Alert**: `brouter-scrape-down` fires on
  `up{job="brouter-cadvisor"} < 1 for 2m`; `noDataState: Alerting`
  so a total scrape failure still pages.

Operator needs one UFW rule on the dedicated host for the cAdvisor
port — documented in `infrastructure/brouter-host/README.md`.

## Documentation (section 8)

- `CLAUDE.md` — hosts table + updated deployment table with SSH
  targets per workflow; BRouter host SSH is `-p 2232 trails@...`,
  different key.
- `docs/architecture.md` — Hosting section rewritten to cover both
  hosts, vSwitch boundary, and the observability-scoping rationale
  for the shared dedicated host.
- `docs/deployment.md` (new) — full operator runbook: host layout,
  first-time BRouter provisioning, SOPS rotation (including the
  macOS `SOPS_AGE_KEY_FILE` gotcha), cutover procedure with
  rollback, manual workflow triggers.

Task 8.4 (infrastructure/README.md) skipped: that file doesn't
exist and the ground is covered by brouter-host/README.md +
docs/deployment.md.

## Validation

- `docker compose config` on both the flagship and brouter-host
  compose files — both validate.
- `pnpm typecheck`, `pnpm lint`, `pnpm test` — all clean (full
  turbo cache hits; no code changes in this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:10:45 +02:00
Ullrich Schäfer
34b2e44ed1
Merge pull request #291 from trails-cool/feat/brouter-host-compose
BRouter host compose + Planner auth + cd-brouter rewrite
2026-04-23 23:09:23 +02:00
Ullrich Schäfer
c49047fd33
BRouter host compose + Planner auth + cd-brouter rewrite
Lands sections 3-5 of the relocate-brouter-to-dedicated-host change:
everything needed to run BRouter on the dedicated Hetzner Robot host
and have the Planner talk to it with the shared-secret header. Does
NOT flip the cutover — the flagship BRouter stays warm during soak.

## BRouter host compose (section 3)

New `infrastructure/brouter-host/` — a standalone compose project that
runs as the `trails` user on `ullrich.is`:

- `docker-compose.yml` — brouter + caddy sidecar. BRouter has no host
  port; caddy binds only to `10.0.1.10:17777` (vSwitch IP). Every
  service explicitly overrides the host's default Loki logging driver
  to `json-file` so logs don't leak to the operator's personal Loki.
- `Caddyfile` — single-purpose reverse proxy that requires
  `X-BRouter-Auth: ${BROUTER_AUTH_TOKEN}` on every request. `auto_https
  off` (vSwitch-only); default access log format omits request
  headers, so the token is never written to disk.
- `download-segments.sh` — crawls brouter.de, pulls planet-wide RD5
  tiles via `wget -N` (incremental). Idempotent, safe to cron.
- `README.md` — one-shot provisioning + token rotation + rollback
  notes.

`docker/brouter/Dockerfile` is patched to honor `JAVA_OPTS` (was
hardcoded `-Xmx1024M` in CMD). Default keeps the flagship's current
heap; compose on the dedicated host overrides to `-Xmx8g` for planet
scale on a 32 GB box.

## Planner shared-secret header (section 4)

`apps/planner/app/lib/brouter.ts`:

- Module-level guard: throws at startup in production if
  `BROUTER_AUTH_TOKEN` is unset.
- `authHeaders()` helper (reads env at call time, so tests can
  `vi.stubEnv` without module reset).
- Header attached on both `computeRoute` (per-segment) and
  `computeSegmentGpx`.

3 new unit tests cover header attachment + the no-token path.

`infrastructure/docker-compose.yml` passes `BROUTER_AUTH_TOKEN` to
the Planner service, and makes `BROUTER_URL` overridable via SOPS so
the cutover is a one-variable flip.

## cd-brouter workflow (section 5)

Rewritten to deploy to the dedicated host:

- SSH as `trails@${BROUTER_DEPLOY_HOST}` on port
  `${BROUTER_DEPLOY_SSH_PORT}` (2232) using
  `${BROUTER_DEPLOY_SSH_KEY}`.
- Decrypts SOPS, extracts ONLY `BROUTER_AUTH_TOKEN` into a `.env`
  file, scp'd alongside the compose project.
- `paths:` trigger now includes `infrastructure/brouter-host/**`.
- Segment download is NOT run here — first-time seed is a manual
  operator step (multi-hour). Routine re-runs are cron-able on the
  dedicated host.
- Grafana annotation step preserved (reaches flagship Grafana as
  before).

## What's NOT here

- `brouter:` service on the flagship is intentionally left in place
  (removed in section 7.5 after the 48 h soak window post-cutover).
- Observability (section 6) — Prometheus scrape + Loki shipping from
  the dedicated host — comes in a follow-up PR.
- Cutover itself (section 7) — flip `BROUTER_URL`, verify, remove the
  flagship brouter — is an operator action gated on first-time
  provisioning + smoke testing.

## Verification

`pnpm typecheck && pnpm lint && pnpm test` all clean; planner build
passes (the CI regression from #286 was fixed in #290).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:52:48 +02:00
Ullrich Schäfer
12010b48c2
Add BROUTER_AUTH_TOKEN to app secrets (SOPS)
Adds the shared-secret header value consumed by the Planner
(as X-BRouter-Auth outbound) and by the BRouter host's Caddy
sidecar (enforced inbound). Lives in secrets.app.env only;
cd-brouter.yml will also read from this file, making it the
single source of truth. secrets.infra.env is intentionally NOT
updated — cd-infra no longer touches BRouter after the move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:47:54 +02:00
Ullrich Schäfer
f0fe6abfa4
Merge pull request #289 from trails-cool/feat/brouter-vswitch-network
Add Hetzner vSwitch network for BRouter relocation
2026-04-23 22:47:07 +02:00
Ullrich Schäfer
102a744e67
Add Hetzner vSwitch network for BRouter relocation
Adds an OpenSpec change scoping the relocation of BRouter from the
co-located flagship (cx23, 4 GB RAM, 40 GB SSD, Europe segments only) to
a dedicated Hetzner Robot host in the same datacenter, with private
connectivity over Hetzner vSwitch #80672 (VLAN 4000).

This first PR only lays the network prerequisite:

- Terraform: a Hetzner Cloud Network (10.0.0.0/16) with a cloud subnet
  (10.0.0.0/24) hosting the flagship at 10.0.0.2, and a vSwitch subnet
  (10.0.1.0/24) bridged to Robot VLAN 4000. The dedicated host's VLAN
  sub-interface (10.0.1.10 on enp4s0.4000) is configured out-of-band via
  netplan and is not Terraform-managed.
- lifecycle { ignore_changes = [user_data] } on the flagship server to
  prevent the Hetzner provider's post-1.45 user_data hash drift from
  triggering a spurious full-server replacement on unrelated applies.
- OpenSpec change with proposal, design, specs (delta for
  brouter-integration / infrastructure / observability /
  security-hardening), and tasks; Section 1 (pre-flight) is checked off
  with operator notes.

Verification: ping both directions across the vSwitch is 0% loss,
sub-ms latency; dedicated host's VLAN config persists across reboot
(verified ~60 s to restore private reachability).

Follow-up PRs will land the BRouter host compose project, Planner
shared-secret header, CD workflow retarget, observability wiring, and
the cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:43:57 +02:00
Ullrich Schäfer
566a322dbb
Merge pull request #290 from trails-cool/fix/overpass-metrics-export
Fix planner build: move Overpass upstream fetch to overpass.server.ts
2026-04-23 22:43:41 +02:00
Ullrich Schäfer
458b0a8a4c
Fix planner build: move Overpass upstream fetch to overpass.server.ts
Route files in React Router only get server-code stripped from a
specific set of exports (loader/action/middleware/headers). The
overpass failover work added a `fetchWithFailover` named export to
`app/routes/api.overpass.ts` for unit tests, but that export pulls
in `metrics.server` (prom-client registry, etc.) — which then
leaks into the client bundle and fails the production build with:

  [plugin react-router:dot-server]
  Error: Server-only module referenced by client
  'app/lib/metrics.server' imported by route 'app/routes/api.overpass.ts'

Move the upstream fetch logic and its config constants into
`app/lib/overpass.server.ts`. The `.server.ts` naming makes the
server-only boundary explicit, and tests now import from the lib
instead of from the route. The route keeps only its `action` export
(plus the per-instance LRU cache, which is internal to the module
and not exported).

Tests: 85 passing (no behavior changes). Build: planner + journal
both succeed. Typecheck + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:40:15 +02:00
Ullrich Schäfer
b53fb736a4
Merge pull request #288 from trails-cool/fix/planner-active-sessions-gauge
Fix planner_active_sessions gauge drifting negative
2026-04-22 22:38:54 +02:00
Ullrich Schäfer
0553cf4a5e
Fix planner_active_sessions gauge drifting negative
The gauge was maintained by conditional inc on "this sessionId isn't
in the in-memory `docs` Map yet" and dec on "last client for the
sessionId left." Because `docs` caches entries indefinitely — even
after every client disconnects — a reconnect found the doc still
cached, skipped the inc, then decremented again on the next
disconnect. Every reconnect-after-last-disconnect leaked one
decrement. After a day of normal reload / wifi-blip / tab-hibernate
patterns the gauge hit -182.

Replace the transitions with a `set()` derived from the live `conns`
map: count distinct session ids with at least one open WebSocket.
Self-correcting, no drift possible.

Extract `countActiveSessions()` as a pure helper + unit tests
including a regression case that simulates repeated reconnect/
disconnect cycles on one session and asserts the count never goes
negative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:37:58 +02:00
Ullrich Schäfer
6e15104496
Merge pull request #287 from trails-cool/feat/overpass-failover
Session-bind /api/route and /api/overpass
2026-04-21 22:21:02 +02:00
Ullrich Schäfer
ed7f6ce153
Session-bind /api/route and /api/overpass
Today both proxies are effectively open to anyone who can set an
Origin header for trails.cool — a third party can use us as a free
BRouter/Overpass relay. Require a live planner session on every call
so abuse traffic costs the scraper a session row (observable,
revocable) before they can issue a single query.

Server:
- New `requireSession(id)` helper — returns the session row or a 401
  Response. Reused by both route handlers.
- `/api/route`: `sessionId` in body is now required and verified;
  rate-limit key always falls back to the session id.
- `/api/overpass`: new `X-Trails-Session` header, verified. Header
  keeps the session out of the request body so the body-keyed cache
  is unaffected.

Client plumbing:
- `useRouting(yjs, sessionId)` — sessionId goes into the /api/route
  body.
- `usePois(sessionId)` → `queryPois(..., sessionId)` → `X-Trails-Session`
  on the proxy call.
- `PlannerMap` + `YjsDebugPanel` gain a `sessionId` prop from
  `SessionView`.

Journal server-to-server:
- Demo-bot and `/api/v1/routes/compute` now POST `/api/sessions` to
  mint a throwaway planner session, then cite it on the forwarded
  `/api/route` call. Planner's `expire-sessions` cron cleans these up
  (7d window) so nothing needs explicit teardown.

Tests:
- 5 unit tests for `requireSession` covering missing / empty /
  non-string / unknown-session / valid-session cases.
- Two integration E2E tests document the 401 for missing session on
  each proxy.
- Pre-existing `/api/route` integration tests updated to mint a
  session first.

Caveat: existing browser tabs lose their /api/route ability until
reload (the old JS doesn't know to send sessionId). Acceptable for
an anonymous planner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:18:45 +02:00
Ullrich Schäfer
203473d44b
Merge pull request #286 from trails-cool/feat/overpass-failover
Failover across multiple Overpass upstreams
2026-04-21 07:51:40 +02:00
Ullrich Schäfer
963902514b
Failover across multiple Overpass upstreams
Public Overpass instances go bad without warning — we just lived
through a day where overpass.private.coffee (our single upstream)
returned 504s after 60s while lz4.overpass-api.de served the same
query in 0.6s. Survive that by trying a list of upstreams in order
until one responds usefully.

Server changes:
- OVERPASS_URLS env — comma-separated list, tried in order. Falls
  back to OVERPASS_URL for backward compat, then to a built-in
  default pair (lz4.overpass-api.de, overpass-api.de).
- Per-upstream timeout of 10s via AbortSignal.timeout, so a saturated
  instance fails over in seconds, not minutes.
- Client abort propagation via AbortSignal.any — if the browser
  cancels (e.g. user panned the map), the in-flight upstream fetch is
  aborted too. Stops wasting upstream capacity on requests nobody
  wants.
- Rate-limited-body detection (Overpass returns 200 with a
  `rate_limited` marker when throttling) triggers failover.
- Per-upstream labels on overpass_upstream_requests_total and
  overpass_upstream_duration_seconds so the dashboard can break out
  health + latency by instance. Histogram buckets extended to 30s.

Compose: OVERPASS_URLS passthrough with the default pair hard-wired,
overridable via SOPS.

Tests: 8 cases covering the new fetchWithFailover helper — happy
path, 5xx failover, rate_limited failover, network error failover,
timeout tagging, all-upstreams-fail, client abort stops the loop,
per-attempt latency observation.

Follow-ups left out of scope:
- Client-side debounce (UI concern).
- Moving `.observe()` after body read for accuracy in measurement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 07:36:10 +02:00
Ullrich Schäfer
d5309b480c
Merge pull request #285 from trails-cool/fix/pgboss-dashboard-columns
Update pgboss column names for v12 (snake_case)
2026-04-20 16:52:48 +02:00
Ullrich Schäfer
875665de66
Update pgboss column names for v12 (snake_case)
pg-boss v11 renamed every concatenated-lowercase timestamp column
(`createdon`, `completedon`) to snake_case (`created_on`,
`completed_on`). Our service-health dashboard and failed-job alert
still referenced the v10 names, so after the v12 upgrade those
queries error out silently.

Fix the three affected queries:
- alerts.yml: failed-job alert
- service-health.json: "Recent Failed Jobs" table
- service-health.json: "Job Queue — Completed/hour" time series

The `state` / `name` / `output` columns stay the same, and the `state`
enum still accepts `'failed'` / `'completed'` literals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:49:43 +02:00
Ullrich Schäfer
9d22ae5510
Merge pull request #284 from trails-cool/feat/pg-boss-v12
Upgrade pg-boss from 10 to 12
2026-04-19 23:04:34 +02:00
Ullrich Schäfer
c2e8461f9c
Upgrade pg-boss from 10 to 12
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.

## Breaking changes in our usage surface

### v12: default export removed → named `PgBoss` export

Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
  re-exports `Job` at the package root in v12)

### v11: queue names restricted to `[A-Za-z0-9_.-]`

Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune`    → `demo-bot-prune`

The planner's `expire-sessions` was already valid.

### v12: minimum Node 22.12

Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.

## Regression guard

Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.

## Prod rollout note

Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.

## Verified

- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:01:11 +02:00
Ullrich Schäfer
5b48ba388b
Merge pull request #283 from trails-cool/feat/e2e-block-unmocked-external
Block unmocked external requests in E2E tests
2026-04-19 22:45:41 +02:00
Ullrich Schäfer
f94020a93a
Block unmocked external requests in E2E tests
Surface net for the class of bug we hit in #282 — a POI test silently
relied on a live Overpass server for months because its `page.route`
mock was targeting a URL the browser no longer hit directly. CI stayed
green on coincidence (real OSM data happened to satisfy the "any
marker renders" assertion) until upstream behaviour drifted.

Add a shared Playwright fixture (e2e/fixtures/test.ts) that installs a
catch-all `page.route("**", ...)` before each test. Requests to
localhost + known tile CDNs pass through via `route.continue()`;
anything else is aborted and accumulated. At test teardown, a non-empty
blocked list throws, failing the test with the offending URLs and a
pointer to the fixture.

All seven e2e *.test.ts files updated to import test/expect (+ types)
from ./fixtures/test.

Full suite passes with this fixture in place (50/50 at --workers=2;
catch-all never fires, meaning no test currently relies on an
unallowlisted external service).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:42:29 +02:00
Ullrich Schäfer
72fb78dbb2
Merge pull request #282 from trails-cool/fix/e2e-poi-proxy-mock
Fix POI E2E test: mock the planner proxy, not upstream Overpass
2026-04-19 21:51:11 +02:00
Ullrich Schäfer
9c9d53d3bd
Fix POI E2E test: mock the planner proxy, not upstream Overpass
The test was added in 0ff04aa (Apr 11), back when the browser called
overpass-api.de directly. When a4df5a4 (Apr 18) moved POI queries
behind the planner's `/api/overpass` proxy, the test's
`page.route("**/api/interpreter", ...)` mock became dead code —
Playwright can only intercept browser traffic, and the browser now
only sees `/api/overpass`. The real upstream got hit and didn't
return a drinking_water node at exactly 52.52, 13.405, so zero
markers rendered and the test failed.

Mock `/api/overpass` instead. Response body shape is unchanged
(the proxy is transparent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:48:21 +02:00
Ullrich Schäfer
88c83e359b
Merge pull request #280 from trails-cool/dependabot/npm_and_yarn/production-42e4c8cc57
Bump the production group across 1 directory with 10 updates
2026-04-19 21:46:00 +02:00
dependabot[bot]
fef9b5f3f6
Bump the production group with 10 updates
Bumps the production group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` |
| [i18next](https://github.com/i18next/i18next) | `26.0.4` | `26.0.6` |
| [prettier](https://github.com/prettier/prettier) | `3.8.2` | `3.8.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.3` | `17.0.4` |
| [isbot](https://github.com/omrilotan/isbot) | `5.1.38` | `5.1.39` |
| [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) | `5.6.2` | `5.7.0` |
| [react-native-screens](https://github.com/software-mansion/react-native-screens) | `4.23.0` | `4.24.0` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.41.0` | `6.41.1` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.48.0` | `10.49.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.37.0` | `10.49.0` |

Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `i18next` from 26.0.4 to 26.0.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.4...v26.0.6)

Updates `prettier` from 3.8.2 to 3.8.3
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

Updates `react-i18next` from 17.0.3 to 17.0.4
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.3...v17.0.4)

Updates `isbot` from 5.1.38 to 5.1.39
- [Changelog](https://github.com/omrilotan/isbot/blob/main/CHANGELOG.md)
- [Commits](https://github.com/omrilotan/isbot/compare/v5.1.38...v5.1.39)

Updates `react-native-safe-area-context` from 5.6.2 to 5.7.0
- [Release notes](https://github.com/AppAndFlow/react-native-safe-area-context/releases)
- [Commits](https://github.com/AppAndFlow/react-native-safe-area-context/compare/v5.6.2...v5.7.0)

Updates `react-native-screens` from 4.23.0 to 4.24.0
- [Release notes](https://github.com/software-mansion/react-native-screens/releases)
- [Commits](https://github.com/software-mansion/react-native-screens/compare/4.23.0...4.24.0)

Updates `@codemirror/view` from 6.41.0 to 6.41.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@sentry/node` from 10.48.0 to 10.49.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.48.0...10.49.0)

Updates `@sentry/react` from 10.37.0 to 10.49.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.37.0...10.49.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: i18next
  dependency-version: 26.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-i18next
  dependency-version: 17.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: isbot
  dependency-version: 5.1.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-native-safe-area-context
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react-native-screens
  dependency-version: 4.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@codemirror/view"
  dependency-version: 6.41.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 21:40:33 +02:00
Ullrich Schäfer
e5a0215000
Merge pull request #267 from trails-cool/dependabot/npm_and_yarn/sentry/react-native-8.8.0
Bump @sentry/react-native from 7.11.0 to 8.8.0
2026-04-19 21:37:54 +02:00
Ullrich Schäfer
ec93d37470
Prefix dedupe commit message + set GH_TOKEN for push step
- Commit subject prefixed with "[github-actions]" so the audit trail
  is obvious at a glance in `git log`.
- GH_TOKEN exposed as an env var on the commit step so the PAT is
  also available to any `gh` invocations the step might grow, and the
  token plumbing is visible at the site where the push happens — not
  only up at the checkout step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:06:38 +02:00
Ullrich Schäfer
da56eb2c19
Stop over-claiming the persist-credentials mechanism
The previous comment claimed `git push` reads the token via a git
credential helper wired into $RUNNER_TEMP. I don't actually know that's
how it works end to end — only that checkout stashes the token there
and that subsequent git ops in the same workspace end up authenticating
as the PAT. Describe just the observable contract, not an unverified
internal path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:58:26 +02:00
Ullrich Schäfer
5d9ffae38a
Correct comment about where checkout persists the PAT
Current actions/checkout stores the token in $RUNNER_TEMP and wires a
git credential helper, rather than writing the token into .git/config's
http.extraheader. The mechanism that makes `git push` pick up the PAT
is unchanged at the caller's level; the comment was just wrong about
where the secret lives on disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:56:54 +02:00
Ullrich Schäfer
32ea0f66f5
Make persist-credentials explicit in dedupe checkout
`actions/checkout` has `persist-credentials: true` as its default, but
that default is the load-bearing part of how the later `git push`
authenticates — it's what writes the token into .git/config's
http.extraheader. Making it explicit so a reader of the YAML can see
the mechanism without having to know the action's defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:55:47 +02:00
Ullrich Schäfer
e505cfa2b9
Require DEPENDABOT_DEDUPE_TOKEN for the dedupe workflow
Previously the workflow fell back to GITHUB_TOKEN when the PAT wasn't
set — which was silently broken: the push succeeds but GitHub's
anti-workflow-loop safeguard means no new CI runs on the dedupe commit.
Reviewers see stale green CI and the dedupe itself isn't tested until
the next dependabot rebase.

Make the PAT a hard requirement: preflight check fails with a clear
message when the secret is missing, and the checkout step uses it
(which is what persists auth for the later `git push`). Intent is now
obvious from the YAML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:53:57 +02:00
dependabot[bot]
e94069232b
Bump @sentry/react-native from 7.11.0 to 8.8.0
Bumps [@sentry/react-native](https://github.com/getsentry/sentry-react-native) from 7.11.0 to 8.8.0.
- [Release notes](https://github.com/getsentry/sentry-react-native/releases)
- [Changelog](https://github.com/getsentry/sentry-react-native/blob/main/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-react-native/compare/7.11.0...8.8.0)

---
updated-dependencies:
- dependency-name: "@sentry/react-native"
  dependency-version: 8.8.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-19 11:34:23 +00:00
Ullrich Schäfer
6ee737694a
Merge pull request #279 from trails-cool/feat/maplibre-react-native-v11 2026-04-19 13:32:18 +02:00
Ullrich Schäfer
7e302b1fbe
Upgrade @maplibre/maplibre-react-native from 10 to 11
v11 is a breaking API rework (renamed components, restructured props,
event payloads on nativeEvent) and requires React Native's new
architecture. Doing it as a manual migration instead of taking
dependabot's #268 because the renames need code changes.

Changes in apps/mobile:

- RouteMap.tsx — switch to named imports; rename MapView→Map,
  ShapeSource→GeoJSONSource (with shape→data), LineLayer→Layer
  (with type="line" and paint instead of style), MarkerView→
  ViewAnnotation (with coordinate→lngLat); Camera uses
  initialViewState with bounds as [w,s,e,n] and a separate padding
  object; onLongPress reads lngLat from event.nativeEvent.

- app.config.ts — set `newArchEnabled: true`. MapLibre RN v11
  supports only the new architecture, so Fabric/TurboModules must
  be on for the Expo prebuild. Cast the config type since Expo SDK
  55's ExpoConfig typings don't yet include the field (the runtime
  does).

- package.json — `^10.4.2` → `^11.0.0`.

Supersedes dependabot #268; it'll auto-close once this merges.

Runtime verification requires an EAS dev build (native deps
changed + new arch flipped). JS-only surface typechecks + lints +
unit tests clean locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:09:06 +02:00
Ullrich Schäfer
c3d9514f4d
Merge pull request #277 from trails-cool/chore/dependabot-dedupe-workflow
Auto-dedupe lockfile on dependabot PRs
2026-04-19 12:01:00 +02:00
Ullrich Schäfer
c39020a848
Merge pull request #278 from trails-cool/chore/gitignore-scheduled-tasks-lock
Ignore .claude/scheduled_tasks.lock
2026-04-19 12:00:40 +02:00
Ullrich Schäfer
760c0b4d4d
Ignore .claude/scheduled_tasks.lock
Created by the Claude Code scheduled-wakeup runtime when a dynamic
loop is active. Pure ephemeral state; no reason to track it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:00:02 +02:00
Ullrich Schäfer
f085b78fd7
Auto-dedupe lockfile on dependabot PRs
pnpm install after a dependabot bump keeps duplicate versions of
packages that happen to be pinned via overlapping semver ranges. For
singleton-ish libraries (i18next, react-i18next) that's a latent bug:
the server's module instance and the client's module instance each
hold their own state, and SSR output doesn't match CSR.

#272 ran into this with an i18next patch bump — the bumped version
stayed on some dep paths while other paths kept the old version. A
manual `pnpm dedupe` collapsed them and hydration worked again.

This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
pushes the resulting lockfile update back to the PR branch so CI
runs against the deduped tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:59:33 +02:00
Ullrich Schäfer
be2baa455f
Merge pull request #276 from trails-cool/chore/zod-4-migration
Upgrade zod from 3 to 4
2026-04-19 11:48:30 +02:00
Ullrich Schäfer
edd2cabf12
DRY up ZodError → FieldError mapping
Five journal API route handlers had the same inline lambda:

    parsed.error.issues.map((i) => ({
      field: i.path.join("."),
      message: i.message,
    }))

Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.

Public error-response shape is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:09 +02:00
Ullrich Schäfer
079c3de90c
Modernise zod idioms to v4
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:

- z.string().url()      → z.url()      (3 sites)
- z.string().uuid()     → z.uuid()     (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)

Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.

All 147 tests still pass.

The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:41:03 +02:00
Ullrich Schäfer
0eea6f2047
Upgrade zod from 3 to 4
Mechanical bump across the three packages that pull zod directly:
- packages/api (8 schema files, ~300 lines)
- apps/journal (persona schema in demo-bot.server.ts, 5 API route
  handlers that introspect parsed.error.issues)
- apps/mobile (declared dep; no active imports yet)

No code changes required. The legacy methods we use (.url(), .uuid(),
.datetime() on strings) still parse identically in v4 via the compat
shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue
introspection (parsed.error.issues.map(i => ...)) keeps the same shape
at the fields we read (path, message, code).

All 147 tests across journal + api + packages pass against zod 4.3.6.

Follow-ups out of scope for this PR:
- Modernise to top-level validator helpers (z.url(), z.iso.datetime()).
  The legacy string methods are deprecated but not removed.
- Consider migrating error introspection to the richer v4 shape (.path
  is now branded, .input is new) if we ever want structured error
  surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:35:39 +02:00
Ullrich Schäfer
a3903d67ac
Merge pull request #274 from trails-cool/feat/bruno-dashboard
Add Bruno (demo-bot) Grafana dashboard
2026-04-19 11:26:14 +02:00
Ullrich Schäfer
75257719d3
Add Bruno (demo-bot) Grafana dashboard
Nine panels at trails-demo-bot covering:
- Current synthetic route/activity counts + time-since-last-walk
- Gauge time series over the configured retention window
- Walks per day + distance distribution
- Loki log stream filtered to `demo-bot` messages
- Recent walks table with name, distance, ascent

Provisioned via the existing grafana/dashboards directory — no extra
wiring needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:25:17 +02:00
Ullrich Schäfer
df6074b0b5
Fix demo-bot cron + cadence math
`*/90 * * * *` is invalid cron (minute field is 0–59) and degrades
to hourly, producing half the intended bot cadence. Switch to
`0,30 * * * *` (every 30 min) and lower the Bernoulli gate from
0.12 to 0.09 so we still hit ~2–3 walks/day during the 07–21
Berlin window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:25:06 +02:00
Ullrich Schäfer
0ac4d9674d
Merge pull request #273 from trails-cool/feat/planner-compute-gpx-endpoint
Route journal BRouter calls through the planner
2026-04-19 11:16:50 +02:00
Ullrich Schäfer
42f4c98014
Route journal BRouter calls through the planner
The planner is the BRouter client in this architecture — route
compute, rate limiting, and the eventual move of BRouter off-box all
live there. The journal had two places that called BRouter directly
(demo-bot and /api/v1/routes/compute) and both were broken on prod
because the journal service has no BROUTER_URL.

Fix:
- Extend the planner's /api/route with an optional `format: "gpx"`
  that returns BRouter's raw GPX (for server-to-server callers that
  don't need the way-tag enriched GeoJSON).
- Point the journal demo-bot at `PLANNER_URL/api/route` with
  `format: "gpx"` instead of calling BRouter directly.
- Point /api/v1/routes/compute at `PLANNER_URL/api/route` instead of
  BRouter directly.

Journal no longer needs BROUTER_URL at all. When BRouter moves to a
dedicated host later, only the planner changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:11:10 +02:00
Ullrich Schäfer
017a098fca
Merge pull request #269 from trails-cool/chore/dependabot-ignore-rules
chore: dependabot ignore rules for mismatched @types/* + expo-pinned react-native
2026-04-19 11:01:04 +02:00
Ullrich Schäfer
debaf769d5
Tighten dependabot ignore rules
- Skip major bumps on @types/jest + @types/node so the types stay
  aligned with the runtime (jest 29 with @types/jest 29, not 30).
- Skip react-native itself since it's pinned by the Expo SDK and
  should only move when we bump Expo. Community react-native-*
  libraries stay on the normal weekly cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:58:05 +02:00
Ullrich Schäfer
fe60c6eb0f
Merge pull request #266 from trails-cool/hotfix/docker-include-jobs-dir
hotfix: include app/jobs in journal Docker image
2026-04-19 10:56:12 +02:00
Ullrich Schäfer
e16bd6de99
Ship app/jobs dir in the journal image
server.ts imports from ./app/jobs/demo-bot-generate.ts and ./app/jobs/
demo-bot-prune.ts, but the Dockerfile only COPYs app/lib. On prod the
worker crashes on boot with ERR_MODULE_NOT_FOUND and neither the
generate nor the prune cron gets scheduled. Add the jobs dir to the
runtime layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:53:22 +02:00
Ullrich Schäfer
e3d58d8187
Merge pull request #261 from trails-cool/hotfix/prom-client-double-register
hotfix: prom-client double-register on prod
2026-04-19 10:48:14 +02:00
Ullrich Schäfer
100187cb80
Fix prom-client double-registration on prod
/users/bruno (and any route that imports demo-bot.server.ts) was
returning 500 on prod after #258 because the route's module graph
loaded metrics.server.ts a second time, re-running collectDefaultMetrics
and `new client.Gauge(...)` — prom-client's global registry rejects
duplicate metric names, so the route module failed to load.

Guard every metric creation via `getSingleMetric` so a second module
load reuses the existing gauges/histograms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:44:57 +02:00
Ullrich Schäfer
fe62b34eb3
Merge pull request #259 from trails-cool/chore/enable-demo-bot-prod
chore: enable demo-activity-bot on prod
2026-04-19 10:41:57 +02:00
Ullrich Schäfer
1b9ce4b234
Merge branch 'main' into chore/enable-demo-bot-prod 2026-04-19 10:36:25 +02:00
Ullrich Schäfer
53bdffa117
Enable demo-activity-bot on prod
Sets DEMO_BOT_ENABLED=true plus DEMO_BOT_RETENTION_DAYS and
DEMO_BOT_REGION in the SOPS env file so the journal worker starts
the generate + prune jobs and bootstraps the Bruno demo user on
next deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:34:19 +02:00
Ullrich Schäfer
0163088b37
Merge pull request #258 from trails-cool/feat/configurable-demo-persona
Apply configurable-demo-persona: per-instance demo identity
2026-04-19 10:34:17 +02:00
Ullrich Schäfer
fc4485f6ef
Apply configurable-demo-persona: per-instance demo identity + voice
Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.

- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
  persona username is already a real user; server declines to
  schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
  boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:30:32 +02:00
Ullrich Schäfer
f447852d70
Merge pull request #257 from trails-cool/feat/demo-activity-bot
Apply demo-activity-bot: Bruno the synthetic walker
2026-04-19 10:18:17 +02:00
Ullrich Schäfer
ba4c4e1b4f
Apply demo-activity-bot: Bruno, the synthetic public-walk generator
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:14:58 +02:00
Ullrich Schäfer
7304a9e904
Merge pull request #256 from trails-cool/feat/public-content-visibility
Apply public-content-visibility: visibility flag + public profile
2026-04-19 09:17:34 +02:00
Ullrich Schäfer
32661a0163
Add visibility disclosure to the binding German privacy text
The previous commit only added a bullet to the English-only Privacy
Manifest appendix. The formal, binding German sections (which the
policy explicitly declares authoritative) didn't mention visibility
at all — and the paired English summary under each section likewise
didn't.

Add a sentence under §2 "Erhobene Daten und Zwecke → Nutzerinhalte"
explaining the visibility setting, defaults, and world-visibility
consequences. Mirror in the section's English summary paragraph. Keep
the Privacy Manifest bullet as an additional plain-language mention.

Re-rendered docs/legal-archive/privacy-2026-04-20.md; both languages
present (verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:14:15 +02:00
Ullrich Schäfer
5905cdadea
Apply public-content-visibility: visibility flag + public profile
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.

Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.

Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
  centralises the rule: public → anyone; unlisted → anyone on direct
  link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
  canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
  content only.

Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
  action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.

Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
  for cross-user queries. Existing owner-scoped listRoutes/listActivities
  stay — owners see their own content regardless of visibility.

Public profile:
- /users/:username is now truly public. Renders the user's public
  routes + activities, 404s when no public content exists AND viewer
  isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.

Privacy manifest:
- Added a bullet noting public content is world-visible on profile
  and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
  snapshot.

Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
  - Private route → 404 for logged-out visitor
  - Public route → reachable + OG tags present (og:title, og:type,
    og:site_name)
  - Owner still sees own private content
  - Profile 404 when no public content
  - Profile renders when at least one public route exists
  - Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
  WebAuthn virtual-authenticator races under Playwright's default
  parallel workers.
- New public-content Playwright project added to config.

Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:11:39 +02:00
Ullrich Schäfer
74dca52ecc
Merge pull request #255 from trails-cool/spec/social-mvp
Propose social MVP: public-content-visibility + demo-activity-bot
2026-04-19 08:50:58 +02:00
Ullrich Schäfer
dcbd703f47
Propose social MVP: public-content-visibility + demo-activity-bot
Two stacked OpenSpec change proposals for a demoable social layer,
with scope deliberately minimal ("enough to send a URL and have it
open to real-looking content without signup"):

1. public-content-visibility
   - Adds visibility enum {private, unlisted, public} on routes +
     activities, defaulting to private for every existing row.
   - Detail pages (/routes/:id, /activities/:id) become accessible to
     logged-out visitors when content is public or unlisted; private
     → 404 (not 403) to avoid existence leaks.
   - Broadens /users/:username into a public profile listing only
     public routes + activities; 404s when there's no public content
     to prevent account enumeration.
   - Open Graph / Twitter Card meta on public detail + profile pages.
   - Visibility selector in the owner's edit flow.
   - Out of scope: follow/follower, cross-user feed, reactions,
     federation.

2. demo-activity-bot  (depends on #1)
   - A single bot user, Bruno the trail dog, seeded on worker startup
     when DEMO_BOT_ENABLED=true. Reserved username, sentinel email,
     no credentials.
   - pg-boss recurring job fires every 90 min, decides-to-walk with
     p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at
     organic times.
   - Each walk: random start + end within inner-Berlin bbox, trekking
     only (dogs don't ride bikes), 2-12 km crow. BRouter plans the
     route; the route GPX is also attached as the activity's trace.
   - Everything inserted with visibility=public, synthetic=true.
   - Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old
     (default 14). Hard cap of 40 items/14d protects against runaway
     growth.
   - Small "🐕 demo account" badge on /users/bruno for honesty.

Both changes are artifacts only — no code lands with this PR. Apply
in order after merge: public-content-visibility first, then
demo-activity-bot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:49:59 +02:00
Ullrich Schäfer
94ce350565
Merge pull request #254 from trails-cool/docs/park-mobile-specs
Park mobile-activity-recording + mobile-nearby-sync under docs/ideas
2026-04-19 08:24:18 +02:00
Ullrich Schäfer
621d91e14f
Park mobile-activity-recording + mobile-nearby-sync under docs/ideas
Both are blocked on the mobile-app foundation and on a user base that
actually records activities or needs offline peer sync. With 3 users
and 0 activities on prod today, these are engineering for a
hypothetical population.

Move the artifact sets out of openspec/changes/ (so openspec list
stays clean) and under docs/ideas/ where self-host-overpass already
lives. Each gets a short README documenting:

- current parked status
- revive triggers (when should we look at this again?)
- key constraints not to rediscover (background-GPS privacy review;
  BLE dev-build requirement; QR-only subset as a cheaper path)

Reviving is `git mv docs/ideas/<name> openspec/changes/<name>` and
running /opsx:apply, same pattern as self-host-overpass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:23:44 +02:00
Ullrich Schäfer
29cbcef844
Merge pull request #253 from trails-cool/feat/route-empty-state-cta
Show empty-state CTA on Route detail when no waypoints
2026-04-19 08:21:17 +02:00
Ullrich Schäfer
5b40bd9b00
Show empty-state CTA on Route detail page when no waypoints
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.

Changes:
- Detect "empty" as no geometry AND no distance (the state every new
  route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
  / Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
  viewer-only copy), primary "Open in Planner" button (same action as
  the existing top-row button, just visually prominent), and a
  secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.

No non-empty route loses any UI — those paths stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:13:24 +02:00
Ullrich Schäfer
1158080b26
Merge pull request #252 from trails-cool/feat/reaccept-terms
Prompt users with stale terms_version to re-accept
2026-04-19 08:09:34 +02:00
Ullrich Schäfer
f16e80a2eb
Prompt users with stale terms_version to re-accept
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.

Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
  writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
  throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
  request is already on an allow-listed path
  (/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
  and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
  acceptance and bounces to a sanitised returnTo). Same-origin check
  on returnTo to avoid open-redirect abuse. Logout button is provided
  as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
  successful re-accept, missing consent, returnTo sanitisation).

No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:06:16 +02:00
Ullrich Schäfer
18fc023cb6
Merge pull request #251 from trails-cool/docs/legal-archive
Add docs/legal-archive + scripts/render-legal.ts
2026-04-19 07:59:44 +02:00
Ullrich Schäfer
374102e633
Make scripts/ a pnpm workspace + add README
render-legal.ts needs @types/node available to the TS language server
so editors (VS Code, cursor, etc.) don't complain about missing Node
globals. Without a tsconfig in this folder VS Code falls back to a
default config with no types and shows red squiggles on every
process/fs/path reference.

Solution: scripts/ becomes its own pnpm workspace (@trails-cool/scripts),
carrying @types/node as a devDep from the catalog, and a tiny
tsconfig.json with types: [node] and include *.ts. This also wires the
folder into `pnpm typecheck` as task #14, so CI type-checks scripts
alongside every other package. No runtime impact — the workspace is
never bundled or deployed.

README documents the layout, current contents (render-legal.ts +
check-dockerfiles.sh), and how to add new scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:56:01 +02:00
Ullrich Schäfer
2abc207c2f
Convert render-legal to TypeScript
The repo is a Node/TS stack, so a Python renderer is out of place.
Port the script to TypeScript, runnable via
`node --experimental-strip-types scripts/render-legal.ts <doc>`, and
update the README to match.

Verified byte-identical output against the Python version before
removing it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:52:53 +02:00
Ullrich Schäfer
d52828543a
Seed docs/legal-archive with Terms / Privacy / Imprint snapshots
Adds a simple legal-archive folder that preserves the full text of the
Terms, Privacy Policy, and Impressum at each version, plus a small
renderer script so future snapshots are mechanical:

    python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md

Why:
- Terms are per-user contract. users.terms_version maps each user to a
  file in this folder so we can always tell what text they agreed to.
- Privacy is informational but GDPR Art. 13/14 still expects us to be
  able to say what the policy disclosed on a given date.
- Impressum archived for symmetry; cheap.

How it works:
- scripts/render-legal.py reads the TSX source, resolves operator.*
  placeholders from operator.ts and TERMS_VERSION / PRIVACY_LAST_UPDATED
  from legal.ts, strips JSX tags, and prints clean markdown to stdout.
- A README in the folder explains when to bump and when to add a
  snapshot (material change only, not typo fixes).

Seeded entries:
- terms-2026-04-19.md
- privacy-2026-04-19.md
- imprint-2026-04-19.md

No automation / CI check yet — trust-based process documented in the
folder's README. Revisit if discipline slips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:51:41 +02:00
Ullrich Schäfer
c6c5ad3765
Merge pull request #250 from trails-cool/feat/terms-version
Store Terms version alongside acceptance timestamp
2026-04-19 07:49:28 +02:00
Ullrich Schäfer
9c4c3d6444
Store Terms version alongside acceptance timestamp
Reviewer's follow-up: it's not enough to record when a user accepted
the Terms; we also need to record which version of the text they saw.

Changes:
- journal.users gains a nullable `terms_version` text column (nullable
  so the three pre-existing users without a version are kept as-is).
- New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single
  source of truth, reused by the legal pages' "Last updated" header
  and by the registration flow as the value to send/store.
- Registration form posts `termsVersion` alongside `termsAccepted` on
  all three relevant steps (start, finish, register-magic-link).
- API route validates that `termsVersion` is a non-empty string on
  any step that requires terms, and forwards it to the auth server.
- auth.server finishRegistration and registerWithMagicLink now take
  `termsVersion` and persist it on the users row.
- journal-auth spec gets a new scenario for version storage and a
  rejection scenario for missing version.

PRIVACY_LAST_UPDATED is also exported from the same module and used
by the Privacy page header, keeping both pages on a single legal.ts
source of truth for "last updated" labels. Privacy is not per-user
stored — it's informational, not contract.

Existing users have NULL terms_version; if we ever prompt them to
re-accept updated Terms, we can backfill with the version they
re-accept at that point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:46:34 +02:00
Ullrich Schäfer
c532a1024b
Merge pull request #249 from trails-cool/docs/park-self-host-overpass
Park self-host-overpass spec under docs/ideas
2026-04-19 07:41:56 +02:00
Ullrich Schäfer
c4874dc04c
Park self-host-overpass spec under docs/ideas
The interim proxy (#239/#240/#242/#243) covers the day-one needs for
Overpass — User-Agent compliance via server-side proxy, same-origin +
rate limit + cache with coalescing, bbox quantization, observability.
Further work on self-hosting the upstream is no longer urgent.

Move the full OpenSpec artifact set out of openspec/changes/ so it
doesn't clutter the active change list, and park it under
docs/ideas/self-host-overpass/ as a reference for when we revive it.
Adds a short README at the new location capturing:
- current interim solution
- triggers that would justify reviving (rate limits, >1 req/s, etc.)
- key decisions already made (Hetzner-to-Hetzner firewall model,
  DOCKER-USER chain, capacity ceiling at DACH on current box)
- how to switch when the time comes (flip OVERPASS_URL, no client
  changes)

The files were never committed in the first place (WIP in the working
tree through the proposal session), so no git history to preserve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:40:44 +02:00
Ullrich Schäfer
4966ded583
Merge pull request #248 from trails-cool/docs/realign-legal-specs
Realign legal-disclaimers spec with post-review implementation
2026-04-19 07:36:25 +02:00
Ullrich Schäfer
2c512ce75e
Realign legal-disclaimers spec with post-review implementation
Fixes one contradiction and closes five gaps where the code (after the
legal-review rounds in #235, #246, #247) now exceeds what the spec
required.

Contradiction fixed:
- Alpha banner was specified as dismissible via sessionStorage, with
  "reappears in a new session". In reality the dismiss control and
  sessionStorage were removed during privacy hardening — the banner
  is now always visible. Updated the requirement to match.

New scenarios documenting what's already shipped:
- Impressum: EU ODR link + non-participation notice; English
  translation noted as informational.
- Terms of Service: service availability, minimum age 16 for Journal
  accounts, content usage-rights licence, acceptable-use rules
  (including no mass automated data extraction), German-law-tiered
  liability, bilingual structure.
- Datenschutzerklärung: explicit data-categories-and-purposes list;
  Terms acceptance classified under Art. 6(1)(b) and explicitly NOT
  (1)(a); dedicated server-logs section (≤14 days, Art. 6(1)(f));
  storage-durations list; alpha-reset caveat; per-third-party
  disclosures with transfer details (Sentry, OSM tile servers,
  Overpass proxy, BRouter, SMTP); Art. 44 ff. DSGVO third-country
  transfer note for Sentry with SCC basis; Berlin supervisory
  authority; Privacy Manifest appendix.
- Footer: now includes a link to the source repository on both apps.

No functional changes — only spec text catching up with what is live
in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:33:27 +02:00
Ullrich Schäfer
b92367147f
Merge pull request #247 from trails-cool/docs/legal-followups
Apply minimal legal-review follow-ups
2026-04-19 07:31:00 +02:00
Ullrich Schäfer
bc311ab7bc
Apply minimal legal-review follow-ups
Privacy:
- Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry
  is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen
  werden nicht aktiv gespeichert" per reviewer's preferred wording.
  Drop the unverified "Frankfurt" hosting claim; keep
  Auftragsverarbeiter framing.
- SMTP → "externer SMTP-Dienst" per reviewer's phrasing.
- Storage-durations section: add explicit alpha-reset caveat so it
  aligns with Terms §4 (database resets).

Terms:
- § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser
  Dienst" (leaves pricing model open without promising free-forever).
- § 7 acceptable use: scraping clause softened to "kein massenhaftes
  automatisiertes Auslesen von Daten" (drops the specific "to build
  competing services" qualifier).

Imprint:
- Normalise address formatting: multi-line across both DE blocks and
  the EN § 5 TMG block (§ 18 MStV and EN block previously used a
  comma-separated single line). One style only, everywhere.
- Add whitespace-nowrap to the mailto link so the email address
  never breaks mid-word on narrow viewports.

No changes to legal bases, server-logs section, storage durations
themselves, or any third-party other than Sentry + SMTP wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:27:56 +02:00
Ullrich Schäfer
c9758a65d2
Merge pull request #246 from trails-cool/docs/legal-rewrite
Rewrite legal pages per legal review feedback
2026-04-18 03:00:39 +02:00
Ullrich Schäfer
1462c54df7
Rewrite legal pages per legal review feedback
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
  required statement that we don't participate in consumer dispute
  resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
  repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.

Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
  purposes → Legal bases → Server logs → Storage durations → Third
  parties → Rights → Complaint authority → (plain-language) Privacy
  Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
  to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
  contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
  / status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
  magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
  browser sends IP+UA directly to OSM tile servers, with OSMF privacy
  policy link), Overpass (via our proxy, upstream only sees our
  server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
  the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
  aligned with current reality (no cookies/localStorage/sessionStorage
  in Planner; no replays; no PII via Sentry).

Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
  has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
  modify/limit/interrupt/discontinue the service or individual
  accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
  operator a limited, non-transferable licence to store/process/
  display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
  (standard German DACH tiering: unlimited for intent/gross neg./life-
  body-health; limited to foreseeable damages on cardinal-duty breach
  for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:57:29 +02:00
Ullrich Schäfer
559642ea30
Merge pull request #245 from trails-cool/fix/grafana-overpass-panel-queries
Fix Overpass health + cache-hit stat panel queries
2026-04-18 02:47:23 +02:00
Ullrich Schäfer
278e74f08e
Fix Overpass health + cache-hit stat panels: vector(0) not clamp_min
The original formulas used clamp_min(denominator, 1) to avoid divide-
by-zero, but that inflates the denominator when request rate is < 1/s.
Real example from prod: ~0.04 req/s, all successful, displayed as
1.02% health (red) instead of ~100%.

Switch to (numerator or vector(0)) / denominator:
- Missing "hit" series (no cache hits yet) → numerator resolves to 0
  instead of empty set, so the stat shows 0% instead of "No data".
- Zero traffic (empty denominator) → whole expr is empty, Grafana
  renders "No data", which is the correct behaviour for a health
  stat when nothing's happening.
- Non-zero low traffic → ratio is true ratio, not artificially
  depressed by the clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:42:50 +02:00
Ullrich Schäfer
7a61550ddd
Merge pull request #244 from trails-cool/docs/privacy-manifest-update
Align privacy manifest with Sentry + Overpass hardening
2026-04-18 02:35:40 +02:00
Ullrich Schäfer
248849318f
Align privacy manifest with Sentry + Overpass hardening
Three sections drifted from the actual behaviour of the deployed apps:

Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
  removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.

Sentry section:
- Drops the "session replays on error" claim — replay integration is
  not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
  are both 0.
- Documents the actual scope: Journal server-side always; Journal
  client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
  and that only the user ID is attached to logged-in Journal errors.

Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
  through our own /api/overpass so the upstream host sees our server,
  not end users.

Also bumps "Last updated" to today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:32:48 +02:00
Ullrich Schäfer
6bba7e31a7
Merge pull request #243 from trails-cool/fix/overpass-proxy-forwarded-origin
Fix /api/overpass 403 behind Caddy reverse proxy
2026-04-18 02:25:28 +02:00
Ullrich Schäfer
2f5ce0e80e
Merge branch 'main' into fix/overpass-proxy-forwarded-origin 2026-04-18 02:22:30 +02:00
Ullrich Schäfer
9925880019
Merge pull request #241 from trails-cool/archive-specs
Archive legal-disclaimers change and sync specs
2026-04-18 02:22:11 +02:00
Ullrich Schäfer
48dfa059fd
Merge pull request #242 from trails-cool/feat/overpass-bbox-quantize
Quantize Overpass query bbox to align cache keys across clients
2026-04-18 02:21:57 +02:00
Ullrich Schäfer
e11c9ab58b
Fix /api/overpass 403 behind Caddy reverse proxy
The same-origin check compared the browser's Origin header against
new URL(request.url), but inside the Planner container request.url
is http://planner:3001/... while the browser sends Origin
https://planner.trails.cool — so production always 403'd.

Trust Caddy's X-Forwarded-Host and X-Forwarded-Proto headers when
present (both are set by Caddy's reverse_proxy directive by default)
to reconstruct the external origin the browser actually connected to.
Falls back to request.url for dev and any direct (non-proxied) access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:21:31 +02:00
Ullrich Schäfer
6c1148d5b7
Quantize Overpass query bbox to align cache keys across clients
Near-identical viewports from different collaborators (slightly
different pan/zoom because Yjs awareness doesn't enforce pixel-exact
alignment) previously produced byte-different Overpass queries and
therefore different server-side cache keys. Each client missed cache
and triggered its own upstream fetch.

buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding
outward (south/west floor, north/east ceil) so the viewport is always
covered, and formats coords with 3 decimals for a stable string.

Trade-offs:
- Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on
  each side. For a zoom-12 viewport (~11 km wide) that's <10% extra
  data; bounded by the existing `[maxsize:1048576]` + `out 100` limits.
- Coordinates in the query are now 3-decimal strings regardless of the
  caller's precision — existing buildQuery test updated to match.

Two new tests cover the cache-alignment property and the outward-
expansion invariant. Hit ratio impact will be visible on the Grafana
"Overpass Cache Hit Ratio" stat added in the prior PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:18:46 +02:00
Ullrich Schäfer
21a00afa85
Archive legal-disclaimers change and sync specs
Move completed legal-disclaimers change to archive, apply deltas to
main specs: append "Terms acknowledgement at signup" requirement to
journal-auth and create the legal-disclaimers capability spec
covering Impressum, Terms, Datenschutzerklärung, alpha banner, and
footer links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:17:48 +02:00
Ullrich Schäfer
97ad15db98
Merge pull request #240 from trails-cool/feat/overpass-metrics
Add Prometheus metrics + Grafana panels for Overpass proxy
2026-04-18 02:15:15 +02:00
Ullrich Schäfer
62e208d850
Add Prometheus metrics + Grafana panels for Overpass proxy
Adds four metrics surfaced via the existing /metrics endpoint:
- overpass_cache_events_total{result=hit|miss|coalesced}
- overpass_cache_size (gauge)
- overpass_upstream_duration_seconds (histogram)
- overpass_upstream_requests_total{status} — covers 2xx/4xx/5xx and
  an explicit "error" label for network-level failures

Grafana dashboards/planner.json gets a new row:
- Upstream health (5m 2xx success ratio, red <90%, green >99%)
- Cache hit ratio
- Cache size
- Upstream p95 latency
Plus timeseries panels for cache event breakdown, upstream status
over time, and p50/p95/p99 upstream latency.

The success-rate formula uses clamp_min(..., 1) on the denominator so
it doesn't NaN when traffic is zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:12:19 +02:00
Ullrich Schäfer
febdf6a9d3
Merge pull request #239 from trails-cool/feat/overpass-proxy
Route Overpass through server-side proxy with cache + coalescing
2026-04-18 02:08:09 +02:00
Ullrich Schäfer
a4df5a43f6
Route Overpass through server-side proxy with cache + coalescing
Adds apps/planner/app/routes/api.overpass.ts — a same-origin, rate-limited
server-side proxy that forwards Overpass QL to the upstream endpoint
configured via OVERPASS_URL (default: overpass.private.coffee).

Motivation:
- private.coffee's best-practices require a meaningful User-Agent
  identifying the project. Browsers cannot set User-Agent on fetch()
  (forbidden header), so the request has to originate server-side.
- Collaborative sessions commonly have N clients pan/zoom the same map,
  so the same bbox query arrives multiple times within seconds.
- Setting up the proxy now lets us swap OVERPASS_URL to a self-hosted
  Overpass later without client changes.

What the proxy does:
- Sets User-Agent "trails.cool Planner (https://trails.cool; legal@trails.cool)"
- Enforces same-origin via the Origin header
- Rate-limits per client IP (120 req/min)
- In-memory LRU cache of upstream responses keyed on the form-encoded body
  (TTL 10 min, max 200 entries)
- Coalesces concurrent misses for the same key so N simultaneous clients
  in one session incur exactly one upstream call

Client change: apps/planner/app/lib/overpass.ts POSTs to /api/overpass
instead of iterating over public Overpass endpoints. Fallback list
removed; resilience is now the proxy's responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:04:50 +02:00
Ullrich Schäfer
bf8cdd5cdd
Merge pull request #238 from trails-cool/fix/overpass-endpoint
Use overpass.private.coffee as primary Overpass endpoint
2026-04-18 01:51:04 +02:00
Ullrich Schäfer
f7c2604121
Use overpass.private.coffee as primary Overpass endpoint
Switch the Planner's primary Overpass endpoint from
overpass.kumi.systems to overpass.private.coffee. private.coffee is a
privacy-focused public Overpass instance that doesn't log queries,
which fits the Planner's privacy-first principle better than the
previous default. Keep overpass-api.de as the silent fallback for
resilience while the self-host-overpass change is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 01:48:09 +02:00
Ullrich Schäfer
32a844d8de
Merge pull request #235 from trails-cool/feat/legal-disclaimers
Add legal pages, ToS acceptance, and alpha banner
2026-04-18 00:46:36 +02:00
Ullrich Schäfer
4c089756a1
Fix sentry-config typecheck: explicit types: [node] in tsconfig
@types/node is a devDependency of the package but tsc only auto-picks
it up locally due to root node_modules hoisting. CI runs with pnpm's
isolated resolution so the dep isn't visible to tsc without an
explicit types field — matching what packages/jobs/tsconfig.json does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:43:48 +02:00
Ullrich Schäfer
01f002cc46
Stop Sentry on logout; fix E2E + Dockerfile for merged consolidation
- sentry.client.ts: add stopSentryClient() that awaits Sentry.close() so
  the SDK tears down when a user logs out. Hub methods become no-ops
  until initSentryClient() is called again on next login.
- root.tsx: call stopSentryClient() from the user-effect when user === null.
- Add sentry-config to journal + planner Dockerfiles (was missing after
  #237 introduced the new workspace package, breaking the Dockerfile
  package check).
- Footer: replace inner <nav> with <div> — nested nav landmarks broke
  the journal "nav bar shows on all pages" e2e test (two navigation
  roles on the page).
- e2e auth: tick the required ToS checkbox in the shared registerUser
  helper so passkey registration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:40:36 +02:00
Ullrich Schäfer
adbf507101
Merge pull request #237 from trails-cool/feat/sentry-consolidation-v2
Consolidate Sentry config; fix Planner PII & missing captureException
2026-04-18 00:31:25 +02:00
Ullrich Schäfer
8e576ac578
Consolidate Sentry config into shared package; fix inconsistencies
Introduces @trails-cool/sentry-config with three helpers that encode
the common Sentry.init options:
- nodeSentryConfig(appContext) for the two HTTP servers
- browserSentryConfig(appContext, env) for the Journal client
- mobileSentryConfig(__DEV__) for the React Native app
- drop404s beforeSend for servers

Also fixes three inconsistencies found in the audit:
- Planner server was missing sendDefaultPii: false (IPs could leak)
- Planner entry.server.tsx didn't call Sentry.captureException on SSR
  render errors; Journal's SSR did
- Sentry.init location now matches across apps (both in server.ts;
  Journal previously had it in app/entry.server.tsx, which meant init
  ran lazily on first request rather than at server startup)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:30:09 +02:00
Ullrich Schäfer
27c9892bab
Merge pull request #236 from trails-cool/feat/sentry-dev-log
Log Sentry dev-inert status; explicit replay=0
2026-04-18 00:15:48 +02:00
Ullrich Schäfer
4092f21475
Log Sentry dev-inert status; explicit replay=0
When Sentry init runs but is disabled (local dev / CI), log a debug
message so developers can see Sentry *would* send in production.

Also explicitly set replaysSessionSampleRate/replaysOnErrorSampleRate
to 0 in the client and mobile configs. Replay integration isn't
installed, so this is documentation — it prevents future accidental
enablement if an integration is ever added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:15:31 +02:00
Ullrich Schäfer
cc0f44258a
Add legal pages, ToS acceptance, and alpha banner
- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:08:48 +02:00
Ullrich Schäfer
6c1cd23358
Merge pull request #232 from trails-cool/feat/login-code
Add login code for mobile authentication
2026-04-17 22:53:43 +02:00
Ullrich Schäfer
a056ec286b
Merge branch 'main' into feat/login-code 2026-04-17 22:38:57 +02:00
Ullrich Schäfer
1397eb0d1d
Merge pull request #233 from trails-cool/spec/legal-disclaimers
Spec: Legal disclaimers (Impressum, Terms, alpha status)
2026-04-17 22:32:24 +02:00
Ullrich Schäfer
b58d899132
Merge branch 'main' into spec/legal-disclaimers 2026-04-17 22:29:44 +02:00
Ullrich Schäfer
d06ce8d77d
Merge pull request #234 from trails-cool/fix/main-ci-broken
Fix CI on main: typecheck and lint errors
2026-04-17 22:29:23 +02:00
Ullrich Schäfer
258e1bb5e5
Fix CI on main: RouteMap typecheck + metro.config lint
- RouteMap.tsx referenced undefined MapLibreRN namespace, causing
  typecheck to fail on main and in every downstream PR
- metro.config.js is CommonJS, exclude from ESLint flat config

Changes that were pushed directly to main earlier bypassed CI and
broke both typecheck and lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 22:26:38 +02:00
Ullrich Schäfer
680815e1f6 Add OpenSpec proposal for legal disclaimers
Proposal, design, specs, and tasks for German legal compliance (Impressum,
Datenschutzerklärung, ToS) and alpha-stage disclaimers at signup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 22:12:23 +02:00
Ullrich Schäfer
3bcb1fce7c
Add 6-digit login code for mobile authentication
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:56:42 +02:00
Ullrich Schäfer
2a94af5655
Add Metro config for monorepo module resolution
Metro couldn't resolve hoisted packages (@maplibre, @gorhom, etc.)
because it only looked in apps/mobile/node_modules. Configure
watchFolders and nodeModulesPaths to include the monorepo root.

Also revert MapLibre fallback — direct import is correct, the issue
was Metro resolution, not a missing native module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:39:04 +02:00
Ullrich Schäfer
58bff065ff
Gracefully handle missing MapLibre native module
Use try/catch require() for MapLibre so the app works without it
(shows fallback UI). Needed when running on builds that don't include
MapLibre native module (old EAS builds, Expo Go).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:28:25 +02:00
Ullrich Schäfer
d74a19afb6
Merge pull request #231 from trails-cool/fix/route-compute-auth
Fix route compute 401: use authenticated API client
2026-04-15 07:15:29 +02:00
Ullrich Schäfer
5c4c0ae49d
Fix route compute 401: use authenticated API client
The route editor called fetch() directly for /api/v1/routes/compute
without the bearer token. Now uses the API client which injects auth
headers and handles 401 auto-refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:12:13 +02:00
Ullrich Schäfer
3ad2ef9006
Merge pull request #230 from trails-cool/fix/flaky-overnight-e2e
Fix flaky overnight E2E test timeout
2026-04-15 02:30:55 +02:00
Ullrich Schäfer
b7fe68359c
Fix flaky overnight E2E test: increase timeouts
The "Waypoints (3)" assertion timed out at 5s in CI. The waypoints
load from URL params but the sidebar needs BRouter mock response +
Yjs sync before updating. Increase to 15s to match other assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:27:59 +02:00
Ullrich Schäfer
c838bd1efc
Merge pull request #229 from trails-cool/fix/android-dev-networking
Allow cleartext HTTP on Android for dev server
2026-04-15 02:25:38 +02:00
Ullrich Schäfer
310f20e0a3
Allow cleartext HTTP on Android for dev server
Android 9+ blocks cleartext HTTP by default. Enable usesCleartextTraffic
so the emulator can reach the Journal dev server at http://10.0.2.2:3000.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 02:21:06 +02:00
Ullrich Schäfer
64b2372a13
Merge pull request #228 from trails-cool/feat/mobile-route-editing
Mobile route editor, MapLibre, bottom sheet, and local builds
2026-04-15 01:26:09 +02:00
Ullrich Schäfer
6c55de9d65
Fix Android emulator dev server: use 10.0.2.2 instead of localhost
Android emulator has its own network stack — localhost refers to the
emulator, not the host. Use 10.0.2.2 (host loopback alias) on Android.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:22:50 +02:00
Ullrich Schäfer
b2514f48be
Add local build scripts via Expo CNG/prebuild
- prebuild: generate native ios/ and android/ projects
- prebuild:clean: regenerate from scratch
- run:ios / run:android: build and run locally with Xcode/Gradle
- Add ios/ and android/ to .gitignore (CNG-generated)

Allows building locally without EAS credits:
  pnpm --filter @trails-cool/mobile prebuild
  pnpm --filter @trails-cool/mobile run:ios

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:19:37 +02:00
Ullrich Schäfer
bdde95e69b
Use @gorhom/bottom-sheet for waypoint sheet
Replace custom View-based bottom sheet with @gorhom/bottom-sheet:
- Native gesture-driven drag, snap points, pan-down-to-close
- Proper safe area handling built in
- Wrap app with GestureHandlerRootView
- Add react-native-reanimated plugin to Expo config

Adds react-native-reanimated and react-native-gesture-handler as
native dependencies (requires new EAS build).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:09:13 +02:00
Ullrich Schäfer
fe07fbdf59
Fix WaypointSheet safe area: add bottom inset padding
Close button was overlapping the home indicator. Now uses
useSafeAreaInsets() for dynamic bottom padding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:10 +02:00
Ullrich Schäfer
d45f1e14fd
Fix route display: parse track coordinates from GPX on init
The route polyline wasn't visible because segments were initialized
empty. Now extractSegmentsFromGpx() parses trkpt coordinates from
the GPX string via regex on init, so the route shows immediately
in both view and edit modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:10 +02:00
Ullrich Schäfer
bd311cd339
Implement mobile route editor with MapLibre and BRouter routing
Route editing (Phase 3.3):
- useRouteEditor hook: waypoint state, add/move/delete, BRouter
  routing via Journal API proxy, GPX generation, save to API
- RouteMap component: MapLibre Native with OSM raster tiles,
  route polyline (ShapeSource + LineLayer), waypoint markers
  (MarkerView), long-press to add waypoints, computing indicator
- WaypointSheet: bottom sheet for waypoint actions — delete with
  confirmation, overnight stop toggle, coordinates display
- Route detail screen: view mode (map preview + stats) and edit
  mode (full-screen map with save button)
- Unsaved changes guard on navigation (beforeRemove listener)
- Smart waypoint insertion at nearest route segment
- Waypoint extraction from GPX via regex (sync, no DOM needed)

Adds DOM.Iterable to mobile tsconfig for gpx package compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:06:10 +02:00
Ullrich Schäfer
62543cd629
Merge pull request #225 from trails-cool/spec/staging-environments
Spec: Staging & PR preview environments
2026-04-15 01:03:49 +02:00
Ullrich Schäfer
448fa8b9b6
Merge branch 'main' into spec/staging-environments 2026-04-15 01:01:05 +02:00
Ullrich Schäfer
d2842937bd
Merge pull request #227 from trails-cool/fix/journal-dockerfile-no-jobs
Fix journal Dockerfile: remove premature app/jobs COPY
2026-04-14 12:33:31 +02:00
Ullrich Schäfer
6cb9a81307 Extend Dockerfile CI check to verify COPY source paths exist
The check now also verifies that source paths referenced by
COPY --from=build lines actually exist in the repo. This would have
caught the premature app/jobs COPY that broke the journal build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:27:18 +02:00
Ullrich Schäfer
8886764ec8 Remove premature app/jobs COPY from journal Dockerfile
The journal doesn't have an app/jobs directory yet — that will arrive
with the Komoot import PR. The COPY line was added prematurely and
breaks the Docker build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:26:10 +02:00
Ullrich Schäfer
a3e97be662
Merge pull request #226 from trails-cool/fix/dockerfile-jobs-package
Fix Dockerfiles: add jobs package for pg-boss resolution
2026-04-14 12:18:32 +02:00
Ullrich Schäfer
7c207a2a98 Add CI check for missing workspace packages in Dockerfiles
Adds a script that verifies every packages/*/package.json is listed in
all app Dockerfiles. Catches the recurring issue where adding a new
workspace package breaks production because the Dockerfile deps stage
doesn't know about it.

Also fixes two pre-existing missing packages in the planner Dockerfile
(api, map-core) caught by the new check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:15:25 +02:00
Ullrich Schäfer
fe57a690c0 Fix Dockerfiles: add jobs package for pg-boss resolution
Both Dockerfiles were missing packages/jobs/package.json in the deps stage,
causing pnpm install to skip pg-boss. Also copy app/jobs directories into
the runtime stage so job handlers are available at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:13:09 +02:00
Ullrich Schäfer
29f9808557
Merge pull request #224 from trails-cool/feat/pg-boss-background-jobs
Add pg-boss background job queue with session expiry
2026-04-14 09:11:45 +02:00
Ullrich Schäfer
f75dd12f59 Fix pg-boss queue creation before work registration
pg-boss v10 requires explicit queue creation via `createQueue()` before
`schedule()` or `work()` can reference a queue. Without this, the planner
crashes on startup in fresh databases (like CI) with "Queue not found".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:08:19 +02:00
Ullrich Schäfer
39ebaa9841 Add OpenSpec proposal for staging environments
Proposal, design, specs, and tasks for persistent staging instance and
ephemeral PR preview environments on the existing Hetzner server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:02:23 +02:00
Ullrich Schäfer
32c5fbde8f Add pg-boss background job queue with session expiry
Add @trails-cool/jobs package wrapping pg-boss for durable background job
execution using the existing PostgreSQL database. Wire up planner session
expiry as the first scheduled job (hourly, 7-day TTL) — this was previously
dead code that never ran. Add placeholder worker to journal for future
Komoot import and federation jobs.

Infrastructure: grant grafana_reader access to pgboss schema, add job queue
health panels to Service Health dashboard, add failed job alert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:17:24 +02:00
Ullrich Schäfer
fc80e66ad4
Merge pull request #222 from trails-cool/fix/observability-and-crash-fix
Fix journal crash loop and improve observability
2026-04-13 20:43:21 +02:00
Ullrich Schäfer
fda376267c
Merge pull request #221 from trails-cool/feat/mobile-activities-ui
Configure EAS Build, native deps, and Sentry
2026-04-13 02:33:03 +02:00
Ullrich Schäfer
b465081e1b
Fix CI: add react-test-renderer to match react version
The smoke test failed in CI with version mismatch between react
(19.2.5 from catalog) and react-test-renderer (19.2.0 transitive).
Adding it explicitly ensures they stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:30:00 +02:00
Ullrich Schäfer
57ca9b8d0d
Update Sentry auth token with org:ci scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:29:33 +02:00
Ullrich Schäfer
04c8995d7c
Update Sentry auth token with org:ci scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:27:53 +02:00
Ullrich Schäfer
3c172c1e56
Fix CD: add api and map-core packages to Journal Dockerfile
The journal now depends on @trails-cool/api which depends on zod.
The Dockerfile was missing these package.json files, causing pnpm
install to skip them and the build to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:27:09 +02:00
Ullrich Schäfer
19c2358c88
Fix CD: add api and map-core packages to Journal Dockerfile
The journal now depends on @trails-cool/api (added in PR #217) which
depends on zod. The Dockerfile was missing the package.json for api
and map-core, causing pnpm install to skip them and the build to fail
with "Failed to resolve import zod".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:23:09 +02:00
Ullrich Schäfer
3da7b8d41f
Fix lockfile sync: add @expo/fingerprint to root devDependencies
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:21:03 +02:00
Ullrich Schäfer
1414924746
Enable Sentry source map upload in EAS builds
- Remove SENTRY_DISABLE_AUTO_UPLOAD from all build profiles
- Add @sentry/cli as explicit dependency to fix pnpm module
  resolution in EAS cloud builds
- SENTRY_AUTH_TOKEN configured as EAS environment secret

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:12:36 +02:00
Ullrich Schäfer
42a9c29c84
Set up Sentry crash reporting for mobile app
- Initialize @sentry/react-native with dedicated mobile project DSN
- Wrap root layout with Sentry.wrap() for unhandled error capture
- Configure Sentry plugin with org (trails-qq) and project (mobile)
  for source map uploads during EAS builds
- Disabled in dev, full tracing in production

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 02:07:44 +02:00
Ullrich Schäfer
dd06d2f231
Add all native dependencies for remaining phases
Batch-install native modules to minimize EAS build credits:
- @maplibre/maplibre-react-native — map view for routes (Phase 3)
- @sentry/react-native — crash reporting and error tracking
- expo-sqlite — offline route storage (Phase 5)
- expo-notifications — push notifications (Phase 7)
- expo-location — GPS for activity recording (future)

All registered as Expo config plugins. Requires one new EAS build
to include the native modules.

Also:
- Use npx eas-cli in build scripts (not global eas)
- Add pnpm dev:ios / dev:android root scripts using eas build:dev
- Fix expo-doctor: add expo-constants, expo-linking, dedupe react

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:49:40 +02:00
Ullrich Schäfer
204fd7084b
Configure EAS Build and fix expo-doctor issues
- Add eas.json with development, preview, and production profiles
- Link project to EAS (projectId: 93c75cae-fecf-4ce5-8cd8-c823760b12e2)
- Add expo-dev-client, expo-constants, expo-linking dependencies
- Remove eas-cli from project deps (use globally)
- Add ITSAppUsesNonExemptEncryption to iOS infoPlist
- Use catalog react with expo.install.exclude to skip version check
- Fix slug to match EAS project registration
- Run pnpm dedupe to resolve duplicate react across workspaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:35:15 +02:00
Ullrich Schäfer
00d9800214
Merge pull request #220 from trails-cool/feat/mobile-routes-ui
Mobile Routes UI: list, detail, and Zod-validated API
2026-04-13 01:18:55 +02:00
Ullrich Schäfer
2f99ce926a
Add route detail screen with metadata and action buttons
Route detail at /routes/:id shows:
- Header with back navigation and route name
- Map placeholder (MapLibre requires EAS dev build)
- Stats row: distance, elevation gain/loss, day count
- Description text
- Version count
- "Edit in Planner" opens Journal web UI
- "Download Offline" button (handler pending Phase 5)

Safe area insets applied to header and scroll content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:15:41 +02:00
Ullrich Schäfer
77e3c1b1ee
Build Routes tab with paginated list and Zod-validated API responses
Routes tab (Phase 3.1):
- Paginated FlatList fetching from Journal REST API
- Route cards with name, distance (km), elevation gain, day count
- Pull-to-refresh, loading spinner, error state with retry, empty state
- Safe area insets for notch and tab bar

API client:
- Import types from @trails-cool/api instead of hand-written interfaces
- Parse list/detail responses through Zod schemas at runtime
- Add zod as mobile app dependency

i18n:
- Add mobile namespace (en + de) for routes, activities, profile, login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:15:40 +02:00
Ullrich Schäfer
05659bcfc1
Merge pull request #219 from trails-cool/feat/journal-rest-api
Implement Journal REST API v1 endpoints
2026-04-13 01:13:05 +02:00
Ullrich Schäfer
7b2fe1ce80
Fix typecheck: add missing unstable_url and unstable_pattern to route test args
React Router 7 now requires these properties on loader/action args.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:10:23 +02:00
Ullrich Schäfer
55463d1f56
Add mocked unit tests for REST API route handlers
Tests the full handler flow with mocked DB: auth guard (401), Zod
validation (400), successful responses, and 404s for missing resources.
Also adds ~ alias resolution to Journal vitest config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:59:45 +02:00
Ullrich Schäfer
234ab9f8c5
Add unit tests for REST API validation and pagination
Tests Zod schema validation for all API endpoints (routes, activities,
compute, uploads), error codes, and cursor pagination logic. 21 new
tests covering the validation layer without requiring a running DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:58:18 +02:00
Ullrich Schäfer
18c3c37eaf
Implement Journal REST API v1 endpoints
Add all REST API endpoints for mobile app consumption:

Routes:
- GET /api/v1/routes — paginated list with cursor, Zod validation
- GET /api/v1/routes/:id — full detail with GPX, versions
- POST /api/v1/routes — create with Zod-validated body
- PUT /api/v1/routes/:id — update, creates new version
- DELETE /api/v1/routes/:id — returns 204

Activities:
- GET /api/v1/activities — paginated list with cursor
- GET /api/v1/activities/:id — full detail
- POST /api/v1/activities — create with GPX stat extraction
- DELETE /api/v1/activities/:id — returns 204

Supporting:
- POST /api/v1/routes/compute — BRouter proxy
- POST /api/v1/uploads — presigned upload URL generation

Device management:
- GET /api/v1/auth/devices — list connected devices with isCurrent
- DELETE /api/v1/auth/devices/:id — revoke device token
- Store device_name on token exchange

Infrastructure:
- requireApiUser() guard — returns 401 with structured error
- apiError() helper for consistent error responses
- All endpoints use Zod schemas from @trails-cool/api for validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:57:27 +02:00
Ullrich Schäfer
4db89d6091
Merge pull request #218 from trails-cool/feat/add-missing-package-tests
Add tests to all packages, remove passWithNoTests
2026-04-13 00:51:32 +02:00
Ullrich Schäfer
9420201932
Add tests to all packages, remove passWithNoTests
Every package now has tests — no more empty test suites:

- map-core: 33 tests — maxspeedColor, routeGradeColor, elevationColor,
  POI categories, getCategoriesForProfile, tile layer configs
- db: 5 tests — withDb error handling (503 on DB error, re-throws
  Response/DataWithResponseInit, logs errors)
- ui: 14 tests — Button variants/sizes/disabled, Input label/id/error,
  Card className forwarding
- map: 2 tests — baseLayers/overlayLayers re-export verification

Remove passWithNoTests from vitest.shared.ts so missing tests are
caught immediately when adding new packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:27 +02:00
Ullrich Schäfer
daf555dbee
Merge pull request #217 from trails-cool/feat/journal-oauth2-pkce
Implement OAuth2 PKCE auth and mobile API client
2026-04-13 00:44:59 +02:00
Ullrich Schäfer
1ae406a8aa
Implement OAuth2 PKCE auth, discovery, and mobile API client
Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:41:40 +02:00
Ullrich Schäfer
998db213d6
Merge pull request #216 from trails-cool/feat/monorepo-pipeline-standardization
Standardize monorepo pipeline across all workspaces
2026-04-13 00:13:37 +02:00
Ullrich Schäfer
c33c8da09b
Fix CI typecheck: disable noUncheckedSideEffectImports
TS 5.9 enables noUncheckedSideEffectImports under strict mode, which
errors on CSS side-effect imports (e.g. leaflet/dist/leaflet.css).
These imports are resolved by bundlers (Vite/Metro), not tsc. Set
noUncheckedSideEffectImports: false in the shared tsconfig base.

Reverts the unnecessary leaflet devDependencies on the map package
since this base config fix handles it properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:10:36 +02:00
Ullrich Schäfer
9f583e6777
Fix map typecheck in CI: add peer deps as devDependencies
leaflet, react-leaflet, and @types/leaflet are peer dependencies of
the map package. In CI, pnpm's strict isolation means they aren't
accessible from the package directory during standalone tsc. Adding
them as devDependencies ensures they're installed for typechecking
while consumers still provide them at runtime via peer deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:06:49 +02:00
Ullrich Schäfer
6edb972e91
Fix map package typecheck: allow CSS imports in tsc
The leaflet CSS side-effect import fails standalone tsc (works under
Vite). Enable allowArbitraryExtensions so tsc accepts .css imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:03:50 +02:00
Ullrich Schäfer
2ac4014521
Standardize monorepo pipeline: test, lint, typecheck across all workspaces
Previously only 2-3 workspaces participated in each turbo task. This
expands test, lint, and typecheck to cover all 11 workspaces with
parallel execution and caching.

Test pipeline:
- Move from single root vitest to per-workspace test scripts via turbo
- Shared vitest config (vitest.shared.ts) with passWithNoTests
- Per-workspace configs re-export the shared base
- Mobile uses Jest (jest-expo + React Native Testing Library)
- Add node-environment GPX tests verifying linkedom fallback
- Add i18n mobile init tests
- Exclude apps/mobile from root vitest (uses Jest separately)

Lint pipeline:
- Add eslint lint script to all 9 previously unlinted workspaces
- Standardize all scripts to "eslint ." with shared root config
- Add .expo/ to global ESLint ignores
- Fix lint errors: unused imports in api/types, export parseGpx

Typecheck pipeline:
- Add "typecheck": "tsc" to all 8 packages
- Add @types/node (catalog) to gpx and db packages
- Fix mobile app.config.ts: remove deprecated experiments.monorepo
  and newArchEnabled (both default in Expo SDK 55)
- Add allowImportingTsExtensions to mobile tsconfig

Shared package compatibility (mobile-app Phase 1.3):
- Add linkedom as explicit dependency to @trails-cool/gpx
- Add initI18nMobile() export to @trails-cool/i18n
- Confirm @trails-cool/types is pure interfaces (no DOM deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:00:43 +02:00
Ullrich Schäfer
423499d21d
Merge pull request #215 from trails-cool/feat/mobile-app-foundation
Initialize mobile app with Expo 55 and NativeTabs
2026-04-12 23:32:49 +02:00
Ullrich Schäfer
4738d77ebe
Use bicycle SF Symbol for Activities tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:30:18 +02:00
Ullrich Schäfer
70ff0652a5
Use NativeTabs for liquid glass tab bar on iOS 26
Switches from expo-router Tabs to NativeTabs (unstable API) which
renders native tab bars with liquid glass on iOS 26 and Material
bottom navigation on Android. Adds SF Symbols and Material icons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:24:51 +02:00
Ullrich Schäfer
065bc633ea
Add root scripts for mobile app development
- pnpm dev:mobile — start Expo dev server
- pnpm dev:mobile:ios — start on iOS simulator
- pnpm dev:mobile:android — start on Android emulator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:16:22 +02:00
Ullrich Schäfer
b762fc424b
Fix Expo 55 dependency compatibility
expo install --fix updated all deps to SDK 55 compatible versions:
- expo-router 5.0.7 → 55.0.12
- expo-status-bar 3.0.9 → 55.0.5
- react 19.1.0 → 19.2.0
- react-native 0.81.5 → 0.83.4
- react-native-safe-area-context 5.7.0 → 5.6.2
- react-native-screens 4.12.0 → 4.23.0
- @types/react 19.1.0 → 19.2.14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:13:54 +02:00
Ullrich Schäfer
acce158b99
Upgrade mobile app to Expo SDK 55
expo 54 → 55, expo-router 4 → 5, react-native-safe-area-context 5.4 → 5.7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:09:14 +02:00
Ullrich Schäfer
95fdbfee2f
Initialize Expo mobile app with tab navigation and monorepo support
- Expo managed project at apps/mobile/ with TypeScript
- Expo Router with 4 tabs: Map, Routes, Activities, Profile
- app.config.ts with monorepo experiment, bundle IDs, scheme
- Workspace packages linked: api, gpx, i18n, map-core, types
- Profile tab imports API_VERSION from @trails-cool/api to verify
  shared package resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:05:57 +02:00
Ullrich Schäfer
48f990f4ec
Mark Phase 1.2 API Contract Package tasks as complete
@trails-cool/api shipped in PR #214 — Zod schemas, endpoint constants,
API_VERSION, error types, pagination, auth, routes, activities, uploads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:59:13 +02:00
Ullrich Schäfer
d8dfaac66f
Merge pull request #214 from trails-cool/feat/api-package
Add @trails-cool/api package with Zod schemas
2026-04-12 22:56:58 +02:00
Ullrich Schäfer
47923040fc
Add unit tests for @trails-cool/api schemas (32 tests)
Tests cover all Zod schemas: validation of valid payloads, rejection
of invalid payloads, default values, coercion, nullable fields,
pagination limits, and endpoint path constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:53:41 +02:00
Ullrich Schäfer
49b304749e
Add @trails-cool/api package with Zod schemas for REST API contract
Single source of truth for the Journal REST API:
- API_VERSION semver constant (1.0.0)
- Endpoint path constants (routes, activities, auth, uploads, push)
- Zod schemas for all request/response shapes:
  - Routes: list, detail, create, update, compute
  - Activities: list, detail, create
  - Auth: token exchange, refresh, device management
  - Discovery: instance info + API version
  - Uploads: presigned URL flow
  - Errors: structured error response with field-level details
- Cursor-based pagination schemas
- TypeScript types inferred from Zod (z.infer)

Used by Journal server (validation) and mobile client (type safety).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:49:30 +02:00
Ullrich Schäfer
572d240ed1
Fix all openspec validation errors (45/45 pass)
Main specs (7): Converted from delta format (## ADDED/MODIFIED) to
proper main spec format (## Purpose + ## Requirements)

Changes (3): Added missing spec files for e2e-test-reorganization,
journal-poi-details, and mobile-nearby-sync

Mobile testing: Added missing scenario for "No Vitest" requirement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:41:51 +02:00
Ullrich Schäfer
8682ec8140
Archive map-core-package, sync spec to main
All 28 tasks complete. Spec synced to openspec/specs/map-core/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:32:54 +02:00
Ullrich Schäfer
5bacba8048
Merge pull request #213 from trails-cool/feat/map-core-package
Extract @trails-cool/map-core package
2026-04-12 22:31:20 +02:00
Ullrich Schäfer
60b94b5789
Extract @trails-cool/map-core package from Planner
New renderer-agnostic package with zero dependencies:
- Tile configs (base layers, overlay layers)
- Color palettes (surface, highway, smoothness, tracktype, cycleway,
  bikeroute, elevation, maxspeed) — 8 color maps + 3 color functions
- POI category definitions (9 categories with Overpass queries, icons,
  colors, profile mappings)
- Z-index layering constants
- Snap distance constant

Updated 15 consuming files to import from @trails-cool/map-core.
Deleted poi-categories.ts and z-index.ts from the Planner (fully moved).
packages/map re-exports tile configs from map-core for backwards compat.

All 117 tests pass, no user-facing changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:28:14 +02:00
Ullrich Schäfer
cd939ccf07
Complete mobile app specs: fill all gaps
Design fixes:
- D6: Cleaned up, points to separate activity-recording change
- D13: Locked in TanStack Query + Zustand + React Context
- D14: Tile hosting (OpenFreeMap default, configurable tileUrl)
- D15: Photo/media (presigned upload URLs, thumbnails)
- D16: Journal REST API implementation (api.v1.*.ts route modules)

New specs (4):
- web-push-relay: Web Push → APNs/FCM relay (Mastodon pattern)
- api-contract-package: @trails-cool/api with Zod schemas
- device-management: Connected devices list + revoke
- photo-media: Presigned uploads, thumbnails, photo display

Task updates:
- Added Phase 2: Journal REST API (16 tasks)
- Added Phase 7: Notifications (8 tasks)
- Renumbered all phases (1-7)
- 112 total tasks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:12:54 +02:00
Ullrich Schäfer
f6e46d871b
Merge pull request #212 from trails-cool/specs/mobile-app
Add mobile app specs: REST API, map-core, nearby sync
2026-04-12 22:03:40 +02:00
Ullrich Schäfer
fff77a2ed2
Add mobile app, map-core, nearby sync, and activity recording specs
mobile-app: Unified React Native + Expo app combining Planner and
Journal. OAuth2 PKCE auth, MapLibre maps, versioned REST API with
Zod schemas, configurable server URL, offline SQLite, Web Push relay
notifications. TanStack Query + Zustand state management. Jest +
Maestro testing. 76 tasks across 5 phases.

map-core-package: Extract renderer-agnostic map definitions (tiles,
color palettes, POI categories, z-index) into @trails-cool/map-core.
Pure refactor preparing for MapLibre on mobile. 27 tasks.

mobile-activity-recording: GPS recording, live stats, HealthKit/Health
Connect export. Separated from mobile-app for independent scheduling.

mobile-nearby-sync: BLE route sync between nearby devices for offline
group riding. QR waypoint sharing as simpler v1. TXQR noted as future.

journal-rest-api spec: Full API contract — endpoints, auth, pagination,
errors, discovery, versioning, BRouter proxy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:52:30 +02:00
Ullrich Schäfer
e288630a12
Merge pull request #210 from trails-cool/dependabot/github_actions/pnpm/action-setup-6
Bump pnpm/action-setup from 5 to 6
2026-04-12 19:56:35 +02:00
Ullrich Schäfer
c4bd21e573
Update pnpm from 10.6.5 to 10.33.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:52:12 +02:00
dependabot[bot]
5bd4866ca9
Bump pnpm/action-setup from 5 to 6
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 5 to 6.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v5...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 19:51:20 +02:00
Ullrich Schäfer
5707983903
Merge pull request #211 from trails-cool/feat/elevation-map-interaction
Add bidirectional elevation chart ↔ map interaction
2026-04-12 19:47:17 +02:00
Ullrich Schäfer
471ecb2309
Archive elevation-map-interaction, sync spec to main
Synced new spec (chart ↔ map interaction, mobile touch, iOS safe area,
responsive header) to openspec/specs/elevation-map-interaction/.

All 4 artifacts complete. All 14 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:43:03 +02:00
Ullrich Schäfer
a9a576081e
Update spec with iOS safe area, responsive header, and mobile touch
Added requirements for:
- iOS safe area insets (notch, home indicator)
- No accidental page zoom (viewport user-scalable=no)
- Responsive header (hide secondary controls on mobile)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:41:17 +02:00
Ullrich Schäfer
7c0a45abe5
Hide computing route text on mobile to prevent layout reflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:39:55 +02:00
Ullrich Schäfer
650fa0f36a
Fix mobile header layout: hide secondary items on small screens
- Participant list: hidden on mobile (sm:block)
- Undo/redo buttons: hidden on mobile (sm:flex)
- Connection status: hidden on mobile (sm:inline)
- Header: no-wrap, tighter padding on mobile
- Username input: text-base on mobile to prevent iOS auto-zoom

Keeps profile selector and export button visible on all sizes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:39:19 +02:00
Ullrich Schäfer
9b3c0a8d44
Disable page zoom on iOS to prevent accidental pinch-to-zoom
maximum-scale=1 + user-scalable=no prevents the browser UI from
zooming when pinching. Map zoom (Leaflet) still works since it
handles touch events independently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:35:56 +02:00
Ullrich Schäfer
ca48284252
Fix iOS safe area: header respects notch, chart respects home indicator
- viewport-fit=cover enables safe area insets in Safari
- Header: pt-[max(0.5rem,env(safe-area-inset-top))] clears the notch
- Elevation chart: pb-[max(0.5rem,env(safe-area-inset-bottom))] clears
  the home indicator bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:34:25 +02:00
Ullrich Schäfer
66885c82f2
Add mobile touch support for elevation chart interaction
- Single touch + drag: scrub the highlight point along the chart,
  map dot follows in real-time
- Tap: pan map to that point (10px threshold for movement)
- Two-finger touch: range select — area between fingers highlighted,
  on release zooms map to fit that route section
- touch-none CSS + preventDefault prevents page scrolling on chart

Spec updated with mobile touch interaction scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:26:14 +02:00
Ullrich Schäfer
3345ef127e
Add bidirectional elevation chart ↔ map interaction
Three new interactions:

1. Route hover → chart: Invisible interactive polyline (weight 20,
   opacity 0) detects mouse hover on the route, computes cumulative
   distance, and highlights the corresponding position on the chart.

2. Chart click → map pan: Clicking the elevation chart pans the map
   to center on that point along the route. Uses 5px threshold to
   distinguish from drag.

3. Chart drag-select → map zoom: Dragging a range on the chart shows
   a blue selection overlay, then zooms the map to fit the route
   coordinates in that range. "Reset zoom" button appears to restore
   the full route view.

State flow avoids feedback loops: external highlights (from map hover)
set the chart crosshair without re-emitting onHover back to the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:23:37 +02:00
Ullrich Schäfer
37fb5fa1c9 Add Journal dashboard, fix Caddy metrics, and improve alerts
Fix blind spot where Caddy 502 errors were invisible: the dashboards and
alerts queried caddy_http_response_duration_seconds (upstream responses only),
missing 502s that Caddy generates itself. Switch to
caddy_http_request_duration_seconds (server-level, all responses).

Add Journal Grafana dashboard with: 502 rate, response codes, request rate
by route, latency percentiles, container restarts/memory/CPU, Node.js event
loop lag and heap, and Loki log panels for errors and Caddy 5xx entries.

Add color coding to Caddy status code panel (green=2xx, blue=3xx, yellow=4xx,
red=5xx). Add log-based error rate panel to the overview dashboard.

New alerts: container restart loop, PostgreSQL connections > 80, application
crash log detection (Loki), and Caddy 502 rate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:00:00 +02:00
Ullrich Schäfer
1433997d5b Fix journal health check and improve log collection
Switch journal Docker health check from `node -e "fetch(...)"` to `curl -sf`,
matching the planner. The Node.js process spawn was heavy and prone to timeout
under memory pressure, causing false health check failures and 502s.

Upgrade Promtail from static file scraping to Docker service discovery so logs
get `service` and `container` labels. Parse Pino JSON logs to extract the
`level` label, enabling Loki queries filtered by log level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:00:00 +02:00
Ullrich Schäfer
e69f93800b Fix TypeScript parameter property crash in Node strip-only mode
oauth.server.ts used a TypeScript parameter property (`public code: string`
in constructor) which is not supported by Node.js v25's strip-only TypeScript
mode. This caused the journal to crash-loop on startup after seeding OAuth
clients.

Also enable `erasableSyntaxOnly` in tsconfig.base.json so `pnpm typecheck`
catches any future use of non-erasable TypeScript syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:00:00 +02:00
Ullrich Schäfer
9a46a7eac0
Merge pull request #209 from trails-cool/docs/spec-updates-and-followups
Update specs and create follow-up changes
2026-04-11 11:23:01 +02:00
Ullrich Schäfer
a44beb87ef
Update route-coloring spec with all 10 modes, create follow-up changes
Spec updates:
- route-coloring: Added speed limit, smoothness, track type, cycleway,
  and bike route modes. Added OSM wiki links, BRouter profile patching,
  dropdown order, bike route human-readable legends.

New follow-up changes (proposals only):
- elevation-map-interaction: Bidirectional chart ↔ map interaction
  (route hover → chart, chart click → pan, chart drag → zoom)
- e2e-test-reorganization: Mock BRouter by default, split tests by
  feature, shared helpers
- journal-poi-details: Surface POI metadata in Journal route detail
  (phone, address, website, opening hours from snapped waypoints)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:20:17 +02:00
Ullrich Schäfer
80404b2494
Merge pull request #208 from trails-cool/road-type
Add road type color mode
2026-04-11 11:17:01 +02:00
Ullrich Schäfer
97184f4b82
Fix E2E: use 'residential' instead of 'cycleway' in legend test
'cycleway' now matches both the highway legend and the Cycleway
dropdown option, causing a strict mode violation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
3787821571
Improve bike route legend: human-readable names, non-judgmental colors
- Legend: International / National / Regional / Local / None
- Hover: shows full names instead of icn/ncn/rcn/lcn
- Palette: purple (intl) → blue (national) → teal (regional) →
  emerald (local) → gray (none). No red/green implied quality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
ffb5348396
Link elevation chart title to OSM wiki for each color mode
Clicking the chart title (e.g. "Road Type Profile") opens the
corresponding OSM wiki page. Plain, elevation, and grade modes
remain as plain text since they're not OSM tag-based.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
6ec95216e1
Add smoothness, track type, cycleway, and bike route color modes
Four new route visualization modes extracted from BRouter WayTags:

- Smoothness: excellent (green) → impassable (dark red)
- Track Type: grade1 (green, best) → grade5 (red, worst)
- Cycleway: track/lane/shared_lane/no with direction awareness
- Bike Route: icn/ncn/rcn/lcn priority from route_bicycle_* tags

Each mode includes map polyline coloring, elevation chart coloring,
inline legend, hover label, i18n (EN+DE), and mock fixture data.

Dockerfile patched to also expose tracktype in BRouter WayTags
(smoothness, cycleway, and route_bicycle already present).

Dropdown order: plain, elevation, grade, surface, road type, speed
limit, smoothness, track type, cycleway, bike route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
768fa9df44
Reorder color mode dropdown: plain, elevation, grade, surface, road type, speed limit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
61d34821df
Re-add speed limit color mode with BRouter profile patch
The dummyUsage trick needs a separate assign per tag:
  assign dummyUsage2 = maxspeed=
(not appended to the existing smoothness= line)

- Dockerfile: Patch all BRouter profiles to include maxspeed in
  WayTags output via separate assign dummyUsage2
- brouter.ts: Extract maxspeed with direction handling
  (maxspeed:forward/backward + reversedirection awareness)
- ColoredRoute: maxspeed color mode (green ≤30, yellow ≤50,
  orange ≤70, red ≤100, dark red 100+)
- ElevationChart: maxspeed chart rendering, legend, hover "X km/h"
- PlannerMap: maxspeeds state + Yjs read + prop passing
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures: maxspeed data for E2E tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
cec613d989
Fix E2E select locator, remove maxspeed mode (data unavailable)
E2E fixes:
- Color mode select locator uses option[value='highway'] filter
  instead of fragile page.locator("select").last()

Removed maxspeed color mode:
- BRouter does not expose maxspeed in WayTags output despite the tag
  existing in its lookup table. The dummyUsage profile trick only
  affects cost calculation, not tiledesc output.
- Can revisit if BRouter adds maxspeed to WayTags in a future version
  or if we fork/patch the BRouter profiles more deeply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
4bcd6137de
Fix E2E select locator and add speed limit color mode
E2E fixes:
- Color mode select locator now uses option[value='highway'] filter
  instead of fragile page.locator("select").last()

Speed limit color mode:
- Extract maxspeed tags from BRouter tiledesc (with forward/backward
  direction handling following brouter-web's pattern)
- Color route and chart by speed limit: green ≤30, yellow ≤50,
  orange ≤70, red ≤100, dark red 100+ km/h
- Legend with speed thresholds, hover shows "X km/h"
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures include maxspeed data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
1e2f6beded
Add road type color mode to route visualization
Extract highway=* tags from BRouter tiledesc messages alongside surface
data and add a new "Road Type" color mode that colors the route polyline
and elevation chart by OSM highway classification (cycleway, residential,
path, etc.). Includes color palette, legend, hover labels, i18n (EN+DE),
unit tests for tag extraction, and E2E tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
f4f0cbc4c8
Merge pull request #207 from trails-cool/feat/notes-sync
Sync notes through GPX, Journal, and Planner roundtrip
2026-04-11 03:38:07 +02:00
Ullrich Schäfer
08070cdd90
Sync notes through GPX, Journal, and Planner roundtrip
Full notes lifecycle:
- GPX: description field in GpxData, <metadata><desc> in generate/parse
- Export: Plan GPX and Save to Journal include notes as description
- Journal: updateRoute extracts description from GPX, stores on route
- Reimport: Edit in Planner passes notes via URL params → Yjs Y.Text
- Drop import: GPX with <desc> restores notes in session

Spec updated: session-notes gains GPX export, Journal sync, and
reimport requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:35:40 +02:00
Ullrich Schäfer
b12b706b77
Merge pull request #206 from trails-cool/docs/spec-updates
Update specs: session-notes, route-coloring, gpx-import
2026-04-11 03:25:26 +02:00
Ullrich Schäfer
327229f814
Merge branch 'main' into docs/spec-updates 2026-04-11 03:25:15 +02:00
Ullrich Schäfer
d6982db868
Update specs: session-notes, route-coloring, gpx-import
session-notes: Reflect CodeMirror 6 editor with character-level sync,
remote cursor awareness, dedicated undo manager, awareness isolation.

route-coloring: Added grade color mode, moved toggle to chart, added
surface/grade chart coloring, legends for all modes, contextual hover.

gpx-import: Added day break preservation (overnight waypoints survive
GPX roundtrip).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:24:54 +02:00
Ullrich Schäfer
4c6351f161
Merge pull request #205 from trails-cool/feat/elevation-improvements
Move color selector to chart, add grade profile and surface coloring
2026-04-11 03:24:32 +02:00
Ullrich Schäfer
9752eb77e8
Fix E2E: color mode toggle test needs a route for elevation chart
The color mode selector moved from the header to the elevation chart,
which only renders when a route exists. Updated test to use mocked
BRouter with waypoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:40 +02:00
Ullrich Schäfer
442f911fda
Improve surface legend and hover text
- Legend shows actual surface types present in the route (up to 6)
  with colored swatches instead of generic text
- Hover label shows surface type name (e.g. "34m · 12.3km · asphalt")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:40 +02:00
Ullrich Schäfer
7bddc131cd
Color elevation chart by surface type when in surface mode
The chart was falling through to plain blue when surface mode was
selected. Now reads surface data from Yjs routeData and draws
segments colored by road surface type, matching the route colors
on the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
858c7e9118
Show min/max elevation in meters in the elevation legend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
ec584c8b88
Add inline color legends next to chart title for all modes
Legends sit between the title and the mode selector dropdown:
- Grade: colored swatches with percentage thresholds
- Elevation: gradient bar from low (green) to high (red)
- Surface: text hint about surface coloring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
67439df0ee
Add color legend below grade profile chart
Shows grade thresholds with color swatches: <3% green, <6% yellow,
<10% orange, <15% red, 15%+ dark red. Only visible in grade mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
da1a5248d6
Move color mode selector to elevation chart, add grade profile
- Moved color mode dropdown from header bar to inline with the
  elevation chart title — closer to the visualization it controls
- Removed ColorModeToggle component from SessionView header
- Added "Grade" color mode: colors route and elevation chart by
  steepness (green <3%, yellow <6%, orange <10%, red <15%, dark red 15%+)
- Grade hover label shows percentage (e.g. "+4.2%")
- Fixed useTranslation namespace in ElevationChart ("planner")
- i18n: Added grade keys for en + de

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
425f1643b9
Merge pull request #204 from trails-cool/feat/collaborative-notes
Replace notes with collaborative CodeMirror editor
2026-04-11 03:10:43 +02:00
Ullrich Schäfer
86f5d082d2
Fix E2E: match CodeMirror placeholder by visible text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:07:45 +02:00
Ullrich Schäfer
378dc41c05
Fix E2E: notes tab test uses CM placeholder instead of textarea
CodeMirror renders placeholders as .cm-placeholder elements, not
HTML placeholder attributes. Updated the sidebar tabs test to look
for the CodeMirror content area instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:05:55 +02:00
Ullrich Schäfer
0fd0e8e135
Add notes persistence scenario to planner-session spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:01:55 +02:00
Ullrich Schäfer
205e2bbbe0
Fix notes not loading on session reload
- Initialize CodeMirror with existing Y.Text content (doc: yjs.notes.toString())
  so text is visible immediately, not just after the next remote change
- Use dedicated Y.UndoManager for notes instead of sharing with waypoints
  undo manager (different tracked origins caused conflicts)
- Use yUndoManagerKeymap for proper Yjs-aware undo/redo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:00:51 +02:00
Ullrich Schäfer
9e7aa4647d
Fix: rename map cursor awareness field to avoid y-codemirror collision
y-codemirror.next uses the awareness 'cursor' field for text editor
cursor positions. Our map cursor tracker was using the same field name,
causing Invalid LatLng errors when the receiving participant tried to
render a CodeMirror cursor object as map coordinates.

Renamed to 'mapCursor' to avoid the collision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:58:05 +02:00
Ullrich Schäfer
f7e0f904a9
Update planner-session spec with collaborative notes requirements
Added scenarios for character-level notes sync and cursor awareness
between participants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:55:36 +02:00
Ullrich Schäfer
5f21564a19
Replace notes textarea with collaborative CodeMirror editor
- CodeMirror 6 + y-codemirror.next for proper character-level Yjs sync
  (replaces delete-all/insert-all textarea binding)
- Remote cursor awareness: colored carets with participant names
- Line wrapping, placeholder text, history (undo/redo)
- Awareness user fields include colorLight for cursor selection tint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:55:06 +02:00
Ullrich Schäfer
360bd87de2
Merge pull request #203 from trails-cool/feat/multiday-export
Add Export Day Segments option for multi-day routes
2026-04-11 02:53:04 +02:00
Ullrich Schäfer
3a0c40d9fd
Add Export Day Segments option for multi-day routes
When a route has overnight waypoints, the export dropdown shows a
third option: "Export Day Segments" which downloads one GPX file per
day. Each file is named day-N-start-name.gpx with the day's track
segment. Only visible when the route has multiple days.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:48:51 +02:00
Ullrich Schäfer
f96c2d1bde
Merge pull request #202 from trails-cool/feat/osm-overlays-final
Complete OSM overlays: markercluster, Yjs sync, context menu, smart insert
2026-04-11 02:48:00 +02:00
Ullrich Schäfer
7dc4fdec3b
Archive osm-overlays change, sync 4 specs to main
Synced to main specs:
- osm-poi-overlays (new): POI overlay requirements
- osm-tile-overlays (new): tile overlay requirements
- map-display (updated): added overlay layer entries
- planner-session (updated): added overlay/POI sync

Archived to openspec/changes/archive/2026-04-11-osm-overlays/.
All 4 artifacts complete. All 45 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:45:32 +02:00
Ullrich Schäfer
f4ebf75a42
Replace right-click behavior with context menu on waypoints
Right-clicking a waypoint now shows a context menu with:
- "Mark as overnight stop" / "Remove overnight stop" (middle waypoints only)
- "Delete" (all waypoints)

Previously first/last waypoints were deleted on right-click and
middle waypoints toggled overnight, with no visual indication of
which action would happen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:42:43 +02:00
Ullrich Schäfer
508581635a
Merge pull request #201 from trails-cool/feat/osm-overlays-final
Add markercluster, base layer sync, and tile overlay sync
2026-04-11 02:41:49 +02:00
Ullrich Schäfer
7d0918259d
Increase route-near insertion threshold from ~200m to ~1km
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:39:08 +02:00
Ullrich Schäfer
a50bc425ee
Insert waypoints near route at correct position instead of appending
When clicking near the existing route to add a waypoint (including POI
snap), find the closest route segment and insert after it. Falls back
to appending at the end if the click is far from the route (~200m
threshold in degrees).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:38:15 +02:00
Ullrich Schäfer
ba8a1bbaeb
Add markercluster for POIs and sync base layer via Yjs
- leaflet.markercluster: Dynamic import with fallback to plain layer
  group. Clusters POI markers at low zoom, ungroups at zoom 15+.
- Base layer sync: baselayerchange event writes to Yjs, new
  participants load the selected base layer on connect.
- Both tile overlays and base layer now persist across participants
  and crash recovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:35:32 +02:00
Ullrich Schäfer
e158205f32
Sync tile overlay state via Yjs across participants
OverlaySync component listens to Leaflet overlayadd/overlayremove
events and writes enabled overlay IDs to Yjs routeData. On connect,
loads initial overlay state from Yjs. LayersControl.Overlay uses
checked prop to reflect the synced state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:32:36 +02:00
Ullrich Schäfer
bf964e07fc
Merge pull request #200 from trails-cool/feat/osm-overlays-followup
Add Yjs POI sync and z-index fix
2026-04-11 02:30:54 +02:00
Ullrich Schäfer
6d98851175
Add Yjs sync for POI categories across participants
useYjsPoiSync: Bidirectional sync between local POI state and Yjs
routeData. When a participant enables/disables POI categories, the
change propagates to all connected clients. Initial state loaded from
Yjs on connect. Crash recovery included via existing Yjs localStorage
snapshot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:28:11 +02:00
Ullrich Schäfer
d7e4c8ff52
Move POI markers below waypoints in z-index (1200 → 900)
POI markers should render below waypoint markers so waypoints are
always easy to click and drag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:26:35 +02:00
Ullrich Schäfer
90918221d8
Merge pull request #199 from trails-cool/feat/osm-overlays
Add OSM tile overlays and POI layers with waypoint snapping
2026-04-11 02:25:55 +02:00
Ullrich Schäfer
3ff4245f6f
Fix E2E: mock BRouter for GPX day breaks test
The test dropped GPX with far-apart waypoints (Berlin/Dessau/Erfurt)
which timed out waiting for BRouter on CI. Switched to nearby Berlin
waypoints and added mockBRouter for instant deterministic routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:23:35 +02:00
Ullrich Schäfer
22c5ebd838
Auto-enable POI categories on routing profile change
useProfileDefaults hook observes profile changes in Yjs and merges
relevant POI categories into the enabled set. Skips initial load to
respect existing state. Cycling profiles → bike infra, hiking →
shelter + viewpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:19:30 +02:00
Ullrich Schäfer
be36a5f650
Change default routing profile to fastbike, move hiking to end
The app is primarily for cycling. Default profile is now "Cycling (fast)"
instead of "Hiking". Profile order: fastbike, safety, shortest, car,
trekking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:17:46 +02:00
Ullrich Schäfer
0ff04aaec7
Add E2E tests for hillshading overlay and POI markers
- Hillshading: mock tile endpoint, enable via DOM evaluate, verify
  tile requests fired
- POI markers: mock Overpass API response, zoom to threshold, enable
  category, verify marker rendered in marker pane

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:14:56 +02:00
Ullrich Schäfer
f414c97887
Update specs with POI-waypoint integration, resilience, z-index
Proposal: Added POI→waypoint, POI snapping, and metadata storage.
Design: Added D10 (POI-waypoint integration), D11 (Overpass fallback),
D12 (z-index layering). Updated risks/trade-offs.
Tasks: Added sections 11 (POI-waypoint, 5 tasks) and 12 (resilience,
5 tasks). Fixed Overpass test to match updated query format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:09:44 +02:00
Ullrich Schäfer
14a2bd82fc
Snap route-inserted waypoints to nearby POIs
When clicking on the route to insert a waypoint, the new point now
snaps to a nearby POI (within 50m) just like click-to-add and drag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:06:40 +02:00
Ullrich Schäfer
246806cf1a
Increase route rate limit from 60 to 300 requests/hour
With multi-waypoint routes (6 BRouter requests per computation) and
debounced re-routing on waypoint changes, 60/hour was too easy to hit
during active planning sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:05:41 +02:00
Ullrich Schäfer
6d82ebe127
Store POI metadata (osmId, tags) on waypoints for Journal use
When a waypoint is created from or snapped to a POI, the Yjs Y.Map
now stores osmId (OSM node ID) and poiTags (phone, website, address,
opening hours, etc.). Dragging away clears this data.

This persists through the Yjs document and will be available when the
route is saved to the Journal, enabling future display of campsite
contact details, opening hours, etc. on route detail pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:02:47 +02:00
Ullrich Schäfer
a71efef1ce
Clear waypoint name when dragged away from a POI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:00:44 +02:00
Ullrich Schäfer
1067b49576
Add waypoint snapping to nearby POIs (50m threshold)
When placing or dragging a waypoint within 50m of a visible POI, the
waypoint snaps to the POI's exact coordinates and adopts its name.
Snapping only applies to visible POIs (enabled categories, current
viewport). Explicit name (from "Add as waypoint" button) is preserved
without snapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:59:49 +02:00
Ullrich Schäfer
3af6aee32c
Add "Add as waypoint" button to POI popups
Clicking the button in a POI popup adds the POI's location and name as
a new waypoint at the end of the route. Uses event delegation on the
map container to handle clicks on dynamically created popup buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:56:10 +02:00
Ullrich Schäfer
8c3fc36d23
Add Overpass fallback endpoint and reduce query size
- Try overpass.kumi.systems first, fall back to overpass-api.de
- Reduced max results from 200 to 100, added maxsize:1MB limit
- Shortened timeout from 15s to 10s, added qt (quiet) output mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:54:03 +02:00
Ullrich Schäfer
729b33a15f
Add Z_WAYPOINT_HIGHLIGHTED (1200) for hovered waypoint markers
Highlighted waypoints now render above normal waypoints (1000) but
below POI markers (1500) and the elevation highlight dot (2000).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:51:39 +02:00
Ullrich Schäfer
337df6b527
Fix marker z-ordering: waypoints and highlight above POIs and route
Extract all Leaflet zIndexOffset values into z-index.ts constants:
  POI markers (-1000) < cursors (-1000) < ghost waypoint (-100)
  < waypoint markers (1000) < highlight dot (2000)

Replaced CircleMarker highlight with a Marker+DivIcon so it
participates in z-index ordering above waypoint markers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:46:55 +02:00
Ullrich Schäfer
f310e0f66f
Lower POI minimum zoom from 12 to 10
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:42:56 +02:00
Ullrich Schäfer
b156093167
Fix POIs not fetching on map pan
- Fixed sort() mutating enabledCategories array in place (used spread copy)
- Show loading status immediately when a fetch will happen, not after
  the debounce delay
- Reduced debounce to 800ms and min interval to 2s for snappier response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:39:33 +02:00
Ullrich Schäfer
787cf23395
Remove picnic sites from camping POI category
Picnic sites (a table in a park) are not useful for route planning
alongside actual camp sites and caravan sites. Removed from the
camping query to reduce noise.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:38:15 +02:00
Ullrich Schäfer
a62b35bf45
Show more info in POI popups: category, description, address, phone
Popup now shows: name, category label, description, address (street +
housenumber + postcode + city), phone (clickable tel: link), opening
hours, website, and OSM link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:33:31 +02:00
Ullrich Schäfer
65330cf9e2
Switch Overpass endpoint to overpass.kumi.systems
overpass.kumi.systems has higher rate limits than overpass-api.de.
This is the same endpoint brouter-web uses as its default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:28:03 +02:00
Ullrich Schäfer
d1b8674575
Handle Overpass rate limit returned as 200 with error body
Overpass API sometimes returns HTTP 200 with a plain-text error body
containing "rate_limited" instead of a proper 429 status. Now checks
response body for this pattern before attempting JSON parse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:25:59 +02:00
Ullrich Schäfer
c92d4cb4af
Fix Overpass 429: increase debounce, add minimum request interval
- PoiRefresher: Use ref for refresh callback to avoid re-running effect
  on every category change. Only trigger refresh on category changes,
  not on mount.
- usePois: Bump debounce from 500ms to 1000ms, add 3s minimum interval
  between actual Overpass requests, increase backoff base to 10s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:23:24 +02:00
Ullrich Schäfer
c23c325965
Add i18n keys and unit tests for overlays
- i18n: POI category names, panel UI strings (en + de)
- overpass.test.ts: Query building, response parsing, deduplication
- poi-cache.test.ts: Tile quantization, cache hit/miss, bbox filtering
- poi-categories.test.ts: Profile-to-overlay mapping, category validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:19:27 +02:00
Ullrich Schäfer
6aa2229b39
Add POI overlay panel, markers, and map integration
- PoiPanel: Collapsible panel with category checkboxes, count badges,
  loading/error/zoom-too-low states
- PoiMarkers: L.Marker with L.DivIcon per POI, click popup with name,
  hours, website, OSM link. z-index below route/waypoints.
- PoiRefresher: Listens to map moveend, refreshes POIs via usePois hook
- Wired into PlannerMap alongside existing controls

Skipped markercluster (7.3) — can add later if density is an issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:17:41 +02:00
Ullrich Schäfer
375ff2fa13
Add Overpass client, POI categories, cache, and usePois hook
- poi-categories.ts: 9 POI categories with Overpass QL queries, icons,
  colors, and profile-aware defaults
- overpass.ts: Query builder, JSON parser, deduplication, rate limit error
- poi-cache.ts: Tile-based cache (0.1° grid cells, 10min TTL)
- use-pois.ts: React hook with 500ms debounce, AbortController, zoom
  threshold (>=12), exponential backoff on 429

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:15:34 +02:00
Ullrich Schäfer
0e399e5174
Add tile overlay definitions and LayersControl entries
- layers.ts: overlayLayers with hillshading, Waymarked Cycling/Hiking/MTB
- PlannerMap: LayersControl.Overlay entries for each overlay tile layer
- Leaflet handles attribution updates natively on toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:13:21 +02:00
Ullrich Schäfer
a1b231c54c
Merge pull request #198 from trails-cool/feat/multi-day-routes
Add multi-day routes with overnight waypoints
2026-04-11 01:10:09 +02:00
Ullrich Schäfer
e6c68fb71b
Add BRouter mock for deterministic E2E tests
- brouter-mock.ts: Canned route responses for 2 and 3 waypoint sessions,
  plus latLngToPixel helper for pixel-precise map interactions
- planner.test.ts: Route split, map zoom, and overnight tests now use
  mockBRouter() for instant deterministic responses instead of real
  BRouter — fixes all 3 flaky tests
- Used .first() for sidebar km locator to avoid strict mode violations
  when both header summary and stats footer match

All 38 E2E tests pass in 9.7s (was 34s+ with real BRouter).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:07:39 +02:00
Ullrich Schäfer
f90571fc2c
Fix overnight E2E test: use nearby waypoints, scope locators to sidebar
The test used waypoints 300km apart causing BRouter timeouts. Switched
to nearby Berlin waypoints for fast route computation. Scoped all
locators to the sidebar to avoid strict mode violations from map
elements matching the same text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:02:02 +02:00
Ullrich Schäfer
b6bca0229b
Fix E2E strict mode violations in multi-day tests
- "km" locator matched both header summary and stats footer — narrowed
  to match "km ·" pattern unique to the header
- "Day 1" locator matched both map label pill and sidebar header —
  scoped to sidebar with page.locator("aside")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:59:03 +02:00
Ullrich Schäfer
adc5b44739
Archive multi-day-routes change, sync spec to main
Synced delta spec (6 requirements) to openspec/specs/multi-day-routes/.
Archived change to openspec/changes/archive/2026-04-11-multi-day-routes/.

All 4 artifacts complete. All 35 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:54:05 +02:00
Ullrich Schäfer
d325127a06
Update specs with waypoint hover, day interaction, per-day export
Proposal: Added waypoint highlighting, Journal day interaction, and
per-day GPX export to What Changes and Impact sections.

Design: Added D13 (sidebar waypoint hover → map marker scale), D14
(Journal day segment hover with fly-to-bounds), D15 (per-day GPX
export endpoint with ?day=N query param).

Tasks: Added sections 8 (Planner waypoint hover, 2 tasks), 9 (Journal
day interaction, 5 tasks). Renumbered testing to section 10.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:52:40 +02:00
Ullrich Schäfer
7e89de0f0e
Reduce map fly animation to 200ms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:48:41 +02:00
Ullrich Schäfer
b010cd8c59
Fly map to highlighted day segment on hover
When hovering a day row in the route detail breakdown, the map smoothly
flies to fit that segment's bounds (0.5s animation). On mouse leave it
flies back to the full route bounds. Refactored geometry splitting into
shared helpers used by both DayColoredRoute and FlyToSegment.

Rotation not implemented — Leaflet doesn't support native bearing
rotation. Would require leaflet-rotate plugin or MapLibre GL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:46:46 +02:00
Ullrich Schäfer
7029c85dd0
Add route detail day segment highlighting and per-day GPX export
- Hovering a day row dims other segments (opacity 0.3) and thickens
  the hovered day's segment (weight 5) for clear visual focus
- Each day row has a GPX download button that exports just that day's
  track segment via /api/routes/:id/gpx?day=N
- GPX endpoint uses computeDays to extract the correct track slice

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:44:11 +02:00
Ullrich Schäfer
e2fc682a95
Adjust waypoint highlight animation to 0.2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:39:41 +02:00
Ullrich Schäfer
8a3a374aa1
Double waypoint highlight animation duration to 0.3s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:39:22 +02:00
Ullrich Schäfer
18c9d12c48
Use CSS scale(1.17) for waypoint highlight instead of sizing individually
Single transform handles both the marker and text scaling uniformly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:38:49 +02:00
Ullrich Schäfer
fe64aa0103
Scale waypoint marker text on highlight (12→13px)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:38:00 +02:00
Ullrich Schäfer
c78a6f8bc9
Remove outline ring from waypoint highlight permanently
The subtle size bump (24→28px) is sufficient visual feedback without
the outline ring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:37:24 +02:00
Ullrich Schäfer
4853db27fc
Temporarily disable outline ring on highlighted waypoint markers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:37:03 +02:00
Ullrich Schäfer
eebf694d1a
Tone down waypoint highlight: 24→28px, keep font size unchanged
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:36:38 +02:00
Ullrich Schäfer
705252a1c6
Highlight waypoint marker on sidebar hover instead of red dot
Replace position-based CircleMarker highlight with index-based marker
highlighting. Hovered waypoint marker grows from 24px to 30px with an
outline ring in its color. Uses CSS transition for smooth animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:35:42 +02:00
Ullrich Schäfer
13c2925c18
Add waypoint highlighting on sidebar hover
Hovering a waypoint row in the sidebar highlights it on the map with
the same red CircleMarker used by the elevation chart hover. Reuses
the existing highlightPosition state — both sidebar and chart feed
into the same mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:33:44 +02:00
Ullrich Schäfer
4a429c8c59
Shrink day label pill and fix centering
Use absolute positioning with left:50% + translate(-50%) for proper
horizontal centering of auto-width content. Reduced font to 10px,
padding to 1px 6px, border-radius to 8px.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:30:49 +02:00
Ullrich Schäfer
83a416b6c9
Fix day label positioning above overnight marker
Use iconSize + iconAnchor instead of CSS transform to position the
day label pill above the waypoint marker. The label was overlapping
the marker because translate(-50%, -30px) didn't clear the 24px icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:30:15 +02:00
Ullrich Schäfer
cd180bc13b
Fix exported plan GPX missing overnight waypoint metadata
ExportButton.getWaypoints() wasn't reading the overnight flag from
Yjs Y.Maps, so exported plan GPX was missing <type>overnight</type>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:28:51 +02:00
Ullrich Schäfer
857100ef94
Fix overnight flag lost on Journal roundtrip
Three gaps in the Planner → Journal → Planner roundtrip:

1. SaveToJournalButton: wasn't including isDayBreak when building
   waypoints for GPX generation
2. PlannerMap GPX import: wasn't setting overnight on Y.Maps when
   importing waypoints with isDayBreak
3. use-yjs initial waypoints: wasn't setting overnight from isDayBreak

Also widened the waypoint type throughout the chain (api.sessions,
session.$id, SessionView, extractWaypoints) to carry isDayBreak.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:09 +02:00
Ullrich Schäfer
2970a460f8
Add E2E tests, Journal map coloring, and remaining unit tests
- planner.test.ts: E2E test for overnight toggle → day breakdown in sidebar,
  and GPX import with overnight metadata
- integration.test.ts: E2E test for isDayBreak preservation through GPX import
- RouteMapThumbnail: Per-day route coloring with alternating colors
- overnight.test.ts: Y.Doc-aware tests for isOvernight/setOvernight
- daybreaks-extraction.test.ts: GPX → dayBreaks index extraction tests

All 28 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:14:08 +02:00
Ullrich Schäfer
5604fe1c82
Add per-day map coloring and remaining unit tests
- RouteMapThumbnail: Color route segments per day with alternating colors
  when dayBreaks are present, pass through from route detail page
- overnight.test.ts: 6 tests for isOvernight/setOvernight with Y.Doc
- daybreaks-extraction.test.ts: 3 tests for GPX → dayBreaks index extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:12:27 +02:00
Ullrich Schäfer
77174f192d
Add unit tests for computeDays and GPX overnight roundtrip
- compute-days.test.ts: 8 tests covering single day, two days, three days,
  ascent/descent, edge cases (first/last overnight, single waypoint, empty)
- generate.test.ts: 4 new tests for isDayBreak emission, GPX roundtrip with
  isDayBreak preserved, and splitByDay multi-track output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:05:54 +02:00
Ullrich Schäfer
774e2b1ae3
Add multi-track GPX export option (splitByDay)
generateGpx() gains a splitByDay option that splits the track into
separate <trk> elements per day, each named "Day N: Start - End".
Track points are split at the closest points to overnight waypoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:04:48 +02:00
Ullrich Schäfer
dd8d72d7b3
Add map overnight markers, day labels, and context menu
- Overnight waypoints render amber-brown (#8B6D3A) with moon icon
- Day boundary labels (white pill "Day N · X km") at overnight waypoints
- Right-click middle waypoints toggles overnight (first/last still delete)
- Wire days prop through SessionView → PlannerMap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:03:56 +02:00
Ullrich Schäfer
a21bf16e73
Fix i18n: use planner namespace in sidebar components
WaypointSidebar and DayBreakdown were calling useTranslation() without
the "planner" namespace, then using fully qualified keys like
"planner.multiDay.overnight". Fixed to use useTranslation("planner")
and drop the prefix from all t() calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:59:27 +02:00
Ullrich Schäfer
3f0fe859a5
Fix lint: remove unused variable in ElevationChart
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:57:27 +02:00
Ullrich Schäfer
b496cd9b02
Add Journal day breakdown and elevation chart dividers
- routes.server.ts: Extract dayBreaks from parsed GPX waypoints on save
- routes.$id.tsx: Compute per-day stats in loader, render day breakdown
  section with distance/ascent/descent per day
- ElevationChart: Dashed vertical lines at day boundaries
- i18n: Add Journal day breakdown keys (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:56:57 +02:00
Ullrich Schäfer
78ee4ef3dc
Add elevation chart day dividers and wire days through SessionView
- ElevationChart: Dashed vertical lines at day boundaries with distance labels
- SessionView: Lift useDays() to top level, pass to both ElevationChart and
  SidebarTabs to avoid duplicate hook calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:54:56 +02:00
Ullrich Schäfer
94016fd4c4
Add sidebar day breakdown and overnight toggle UI
- DayBreakdown: Collapsible day sections with per-day stats
- WaypointSidebar: Overnight toggle button (moon icon), day-grouped view
  when any waypoint is marked overnight, route summary in header
- SessionView: Wire useDays() hook into sidebar via SidebarTabs
- i18n: Add multiDay keys for en + de (day labels, overnight, stats)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:53:43 +02:00
Ullrich Schäfer
efdb3c1973
Add multi-day data model, computeDays, and GPX roundtrip
- overnight.ts: Yjs helpers to set/clear/check overnight flag on waypoints
- compute-days.ts: Pure function splitting routes into DayStage[] at day breaks
- use-days.ts: Reactive hook mapping Yjs state into computeDays() input
- GPX generate: emit <type>overnight</type> for isDayBreak waypoints
- GPX parse: recognize <type>overnight</type> and set isDayBreak on waypoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:51:31 +02:00
Ullrich Schäfer
c9d6f65616
Merge pull request #197 from trails-cool/chore/dependency-updates-v2
Update dependencies and move Sentry to pnpm catalog
2026-04-10 23:42:15 +02:00
Ullrich Schäfer
04efe791cd
Update dependencies and move Sentry to pnpm catalog
Dependency bumps:
- react, react-dom: 19.2.4 → 19.2.5 (catalog)
- @sentry/node, @sentry/react: 10.47.0 → 10.48.0
- @sentry/vite-plugin: 5.1.1 → 5.2.0
- @geoman-io/leaflet-geoman-free: 2.19.2 → 2.19.3
- jsdom: 29.0.1 → 29.0.2
- prettier: 3.8.1 → 3.8.2
- turbo: 2.9.3 → 2.9.6
- typescript-eslint: 8.58.0 → 8.58.1
- vitest: 4.1.2 → 4.1.4

Catalog cleanup:
- Move @sentry/node and @sentry/react to pnpm catalog (used by both apps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:39:55 +02:00
Ullrich Schäfer
3ad8c301ea
Merge pull request #193 from trails-cool/chore/catalog-cleanup
Deduplicate dependency versions via pnpm catalog
2026-04-10 23:07:13 +02:00
Ullrich Schäfer
760818c843
Deduplicate dependency versions via pnpm catalog
Root package.json had hardcoded versions for react, react-dom, @types/react,
@types/react-dom, tailwindcss, @tailwindcss/vite, drizzle-orm, drizzle-kit,
and drizzle-postgis that drifted from catalog entries. Switched them all to
catalog: and bumped catalog versions to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:04:34 +02:00
Ullrich Schäfer
a408d69965
Merge pull request #190 from trails-cool/spec/multi-day-routes-journal
Expand multi-day-routes spec to include Journal integration
2026-04-10 22:57:41 +02:00
Ullrich Schäfer
b38122f770
Merge branch 'main' into spec/multi-day-routes-journal 2026-04-10 22:55:26 +02:00
Ullrich Schäfer
f8948fe974
Merge pull request #191 from trails-cool/chore/vite-8-upgrade
Upgrade Vite from 6.4.2 to 8.0.8
2026-04-10 22:55:06 +02:00
Ullrich Schäfer
26cd8a8a0f
Merge branch 'main' into spec/multi-day-routes-journal 2026-04-10 22:55:05 +02:00
Ullrich Schäfer
06949fc7b5
Upgrade Vite from 6.4.2 to 8.0.8
Vite 8 ships Rolldown as its Rust-based bundler, replacing esbuild+Rollup.
Removed the esbuild override from pnpm-workspace.yaml since Vite 8 no longer
uses esbuild for bundling. All plugins (@react-router/dev, @tailwindcss/vite,
@sentry/vite-plugin, @vitejs/plugin-basic-ssl) support Vite 8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:52:43 +02:00
Ullrich Schäfer
f7f9bef2ba
Expand multi-day-routes spec to include Journal integration
The spec was Planner-only — day structure would be lost when saving to the
Journal. Added GPX roundtrip (D9), Journal storage (D10), route detail day
breakdown (D11), and shared computeDays in @trails-cool/gpx (D12).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:44:03 +02:00
Ullrich Schäfer
d7db09f13e
Merge pull request #189 from trails-cool/dependabot/npm_and_yarn/nodemailer-8.0.5
Bump nodemailer from 8.0.4 to 8.0.5
2026-04-10 21:04:28 +02:00
dependabot[bot]
3b49aa7a5b
Bump nodemailer from 8.0.4 to 8.0.5
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.4 to 8.0.5.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.4...v8.0.5)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 19:01:58 +00:00
Ullrich Schäfer
8a076f8c4b
Merge pull request #187 from trails-cool/fix/security-vite-lodash
Fix security alerts: vite 6.4.2, lodash 4.18.1
2026-04-06 22:38:29 +01:00
Ullrich Schäfer
1c99768928 Fix security alerts: bump vite to 6.4.2, override lodash to 4.18.1
- Vite 6.4.1 → 6.4.2: fixes path traversal in optimized deps .map
  handling (medium) and arbitrary file read via dev server WebSocket
  (high)
- lodash 4.17.23 → 4.18.1 via pnpm override: fixes code injection
  via _.template (CVE-2026-4800, high) and prototype pollution via
  _.unset/_.omit (CVE-2026-2950, medium). Transitive dep from
  @react-router/dev — override needed until they bump it upstream.
  Skipped 4.18.0 which was marked as a bad release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:35:53 +02:00
Ullrich Schäfer
43625915d7
Merge pull request #184 from trails-cool/deps/i18n
Bump i18next and react-i18next
2026-04-06 22:32:16 +01:00
Ullrich Schäfer
70319e702f Bump i18next from 26.0.1 to 26.0.3 and react-i18next from 17.0.1 to 17.0.2
Previous attempt left duplicate i18next versions in the lockfile
(26.0.1 for @trails-cool/i18n peer dep, 26.0.3 for root). This caused
two different i18next instances at runtime, breaking i18n initialization
and leaving pages blank in E2E tests.

Fix: update specifiers and run pnpm dedupe to ensure a single version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:29:26 +02:00
Ullrich Schäfer
d56bbc9708
Merge pull request #183 from trails-cool/deps/playwright
Bump playwright from 1.58.2 to 1.59.1
2026-04-06 22:26:30 +01:00
Ullrich Schäfer
18f5388614 Bump playwright from 1.58.2 to 1.59.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:23:56 +02:00
Ullrich Schäfer
279b043faf
Merge pull request #186 from trails-cool/deps/react-router
Bump react-router packages from 7.13.2 to 7.14.0
2026-04-06 22:21:41 +01:00
Ullrich Schäfer
99cfbc6c1b
Merge pull request #185 from trails-cool/deps/sentry
Bump @sentry/node and @sentry/react from 10.46.0 to 10.47.0
2026-04-06 22:16:56 +01:00
Ullrich Schäfer
75e1d87795
Merge pull request #182 from trails-cool/deps/misc
Bump postgres and isbot
2026-04-06 22:16:18 +01:00
Ullrich Schäfer
03e945eb7b
Merge branch 'main' into deps/misc 2026-04-06 22:13:54 +01:00
Ullrich Schäfer
3b0436fe33 Bump react-router packages from 7.13.2 to 7.14.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:13:04 +02:00
Ullrich Schäfer
a96ffebde6
Merge pull request #181 from trails-cool/deps/tooling
Bump eslint, typescript-eslint, and turbo
2026-04-06 22:12:53 +01:00
Ullrich Schäfer
7c3ab0a95c Bump @sentry/node and @sentry/react from 10.46.0 to 10.47.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:11:40 +02:00
Ullrich Schäfer
2a59fc0538 Bump postgres from 3.4.8 to 3.4.9 and isbot from 5.1.36 to 5.1.37
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:10:16 +02:00
Ullrich Schäfer
0af67bef0e Bump eslint, typescript-eslint, and turbo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 23:09:49 +02:00
Ullrich Schäfer
12fde4aa20
Merge pull request #178 from trails-cool/fix/leaflet-ssr
Fix Leaflet SSR error: use .client.tsx convention
2026-04-06 21:56:00 +01:00
Ullrich Schäfer
9b2adebc4d Add ClientMap wrapper to avoid lazy() SSR resolution
.client.tsx modules return undefined on the server, but lazy() still
tries to resolve them during SSR causing "Element type is invalid".
Replace lazy()+Suspense with a ClientMap component that dynamically
imports the map only in useEffect (client-side only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:52:57 +02:00
Ullrich Schäfer
81eb38a24d Fix Leaflet SSR error: rename to .client.tsx
Leaflet accesses `window` on import, crashing during SSR. Renamed
RouteMapThumbnail to .client.tsx so React Router excludes it from
the server bundle entirely. Updated all lazy() imports to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:52:57 +02:00
Ullrich Schäfer
64231252bc
Merge pull request #180 from trails-cool/fix/planner-health-connection-leak
Fix DB connection leak in health check handlers
2026-04-06 21:42:43 +01:00
Ullrich Schäfer
f80ed10a8b Remove unused journal health route file
api.health.ts was never registered in routes.ts — the server.ts
handler intercepts /api/health before React Router sees it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:24 +02:00
Ullrich Schäfer
be13145072 Fix DB connection leak in health check handlers
Both server.ts files called createDb() on every /health request,
creating a new postgres.js connection pool (~10 connections) that was
never closed. Docker healthchecks hit this every 15s, exhausting
max_connections within minutes and causing "too many clients" errors.

Fix: use a fresh postgres.js client (max: 1) per health check that is
properly closed in a finally block. This truly tests whether the DB is
accepting new connections without leaking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:39:42 +02:00
Ullrich Schäfer
2d7844c02b
Merge pull request #179 from trails-cool/fix/openspec-validation
Fix all OpenSpec validation failures
2026-04-06 21:22:22 +01:00
Ullrich Schäfer
0b9b99984e Fix all OpenSpec validation failures (36/36 passing)
- Add ## Purpose sections and convert delta headers to ## Requirements
  on all 25 specs
- Add SHALL keywords to requirements missing them (gpx-import,
  planner-session, planner-journal-handoff)
- Convert prose GPX format section to proper scenarios (no-go-areas)
- Create specs/ delta files for 7 changes that were missing them
  (activity-photos, local-dev-stack, multi-day-routes, route-discovery,
  route-sharing, visual-redesign, waypoint-notes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:17:52 +02:00
Ullrich Schäfer
c6949e9490
Merge pull request #177 from trails-cool/fix/wahoo-import-pass-stats
Pass workout stats through on import
2026-04-05 19:06:37 +01:00
Ullrich Schäfer
e358c432e7
Fix lint: duration is never reassigned, use const
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:04:10 +02:00
Ullrich Schäfer
80291ccf6e
Pass workout stats (distance, duration, startedAt) through on import
ActivityInput now accepts optional distance, duration, and startedAt.
GPX-derived stats take precedence when available, but provider stats
are used as fallback for workouts without GPS data.

The import form and Import All now pass these fields from the Wahoo
workout data so activities show correct metadata even without a FIT file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:59:54 +02:00
Ullrich Schäfer
b331c2295f
Merge pull request #176 from trails-cool/docs/fix-infra-spec-drift
Fix infrastructure spec drift
2026-04-05 18:57:42 +01:00
Ullrich Schäfer
c6244666fd
Fix infrastructure spec drift
- Remove Garage from service list (commented out, not deployed)
- Replace S3_ENDPOINT/S3_BUCKET with actual Journal env vars
  (JWT_SECRET, SESSION_SECRET, WAHOO_*)
- Add monitoring exporters (postgres-exporter, node-exporter, cAdvisor)
  to monitoring stack scenario
- Add metrics collection requirement documenting Prometheus scrape
  targets and pg_stat_statements custom queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:57:31 +02:00
Ullrich Schäfer
6203df7543
Merge pull request #174 from trails-cool/fix/wahoo-import-bugs-and-ux
Fix Wahoo import bugs, add Import All UX
2026-04-05 16:50:30 +01:00
Ullrich Schäfer
20b91ef511
Fix Wahoo import bugs, add Import All, update spec
Bugs fixed:
- FIT parser returns timestamps as Date objects, not strings — convert
  to ISO 8601 before passing to generateGpx (caused str.replace crash)
- FIT parser already converts semicircles to degrees — remove redundant
  conversion that produced near-zero coordinates
- Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header
  from download requests (caused 400 "Unsupported Authorization Type")
- Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo
  does not share their data via the API

UX improvements:
- Individual imports use fetcher (no page refresh), button shows
  "Importing..." inline
- "Import all" button imports all unimported workouts on the page
  sequentially with progress indicator
- Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1)

Also adds unit test for FIT-to-GPX conversion with real fixture file,
and updates wahoo-import spec to reflect all behaviors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:47:48 +02:00
Ullrich Schäfer
01d7620617
Merge pull request #173 from trails-cool/feat/activity-import-ux
Add activity import source, delete, and No GPS indicator
2026-04-05 15:52:15 +01:00
Ullrich Schäfer
fdd193cbf0
Add activity import source badge, delete action, and No GPS indicator
- Activity detail: show "Imported from wahoo" badge when activity was
  imported from an external provider
- Activity detail: add delete button with confirmation, cleans up
  sync_imports so the workout can be reimported
- Import page: show "No GPS" badge with tooltip on workouts that have
  no file URL from the provider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:49:42 +02:00
Ullrich Schäfer
ab870dbfa5
Merge pull request #172 from trails-cool/fix/journal-dockerfile-copy-lib
Fix journal Dockerfile: copy app/lib for custom server
2026-04-05 15:30:27 +01:00
Ullrich Schäfer
8631c8ff14
Fix journal Dockerfile: copy app/lib for custom server imports
The custom server.ts imports logger and metrics from app/lib/ which
are TypeScript source files run via --experimental-strip-types. The
runtime image needs these source files copied (same as planner).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:30:17 +02:00
Ullrich Schäfer
cde1ddc6f1
Merge pull request #171 from trails-cool/fix/overview-error-rate-and-journal-metrics
Fix overview error rate panel, add request metrics to journal
2026-04-05 15:23:07 +01:00
Ullrich Schäfer
7994ea79f4
Fix overview error rate panel, add request metrics to journal
Dashboard: switch error rate panel from app-level http_request_duration
(never observed) to Caddy's caddy_http_response_duration_seconds_count.

Journal: add custom server.ts (matching planner pattern) that observes
http_request_duration_seconds on every request. Replaces react-router-serve
with a custom Node HTTP server that handles /api/health, /api/metrics,
static assets, and request timing. Removes redundant React Router routes
for health and metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:20:18 +02:00
Ullrich Schäfer
959dcdaa75
Merge pull request #170 from trails-cool/fix/service-health-dashboard
Fix empty Caddy and pg_stat_statements panels in service-health dashboard
2026-04-05 15:02:33 +01:00
Ullrich Schäfer
3adf6fbfc4
Fix empty Caddy and pg_stat_statements panels in service-health dashboard
- Caddy Response Status Codes: caddy_http_responses_total doesn't exist,
  use caddy_http_response_duration_seconds_count which has the code label
- pg_stat_statements: enable extension in init script, add custom queries
  config for postgres-exporter, remove invalid datname filter from query
- Add postgres/queries.yml to cd-infra SCP sources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:02:23 +02:00
Ullrich Schäfer
b7e3d315ab
Merge pull request #169 from trails-cool/docs/infra-spec-promtail
Add Promtail log shipping to infrastructure spec
2026-04-05 14:59:19 +01:00
Ullrich Schäfer
612b6afb55
Add Promtail log shipping to infrastructure spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:59:10 +02:00
Ullrich Schäfer
a4908f88f9
Merge pull request #168 from trails-cool/fix/cd-infra-promtail
Fix cd-infra to deploy Promtail config and service
2026-04-05 14:52:03 +01:00
Ullrich Schäfer
271dfa8b01
Add Promtail to cd-infra SCP sources and deploy service list
The previous PR added Promtail to docker-compose but the CD workflow
didn't copy the config file or include promtail in the service list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:51:53 +02:00
Ullrich Schäfer
826cce588e
Merge pull request #167 from trails-cool/infra/add-promtail
Add Promtail for Docker log shipping to Loki
2026-04-05 14:23:57 +01:00
Ullrich Schäfer
5a19217b33
Add Promtail to ship Docker container logs to Loki
Loki was running but had no log shipper — no labels or logs were
visible in Grafana. Adds Promtail to scrape all Docker container
logs and push them to Loki.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:23:41 +02:00
Ullrich Schäfer
38565f8cc2
Merge pull request #166 from trails-cool/dependabot/npm_and_yarn/development-e618c0ee78
Bump @types/nodemailer from 7.0.11 to 8.0.0 in the development group
2026-04-05 12:12:38 +01:00
dependabot[bot]
17a8e82217
Bump @types/nodemailer from 7.0.11 to 8.0.0 in the development group
Bumps the development group with 1 update: [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer).


Updates `@types/nodemailer` from 7.0.11 to 8.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-05 08:28:22 +00:00
Ullrich Schäfer
4d954c3d6b
Merge pull request #164 from trails-cool/fix/wahoo-import-no-file
Fix Wahoo import crash for workouts without FIT files
2026-04-04 11:41:53 +01:00
Ullrich Schäfer
9aaa17c898
Fix Wahoo import crash for workouts without FIT files
Some Wahoo workouts (indoor, manual entries) don't have a FIT file URL.
Both the manual import action and webhook handler called downloadFile
unconditionally, throwing "No file URL for workout". Now skips the
download and creates the activity without GPX when no file is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:41:28 +01:00
Ullrich Schäfer
c6fb7da666
Merge pull request #163 from trails-cool/fix/wahoo-env-vars
Fix missing Wahoo OAuth env vars in journal container
2026-04-04 11:37:00 +01:00
Ullrich Schäfer
ef39379a12
Fix missing Wahoo OAuth env vars in journal container
WAHOO_CLIENT_ID, WAHOO_CLIENT_SECRET, and WAHOO_WEBHOOK_TOKEN were in
SOPS secrets but never passed through to the journal service in
docker-compose.yml, causing the OAuth authorize URL to have an empty
client_id.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:36:43 +01:00
Ullrich Schäfer
ff93a4cfdd
Archive wahoo-import and journal-route-previews, sync specs
Archive completed changes and sync their delta specs to main:
- wahoo-import: new wahoo-import spec, updated journal-auth and account-settings
- journal-route-previews: new route-preview spec, updated map-display and route-management

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:33:16 +01:00
Ullrich Schäfer
4c5aacf223
Merge pull request #162 from trails-cool/feat/wahoo-import
Add Wahoo activity sync with provider-agnostic framework
2026-04-04 11:24:24 +01:00
Ullrich Schäfer
b6711d23d6
Add Wahoo activity sync with provider-agnostic framework
Provider-agnostic sync framework + Wahoo as first implementation:

Framework (apps/journal/app/lib/sync/):
- SyncProvider interface for OAuth2, webhooks, import, conversion
- Provider registry for settings UI iteration
- Generic sync_connections + sync_imports tables
- Token storage with auto-refresh

Wahoo provider:
- OAuth2 with workouts_read, user_read, offline_data scopes
- Webhook-based auto-import (workout_summary events)
- Manual import page with pagination
- FIT→GPX conversion via fit-file-parser
- Webhook token verification

Routes:
- /api/sync/connect/:provider — OAuth redirect
- /api/sync/callback/:provider — OAuth callback
- /api/sync/disconnect/:provider — remove connection
- /api/sync/webhook/:provider — webhook receiver
- /sync/import/:provider — manual import page

Settings: Connected Services section with per-provider connect/disconnect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:22:02 +01:00
Ullrich Schäfer
dd6543e313
Merge pull request #161 from trails-cool/feat/undo-redo
Add undo/redo to planner
2026-04-04 10:06:43 +01:00
Ullrich Schäfer
743169550e
Add undo/redo to planner
Yjs UndoManager tracks waypoints, no-go areas, and notes. Only
local mutations (origin "local") are undoable — remote changes
from other users are not.

- Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo)
- Shortcuts suppressed in text inputs (browser-native undo there)
- Undo/redo buttons in header with disabled state
- stopCapturing on drag to isolate drag as one undo step
- All mutation sites wrapped with "local" origin
- 5 unit tests for UndoManager behavior
- Archived undo-redo change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:04:01 +01:00
Ullrich Schäfer
52bd063a2a
Merge pull request #160 from trails-cool/feat/journal-route-previews
Add map previews to journal route and activity pages
2026-04-04 09:44:38 +01:00
Ullrich Schäfer
6aac8bd885
Add map previews to journal route and activity pages
- Route/activity list pages: map thumbnails with route drawn on OSM tiles
- Route/activity detail pages: interactive Leaflet map with zoom controls
- Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists)
- RouteMapThumbnail component: shared, supports thumbnail and interactive modes
- Placeholder shown for routes/activities without geometry
- Archive journal-route-previews change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:42:07 +01:00
Ullrich Schäfer
3a4af698ca
Merge pull request #159 from trails-cool/feat/gpx-import-planner
Add GPX file import to planner
2026-04-03 17:34:15 +01:00
Ullrich Schäfer
a9f8ee61f0
Add GPX file import to planner + archive change
Two import entry points:
- Home page: "Import GPX" button next to "Start Planning"
- In-session: drag-and-drop GPX onto the map (with confirmation)

Parses GPX client-side, extracts waypoints (Douglas-Peucker) and
no-go areas from extensions. Non-GPX files show error toast.

Also:
- Fix spec drift: non-GPX drop now shows error toast (was silent)
- Add E2E tests for import button, invalid GPX, and session creation
- Archive gpx-import-planner change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:31:44 +01:00
Ullrich Schäfer
516b86aa3d
Merge pull request #158 from trails-cool/feat/export-plan-with-nogos
Add split export: Export Route vs Export Plan
2026-04-03 12:55:39 +01:00
Ullrich Schäfer
7ef694c366
Update no-go areas spec with GPX persistence and export options
Documents:
- Right-click to delete
- Save to Journal preserves no-go areas in GPX extensions
- Export Plan includes full planning state
- Export Route is clean track only
- GPX extensions format with trails:planning namespace

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:53:17 +01:00
Ullrich Schäfer
180f1fc4bd
Add unit tests for no-go area round-trip and waypoint extraction
15 tests covering:
- No-go area generation (namespace, structure, multiple areas)
- No-go area parsing (namespaced, non-namespaced, min 3 points, empty)
- Full round-trip: generate → parse → verify no-go areas match
- Complete planning state round-trip (waypoints + tracks + no-go areas)
- extractWaypoints: explicit waypoints, multi-segment, Douglas-Peucker,
  empty tracks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:50:57 +01:00
Ullrich Schäfer
b6179fbdd2
Initialize no-go areas client-side via URL params
In dev mode, the sessions API and the Yjs WebSocket server are
separate processes (Vite plugin vs React Router action), so
server-side Yjs doc initialization doesn't reach the client.

Now no-go areas flow the same way as waypoints:
API response → URL params → client-side Yjs initialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:47:40 +01:00
Ullrich Schäfer
fdfacd2997
Save full planning state to journal (waypoints + no-go areas)
Save to Journal now includes waypoints and no-go areas alongside
the track — the full planning state needed for round-tripping.
Export Route remains the clean track-only option for other apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:41:35 +01:00
Ullrich Schäfer
45d1360be5
Include no-go areas when saving to journal
Save to Journal now includes no-go areas in GPX extensions so they
round-trip correctly: Planner → Journal → Edit in Planner preserves
the no-go polygons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:32:05 +01:00
Ullrich Schäfer
6ccf7af7b1
Fix no-go area parsing for namespaced XML elements
querySelectorAll('nogo') doesn't match <trails:nogo>. Now matches
both unprefixed and prefixed element names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:26:54 +01:00
Ullrich Schäfer
e12479293d
Add descriptions to export dropdown, fix z-index and alignment
- Add description text below each export option explaining the difference
- Fix z-index (z-[1001]) so dropdown renders above the map
- Align dropdown left instead of right to avoid overflow
- Widen dropdown (w-56) to fit descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:06:58 +01:00
Ullrich Schäfer
87251ea366
Fix dropdown race: use mousedown for outside-click handler
The document click handler registered on open could catch the same
click event that opened the dropdown, immediately closing it.
Using mousedown avoids the race since it fires before click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:56:22 +01:00
Ullrich Schäfer
721ec9e5e5
Fix export chevron: wider padding + stop click propagation
The chevron was too narrow (px-1.5 → px-2.5) and clicks were
caught by the outside-click handler before toggling the dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:53:18 +01:00
Ullrich Schäfer
4b711abaec
Add split export: Export Route vs Export Plan
Split button with default "Export GPX" (track only) and dropdown:
- Export Route: clean track for use in any app
- Export Plan: track + waypoints + no-go areas in GPX extensions
  (trails:planning namespace) for reimporting into the planner

Also:
- Add no-go area serialization to generateGpx (GPX extensions)
- Parse no-go areas from GPX extensions on import
- Initialize no-go areas in Yjs session from imported GPX
- Save to Journal now exports track only (consistent with Export Route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:39 +01:00
Ullrich Schäfer
237660db5e
Merge pull request #156 from trails-cool/fix/nogo-button-click-through
Fix no-go area button click creating a waypoint
2026-04-03 10:40:05 +01:00
Ullrich Schäfer
16568ffa45
Fix no-go button click creating a waypoint
The button was rendered as a React component, not a Leaflet control,
so clicks propagated to the map. Use L.DomEvent.disableClickPropagation
on the container — same approach Leaflet's built-in controls use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:37:43 +01:00
Ullrich Schäfer
596d96d8cb
Merge pull request #155 from trails-cool/fix/nogo-polygons-native
Pass no-go area polygons natively to BRouter
2026-04-03 10:34:08 +01:00
Ullrich Schäfer
3abea9d5a8
Pass no-go area polygons natively to BRouter
BRouter supports a `polygons` parameter with full vertex coordinates.
We were approximating polygons as circles (centroid + max radius),
which poorly represented elongated or concave shapes.

Now passes polygon vertices directly — no approximation, exact
no-go boundaries.

Also removes unused haversineMeters helper and updates the spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:31:43 +01:00
Ullrich Schäfer
d2ff488f37
Merge pull request #154 from trails-cool/chore/prometheus-5s-scrape
Reduce Prometheus scrape interval to 5s
2026-04-03 10:16:38 +01:00
Ullrich Schäfer
55076ef440
Reduce Prometheus scrape interval from 15s to 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:15:08 +01:00
Ullrich Schäfer
5a5fe87905
Merge pull request #149 from trails-cool/fix/planner-metrics-gauges
Fix planner metrics gauges always reporting zero
2026-04-03 10:04:40 +01:00
Ullrich Schäfer
d97992e967
Guard all metric registrations against re-evaluation
Vite's dev server can re-evaluate modules, causing prom-client
"already registered" errors for all metrics, not just default ones.
Use getOrCreate pattern to reuse existing metrics from the registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:02:21 +01:00
Ullrich Schäfer
a3ad073d96
Guard collectDefaultMetrics against duplicate registration
Importing metrics.server.ts from yjs-server.ts caused
collectDefaultMetrics() to re-register during HMR, crashing
the planner dev server. Check if metrics already exist first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:16:31 +01:00
Ullrich Schäfer
e083ee134d
Wire planner metrics gauges to actual session/client counts
plannerActiveSessions and plannerConnectedClients were registered
but never updated — they always reported 0. Now incremented on
WebSocket connect and decremented on close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:16:31 +01:00
Ullrich Schäfer
ca057eb9d4
Merge pull request #153 from trails-cool/chore/update-drifted-specs
Update specs to match implementation
2026-04-03 09:15:31 +01:00
Ullrich Schäfer
02939ca828
Update specs to match implementation
Addresses spec drift items #3, 5, 6, 7, 8, 9, 10, 11, 12 from #147:

- transactional-emails: Resend → Nodemailer + SMTP
- infrastructure: CX21 → cx23
- secret-management: single secrets.env → split app/infra files
- brouter-integration: 5s failover delay → instant via clientID election
- brouter-integration: 2 profiles → 5 (trekking, fastbike, safety, shortest, car)
- observability: add version field to health response
- observability: add brouter_request_duration_seconds metric
- planner-session: remove 30-day max ceiling (not enforced)
- shared-packages: clarify map package scope vs planner-specific features

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:13:07 +01:00
Ullrich Schäfer
9e823317b9
Merge pull request #152 from trails-cool/fix/remove-sync-parsegpx
Fix GPX distance + remove sync parseGpx
2026-04-03 09:11:36 +01:00
Ullrich Schäfer
2b0b89add8
Use epsilon 0.005 for Douglas-Peucker waypoint extraction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:40:01 +01:00
Ullrich Schäfer
0e27d6eaef
Use Douglas-Peucker simplification for single-segment GPX
For GPX files with one long track segment, extracting only start/end
gives just 2 waypoints. Now uses Douglas-Peucker line simplification
(epsilon=0.05° ≈ 5km) to find significant turning points.

Result: berlin-dresden-radweg (249km, 5660 points) → 10 waypoints
that capture the route's key direction changes.

Multi-segment GPX files still use segment endpoints as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:36:13 +01:00
Ullrich Schäfer
cc90c32a0e
Extract shared extractWaypoints into @trails-cool/gpx
Both new.tsx and api.sessions.ts had duplicated track-segment
waypoint extraction logic. Moved to packages/gpx as
extractWaypoints(gpxData) — uses <wpt> elements when present,
falls back to start of each track segment + end of last.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:47:17 +01:00
Ullrich Schäfer
e0f7c5b806
Apply track-segment waypoint extraction to sessions API
Same fix as new.tsx — the journal→planner handoff also only used
<wpt> elements from GPX. Now both code paths extract waypoints
from track segments when no explicit waypoints exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:45:05 +01:00
Ullrich Schäfer
79942bb99e
Extract start/end from track when GPX has no waypoints
The planner's GPX import only used <wpt> elements, which many GPX
files don't have. Now falls back to the first and last track point,
giving the planner two endpoints to route between.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:41:29 +01:00
Ullrich Schäfer
5f3d4ae846
Remove sync parseGpx, use parseGpxAsync everywhere
parseGpx (sync) needs browser DOMParser which doesn't exist on the
server — it silently failed in every server-side caller. Removed the
export and migrated all callers to parseGpxAsync. Updated tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:32:48 +01:00
Ullrich Schäfer
d5bcecb345
Fix distance calculation for GPX files without elevation data
Distance was only stored in the elevation profile array, which was
empty when track points had no <ele> elements. Now computed via
haversine independently and exposed as gpxData.distance.

Tested: berlin-dresden-radweg-2021.gpx (5660 points, no elevation)
now correctly reports 248.8 km.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:32:47 +01:00
Ullrich Schäfer
ed2d04c248
Merge pull request #151 from trails-cool/fix/postgis-geom-async-parse
Fix PostGIS geom: use parseGpxAsync + raw SQL
2026-04-02 17:34:43 +01:00
Ullrich Schäfer
6ed828ef9c
Fix PostGIS geom population: use parseGpxAsync + raw SQL
The initial approach (#150) didn't work because:
1. routes.server.ts used parseGpx (sync, needs browser DOMParser)
   instead of parseGpxAsync (uses linkedom on Node.js) — GPX parsing
   silently failed, so stats AND geom were never populated
2. Drizzle's customType doesn't pass SQL expressions through toDriver

Fixes:
- Switch to parseGpxAsync for all server-side GPX parsing
- Use db.execute() with raw ST_GeomFromGeoJSON SQL after insert
- Remove unused lineStringFromCoords helper from schema
- Share setGeomFromGpx between routes and activities
- Add unit tests for GPX→GeoJSON coordinate extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:32:07 +01:00
Ullrich Schäfer
feeca58064
Merge pull request #150 from trails-cool/fix/populate-postgis-geom
Populate PostGIS geom column from GPX track data
2026-04-02 17:07:26 +01:00
Ullrich Schäfer
b4c3f97296
Populate PostGIS geom column from GPX track data
Extract coordinates from parsed GPX tracks and store as PostGIS
LineString geometry via ST_GeomFromGeoJSON. Applied to:
- createRoute / updateRoute (routes.server.ts)
- createActivity / createRouteFromActivity (activities.server.ts)

Adds lineStringFromCoords() helper to the journal schema that
builds the SQL expression from [lon, lat] coordinate pairs.

Unblocks spatial queries (e.g. route discovery via ST_Intersects).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:05:02 +01:00
Ullrich Schäfer
47baa11e69
Merge pull request #148 from trails-cool/fix/grafana-oauth-user-lookup
Fix Grafana OAuth creating duplicate users
2026-04-02 16:41:54 +01:00
Ullrich Schäfer
acccfcc53b
Enable Grafana OAuth email lookup to reuse existing users
Without this, GitHub OAuth creates a new Grafana user on every login
instead of linking to the existing account by email.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:39:35 +01:00
Ullrich Schäfer
d1faf21a83
Merge pull request #146 from trails-cool/feat/europe-brouter-segments
Expand BRouter routing to all of Europe
2026-03-29 22:49:16 +02:00
Ullrich Schäfer
836e0a2b6a
Expand BRouter routing coverage to all of Europe
Add ~67 segment tiles covering W10-E40, N35-N70 (was 4 Germany-only
tiles). Total disk usage ~2.9 GB. Tiles that don't exist on the
BRouter CDN are silently skipped.

Already downloaded to the server and BRouter restarted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:48:11 +02:00
Ullrich Schäfer
56cd6b1ccc
Merge pull request #145 from trails-cool/chore/archive-account-settings
Archive account-settings change
2026-03-29 22:44:20 +02:00
Ullrich Schäfer
7e253caf64
Archive account-settings change, sync specs
All 37 tasks complete. Synced delta specs to main:
- New: openspec/specs/account-settings/
- Updated: openspec/specs/journal-auth/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:41:51 +02:00
Ullrich Schäfer
0150c40631
Merge pull request #144 from trails-cool/chore/harden-sshd
Harden SSH config
2026-03-29 22:33:57 +02:00
Ullrich Schäfer
8eb3481831
Harden SSH: disable passwords, forwarding, add keepalive
Add SSH hardening to user_data for future server rebuilds:
- PasswordAuthentication no (key-only access)
- X11Forwarding no (headless server)
- AllowTcpForwarding no (no SSH tunnels needed)
- AllowAgentForwarding no (no agent forwarding needed)
- MaxAuthTries 3 (reduced from 6)
- ClientAliveInterval 300 + ClientAliveCountMax 2 (clean up dead sessions)

Already applied manually to the existing server via
/etc/ssh/sshd_config.d/99-hardening.conf.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:32:30 +02:00
Ullrich Schäfer
7d63e2b754
Merge pull request #143 from trails-cool/fix/unwrap-dashboard-json
Fix dashboard JSON: unwrap for Grafana provisioning
2026-03-29 22:21:26 +02:00
Ullrich Schäfer
3b6aede461
Fix error-rate-high alert firing on no data
The alert was firing with DatasourceNoData because zero 5xx responses
means the numerator returns empty (not 0). Fixed by:
- Adding `or vector(0)` so zero 5xx returns 0% instead of empty
- Setting `noDataState: OK` as a safety net

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:20:14 +02:00
Ullrich Schäfer
d45d6e3eaf
Unwrap dashboard JSON for Grafana provisioning
Grafana file-based provisioning expects flat dashboard JSON, not the
API format with a "dashboard" wrapper. The 4 wrapped dashboards were
silently failing to load — only business.json (already flat) worked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:18:06 +02:00
Ullrich Schäfer
1103deb7e8
Merge pull request #142 from trails-cool/fix/grafana-annotation-filter-type
Fix Grafana annotation filter: remove type:dashboard
2026-03-29 22:00:07 +02:00
Ullrich Schäfer
9223eacb91
Remove type:dashboard from annotation config
The type:dashboard field was causing Grafana to filter annotations
by dashboard scope. Our deploy annotations are global (no dashboardUID),
so the target.type:tags is the only filter needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:57:39 +02:00
Ullrich Schäfer
16fd07bd97
Merge pull request #141 from trails-cool/fix/grafana-annotation-target
Fix Grafana deploy annotation query
2026-03-29 21:53:57 +02:00
Ullrich Schäfer
cafedfe6fc
Fix Grafana annotation query: add target with type tags
The annotation config was missing the target block that tells
Grafana to filter by tags. Without it, the annotation query
was silently ignored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:53:33 +02:00
Ullrich Schäfer
af12837772
Merge pull request #140 from trails-cool/fix/deploy-annotations-curl-dashboards
Fix deploy annotations: wget→curl + dashboard queries
2026-03-29 20:51:06 +02:00
Ullrich Schäfer
fcfea209b8
Add deploy annotation query to all Grafana dashboards
Provisioned dashboards now display deploy markers (cyan vertical
lines) by querying built-in Grafana annotations tagged "deploy".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:49:40 +02:00
Ullrich Schäfer
87be96db67
Switch deploy annotations from BusyBox wget to curl
BusyBox wget in the Grafana Alpine container doesn't support
--post-data/--header. Grafana's image includes curl.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:49:40 +02:00
Ullrich Schäfer
7d0ad7fe31
Merge pull request #139 from trails-cool/fix/deploy-annotation-env-file
Fix deploy annotation reading token from wrong env file
2026-03-29 20:00:07 +02:00
Ullrich Schäfer
c2b9032686
Fix deploy annotation reading token from wrong env file
GRAFANA_SERVICE_TOKEN is in secrets.infra.env, which the infra
workflow merges into .env on the server. The apps workflow was
reading from app.env (which only has app secrets).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:59:17 +02:00
Ullrich Schäfer
8440c2de8f
Merge pull request #138 from trails-cool/chore/grafana-service-token
Add GRAFANA_SERVICE_TOKEN to encrypted infra secrets
2026-03-29 19:51:45 +02:00
Ullrich Schäfer
8ab174d5bb
Add GRAFANA_SERVICE_TOKEN to encrypted infra secrets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:49:29 +02:00
Ullrich Schäfer
4d8b9a6bb4
Merge pull request #137 from trails-cool/feat/grafana-deploy-annotations
Add Grafana deploy annotations to CD workflows
2026-03-29 19:47:53 +02:00
Ullrich Schäfer
6c482238e8
Add Grafana deploy annotations to all CD workflows
Posts an annotation to Grafana after each successful deploy, tagged
with "deploy" and the service name. Annotations appear as vertical
markers on dashboards, making it easy to correlate deploys with
metric changes.

Uses GRAFANA_SERVICE_TOKEN from SOPS secrets — must be added via
sops infrastructure/secrets.infra.env. Gracefully skips if the
token is not configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:44:15 +02:00
Ullrich Schäfer
f6b89a2b7e
Merge pull request #135 from trails-cool/copilot/change-map-zoom-for-route
Fit map to route bounds on open
2026-03-29 19:38:05 +02:00
Ullrich Schäfer
156bb7cb36
Merge branch 'main' into copilot/change-map-zoom-for-route 2026-03-29 19:35:27 +02:00
Ullrich Schäfer
99a4d7af97
Fix fitBounds running before elevation chart resizes map
invalidateSize() + requestAnimationFrame ensures Leaflet knows
the map's actual dimensions after the elevation chart renders.
Also removes unnecessary filter on already-typed coordinates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:20:06 +02:00
Ullrich Schäfer
fe3b1f5a79
Merge pull request #136 from trails-cool/copilot/add-agents-file-and-context
Add GitHub Copilot context files
2026-03-29 15:15:52 +02:00
copilot-swe-agent[bot]
d20505720c
Restore 'primary development tool' qualifier for Claude Code in README
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/5338ac00-6f6c-4e04-8d15-3c443d87eab4

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-03-29 13:12:04 +00:00
copilot-swe-agent[bot]
2079b7e6de
Simplify copilot-instructions.md to point to CLAUDE.md instead of duplicating content
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/bc100497-d093-4f44-bf81-5fbd357af6b3

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-03-29 12:41:30 +00:00
copilot-swe-agent[bot]
1b9683752f
Fit map to route bounds when opening a route in the planner
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/558ccff8-ed5d-432a-a5d3-aca49c0e531e

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-03-29 12:31:40 +00:00
copilot-swe-agent[bot]
0ac1d4ca39
Add .github/copilot-instructions.md and update README for GitHub Copilot
Agent-Logs-Url: https://github.com/trails-cool/trails/sessions/d830c6a7-0796-4aeb-8169-d7a7718a6170

Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
2026-03-29 12:30:59 +00:00
copilot-swe-agent[bot]
39c58185b8
Initial plan 2026-03-29 12:28:15 +00:00
copilot-swe-agent[bot]
9de1a05903
Initial plan 2026-03-29 12:26:51 +00:00
Ullrich Schäfer
28fa0f6875
Merge pull request #134 from trails-cool/feat/route-error-toasts
Add route error toasts with waypoint rollback
2026-03-29 14:11:45 +02:00
Ullrich Schäfer
b9a77be020
Add route error toasts with waypoint rollback
- Show error toasts when route calculation fails, with distinct messages:
  - "No route found" for BRouter 4xx (unroutable waypoints, missing tiles)
  - "Route calculation failed" for server errors / network failures
  - "Too many requests" for rate limiting
- Rollback all waypoints to last known good state on route failure
- Guard against restore→recompute loop via restoringRef flag
- Extract reusable useToasts hook from awareness toast logic
- Add BRouterError class to distinguish client vs server errors
- API returns 422 for unroutable requests instead of blanket 502

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:09:20 +02:00
Ullrich Schäfer
6a18980d7e
Merge pull request #133 from trails-cool/fix/waypoint-drag-ghost-marker
Fix waypoint drag creating unwanted split points
2026-03-29 12:36:38 +02:00
Ullrich Schäfer
400189d9c1
Fix waypoint drag creating unwanted split points
The ghost marker (route split indicator) was interfering with waypoint
dragging because it sat above waypoints (zIndexOffset 1000) and stayed
active during drag operations.

Adopt brouter-web's suspension pattern:
- Suspend route interaction on waypoint mouseover (not just dragstart),
  unsuspend on mouseout — ghost marker hides before any click/drag
- Lower ghost marker zIndexOffset to -100 so waypoints always receive
  clicks first
- Keep drag guards as a safety net

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 12:34:09 +02:00
Ullrich Schäfer
26dd1a4d83
Merge pull request #132 from trails-cool/chore/upgrade-i18next-v26
Upgrade i18next v26 + react-i18next v17 with SSR-safe init
2026-03-29 11:55:52 +02:00
Ullrich Schäfer
4403e22006
Remove useEffect flicker from ClientDate
Now that both server and client use the same locale from context,
the post-hydration useEffect that re-formatted with the browser's
OS locale was causing a visible flicker. Just render with the
context locale directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:48:01 +02:00
Ullrich Schäfer
38ada3bd4f
Pass full locale from Accept-Language to ClientDate
Add detectLocale() that extracts the full locale tag (e.g. "de-DE")
from the request, not just the language. The journal root loader passes
it via LocaleContext so ClientDate renders with the correct locale on
both server and client — no more hardcoded "en-US" fallback.

Also fixes settings page hydration mismatch by using ClientDate for
passkey creation dates instead of inline toLocaleDateString().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:46:21 +02:00
Ullrich Schäfer
79f000fcc5
Upgrade i18next v26 + react-i18next v17 with SSR-safe init
Split initI18n() into separate server/client initialization paths
inspired by remix-i18next's architecture:

- initI18nServer(lng): no LanguageDetector, per-request language from
  Accept-Language header
- initI18nClient(): LanguageDetector reads <html lang> first to match
  server, then localStorage and navigator
- detectLanguage(request): parses Accept-Language for best match

Fixes rehydration errors caused by v26 removing initImmediate option
(replaced with initAsync) and LanguageDetector running on the server
where browser APIs don't exist.

Also makes <html lang> dynamic instead of hardcoded "en".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:38:36 +02:00
Ullrich Schäfer
4be716687a
Merge pull request #126 from trails-cool/dependabot/npm_and_yarn/production-54355eb8c8
Bump the production group with 10 updates
2026-03-29 10:58:53 +02:00
Ullrich Schäfer
f597ff2f85
Merge branch 'main' into dependabot/npm_and_yarn/production-54355eb8c8 2026-03-29 10:53:17 +02:00
Ullrich Schäfer
8bfd923368
Merge pull request #130 from trails-cool/fix/home-passkey-prompt
Only show add-passkey prompt for users without passkeys
2026-03-29 10:53:09 +02:00
Ullrich Schäfer
4859f5d5e0
Only show add-passkey prompt when user has no passkeys
The prompt now only appears when coming from signup/login with
?add-passkey=1 AND the user has zero passkeys. Passkey count display
removed from home — that info lives in /settings now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:50:30 +02:00
Ullrich Schäfer
c825005249
Merge pull request #128 from trails-cool/chore/static-assets
Add favicons and static assets to silence 404s
2026-03-29 10:43:47 +02:00
Ullrich Schäfer
57a41501da
Merge branch 'main' into dependabot/npm_and_yarn/production-54355eb8c8 2026-03-29 10:41:28 +02:00
Ullrich Schäfer
46a7c1be34
Merge branch 'main' into chore/static-assets 2026-03-29 10:41:22 +02:00
Ullrich Schäfer
5735a0dc8f
Merge pull request #129 from trails-cool/chore/skip-gitleaks-dependabot
Skip Gitleaks scan for Dependabot PRs
2026-03-29 10:41:07 +02:00
Ullrich Schäfer
60d7220634
Merge branch 'main' into chore/skip-gitleaks-dependabot 2026-03-29 10:40:56 +02:00
Ullrich Schäfer
d797564201
Skip Gitleaks scan for Dependabot PRs
Gitleaks requires a license key for org repos. Skip the step (not the
job) for dependabot[bot] so the Security Scan job still passes as a
required status check. pnpm audit still runs for all PRs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:40:27 +02:00
Ullrich Schäfer
33c0315941
Revert "Skip Gitleaks scan for Dependabot PRs"
This reverts commit b15c5af72c.
2026-03-29 10:40:08 +02:00
Ullrich Schäfer
b15c5af72c
Skip Gitleaks scan for Dependabot PRs
Gitleaks requires a license key for org repos. Skip the step (not the
job) for dependabot[bot] so the Security Scan job still passes as a
required status check. pnpm audit still runs for all PRs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:38:41 +02:00
Ullrich Schäfer
d426912945
Merge branch 'main' into chore/static-assets 2026-03-29 10:36:22 +02:00
Ullrich Schäfer
518ee9ab69
Add favicons, apple-touch-icons, and Chrome DevTools JSON
Eliminates 404 noise from:
- /favicon.ico (browsers)
- /apple-touch-icon.png, /apple-touch-icon-precomposed.png (iOS)
- /.well-known/appspecific/com.chrome.devtools.json (Chrome)

Favicon uses the Waypoint Dot logo mark (sage green, SVG + ICO).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:35:03 +02:00
Ullrich Schäfer
107d926a0e
Merge pull request #124 from trails-cool/passkey-browser-compat
Passkey browser compat, magic link registration, account settings
2026-03-29 10:34:23 +02:00
Ullrich Schäfer
8bc426daf4
Merge branch 'main' into passkey-browser-compat 2026-03-29 10:31:53 +02:00
Ullrich Schäfer
a766bffad5
Add purpose column to magic_tokens, fix email change race condition
- Add `purpose` column (default: 'login') to distinguish login tokens
  from email-change tokens
- verifyMagicToken only matches purpose='login'
- verifyEmailChange only matches purpose='email-change'
- Re-check email availability at verification time, not just initiation
  — prevents race where someone registers with the new email between
  request and verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:28:53 +02:00
dependabot[bot]
aa4d491950
Bump the production group with 10 updates
Bumps the production group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.13.1` | `7.13.2` |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.13.1` | `7.13.2` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.13.1` | `7.13.2` |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.45.1` | `0.45.2` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.13.1` | `7.13.2` |
| [turbo](https://github.com/vercel/turborepo) | `2.8.20` | `2.8.21` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.57.1` | `8.57.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.0` | `4.1.2` |
| [@sentry/node](https://github.com/getsentry/sentry-javascript) | `10.45.0` | `10.46.0` |
| [@sentry/react](https://github.com/getsentry/sentry-javascript) | `10.45.0` | `10.46.0` |


Updates `@react-router/dev` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.13.2/packages/react-router-dev)

Updates `@react-router/node` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.13.2/packages/react-router-node)

Updates `@react-router/serve` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.13.2/packages/react-router-serve)

Updates `drizzle-orm` from 0.45.1 to 0.45.2
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.1...0.45.2)

Updates `react-router` from 7.13.1 to 7.13.2
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

Updates `turbo` from 2.8.20 to 2.8.21
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.8.20...v2.8.21)

Updates `typescript-eslint` from 8.57.1 to 8.57.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.2/packages/typescript-eslint)

Updates `vitest` from 4.1.0 to 4.1.2
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

Updates `@sentry/node` from 10.45.0 to 10.46.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.45.0...10.46.0)

Updates `@sentry/react` from 10.45.0 to 10.46.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.45.0...10.46.0)

---
updated-dependencies:
- dependency-name: "@react-router/dev"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/node"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@react-router/serve"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: turbo
  dependency-version: 2.8.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: typescript-eslint
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: "@sentry/node"
  dependency-version: 10.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: "@sentry/react"
  dependency-version: 10.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 08:26:40 +00:00
Ullrich Schäfer
715b272395
Fix i18n: use common:key namespace syntax for cross-namespace keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:19:11 +02:00
Ullrich Schäfer
881374008f
Merge pull request #125 from trails-cool/openspec-account-settings
Add account-settings OpenSpec change
2026-03-29 10:17:25 +02:00
Ullrich Schäfer
070a316164
Fix missing common.delete i18n key in settings page
Use both journal and common namespaces so t("common.delete") resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:16:41 +02:00
Ullrich Schäfer
25b6e36fb9
Merge branch 'main' into openspec-account-settings 2026-03-29 10:14:54 +02:00
Ullrich Schäfer
be5766fa50
Add account settings page with passkey management
- /settings with profile, security, and account sections
- Profile: edit display name and bio
- Security: list passkeys with device labels, add/delete with
  last-passkey warning
- Account: email change with magic link verification, account
  deletion with username confirmation
- Settings link in nav for authenticated users
- E2E tests: auth guard, profile editing, passkey add/delete,
  last-passkey warning, account deletion, nav visibility
- i18n: full en + de translations for all settings UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:09:06 +02:00
Ullrich Schäfer
388a7d4866
Add account-settings OpenSpec change
Settings page at /settings with profile editing, passkey management
(list, add, delete), email change with verification, and account
deletion. Includes comprehensive E2E test plan using virtual WebAuthn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:00:46 +02:00
Ullrich Schäfer
14ab9758c8
Merge pull request #123 from trails-cool/openspec-breakout-specs
Break up route-features into focused OpenSpec changes
2026-03-29 09:56:15 +02:00
Ullrich Schäfer
0230d93004
Update journal-auth spec for browser compat changes
Add scenarios for: magic link registration fallback, WebAuthn detection
on login/register, unsupported browser messaging, passkey count display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:56:12 +02:00
Ullrich Schäfer
9adf9b977a
Passkey browser compatibility: detect support, magic link fallback
- Login: hide passkey button when browser lacks WebAuthn, default to
  magic link mode, hide "back to passkey" link
- Register: detect passkey support and offer magic link registration as
  fallback. New server-side registerWithMagicLink() creates account
  without credential and sends verification email.
- Home: show passkey count for logged-in users, show amber unsupported
  message instead of hiding the add-passkey prompt entirely
- i18n: add passkeyNotSupported, passkeyNotSupportedRegister,
  registerWithMagicLink, passkeyStatus keys (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:54:37 +02:00
Ullrich Schäfer
0a330e4466
Break up route-features into focused specs, add new changes
Archive the monolithic route-features spec and replace with 9 focused
OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping),
undo-redo, local-dev-stack, route-sharing, route-discovery,
activity-photos, osm-overlays, plus the existing changelog and
komoot-import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:53:41 +02:00
Ullrich Schäfer
9c7891402f
Merge pull request #122 from trails-cool/visual-redesign-spec
Add visual redesign spec, mockups, and logo concepts
2026-03-28 19:28:14 +01:00
Ullrich Schäfer
36b9b6b382
Add visual redesign OpenSpec change with mockups and logo concepts
Design direction: D1 warmth + D3 lightness (warm off-whites, sage green,
Outfit + Geist Mono). Includes desktop + mobile HTML mockup, 5 logo
concepts, and 33 implementation tasks across 7 phases.

Logo decision: Waypoint Dot mark (3 connected dots) + wordmark combo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:28:08 +01:00
Ullrich Schäfer
28d6d02858
Merge pull request #121 from trails-cool/fix-error-rate-alert
Fix error rate alert firing on zero traffic
2026-03-28 07:54:54 +01:00
Ullrich Schäfer
2b0aba205f
Fix error rate alert: avoid NaN when no traffic
The error rate formula (errors/total*100) produces NaN when there's
zero traffic (0/0). Grafana treats NaN as a firing condition.
Use clamp_min on the denominator to return 0% instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 07:54:49 +01:00
Ullrich Schäfer
88792b5898
Merge pull request #120 from trails-cool/fix-dependabot-alerts
Fix Dependabot alerts: brace-expansion, path-to-regexp
2026-03-28 07:52:01 +01:00
Ullrich Schäfer
55afc4cbf3
Fix Dependabot alerts: brace-expansion and path-to-regexp
- Override brace-expansion >=4.0.0 <5.0.5 → 5.0.5 (process hang via zero-step sequences)
- Override path-to-regexp <0.1.13 → 0.1.13 (ReDoS via Express transitive dep)
- Dismissed esbuild alert #1 (already on 0.25.12+, alert for <=0.24.2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 07:49:37 +01:00
Ullrich Schäfer
ff1a2ba2ff
Merge pull request #119 from trails-cool/archive-observability
Archive observability, sync specs
2026-03-27 21:44:12 +01:00
Ullrich Schäfer
81d0feffdd
Archive observability change, sync specs to main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:44:07 +01:00
Ullrich Schäfer
5915907656
Merge pull request #118 from trails-cool/fix-planner-csp
Fix CSP: allow Planner to connect to Journal for callbacks
2026-03-27 21:34:45 +01:00
Ullrich Schäfer
f344c27478
Allow Planner to connect to Journal in CSP
The Planner's save-to-journal callback makes a cross-origin request to
trails.cool from planner.trails.cool. Add the Journal's origin to the
Planner's connect-src CSP directive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:34:41 +01:00
Ullrich Schäfer
39b57b73b7
Add passkey authentication E2E tests
Uses Playwright's virtual WebAuthn authenticator (CDP) to test:
- Register with passkey + sign in (full round-trip)
- Login fails with no credential (friendly error message)
- Duplicate email rejection
- Duplicate username rejection

Would have caught the credential encoding bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:33:34 +01:00
Ullrich Schäfer
24fc6cc089
Merge pull request #116 from trails-cool/fix-passkey-credential-encoding
Fix passkey credential ID encoding
2026-03-27 21:20:43 +01:00
Ullrich Schäfer
d95d142122
Fix passkey credential ID encoding and improve error handling
Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".

Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.

Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.

Existing passkeys must be re-registered after deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:18:14 +01:00
Ullrich Schäfer
3180017f16
Merge pull request #115 from trails-cool/fix-planner-health
Fix Planner health check DB connection
2026-03-27 20:58:22 +01:00
Ullrich Schäfer
7fdbd4cddf
Fix Planner health check: use createDb instead of non-existent db export
@trails-cool/db doesn't export a db instance — only createDb and withDb.
The health check was importing a non-existent export, always failing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:58:17 +01:00
Ullrich Schäfer
93befca4ac
Merge pull request #114 from trails-cool/disable-sentry-replays
Disable Sentry session replays
2026-03-27 20:52:15 +01:00
Ullrich Schäfer
9187c3ce2b
Disable Sentry session replays
Removes replayIntegration and replay sample rates from both apps.
Quota was already exhausted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:52:10 +01:00
Ullrich Schäfer
b4412d1b9c
Merge pull request #113 from trails-cool/health-version
Add deployed version to health endpoints
2026-03-27 20:48:31 +01:00
Ullrich Schäfer
0ff9052ae8
Add version (git SHA) to health endpoints
Returns SENTRY_RELEASE (set at build time) as the version field,
making it easy to verify which commit is deployed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:48:27 +01:00
Ullrich Schäfer
73b8ce26e1
Merge pull request #112 from trails-cool/doc-route-registration
Document route registration requirement
2026-03-27 20:46:54 +01:00
Ullrich Schäfer
98df1501c7
Document explicit route registration requirement in CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:46:16 +01:00
Ullrich Schäfer
bfef1cd3f2
Merge pull request #111 from trails-cool/fix-journal-routes
Register health and metrics routes in Journal
2026-03-27 20:42:18 +01:00
Ullrich Schäfer
3c5fb517dc
Add health and metrics routes to Journal route config
The route files existed but weren't registered in the explicit routes.ts,
so React Router never compiled them into the build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:42:10 +01:00
Ullrich Schäfer
1246799f12
Merge pull request #110 from trails-cool/grafana-smtp
Add SMTP to Grafana for alert emails
2026-03-27 20:27:50 +01:00
Ullrich Schäfer
296f9e57dd
Add SMTP configuration to Grafana for alert emails
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:27:45 +01:00
Ullrich Schäfer
4801c40e04
Merge pull request #109 from trails-cool/fix-grafana-alerts-v2
Fix Grafana alert rule expressions
2026-03-27 20:23:51 +01:00
Ullrich Schäfer
3914da7a57
Fix Grafana alert rules: proper threshold expressions
The threshold expressions were missing the 'expression' field referencing
the query refId, causing 'no variable specified for refId C' errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:23:46 +01:00
Ullrich Schäfer
8ae452999a
Merge pull request #108 from trails-cool/business-dashboard
Add Business Metrics Grafana dashboard
2026-03-27 20:17:08 +01:00
Ullrich Schäfer
e866a1ad40
Add Business Metrics Grafana dashboard
Panels:
- Stat cards: total users, routes, activities, active planner sessions
- Time series: cumulative user growth, route growth
- Bar charts: signups/day, routes/day, planner sessions/day
- Table: top 20 routes by distance

All queries use the grafana_reader PostgreSQL datasource.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:16:54 +01:00
Ullrich Schäfer
2a5615dfdf
Merge pull request #107 from trails-cool/grafana-postgres-datasource
Add PostgreSQL datasource to Grafana with read-only user
2026-03-27 20:08:28 +01:00
Ullrich Schäfer
424eecd274
Add PostgreSQL datasource to Grafana with read-only user
- Create grafana_reader role with SELECT-only access on all schemas
- Init script runs on postgres first boot (docker-entrypoint-initdb.d)
- Grafana PostgreSQL datasource provisioned with read-only credentials
- Enables SQL queries in dashboards (user count, routes, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:07:27 +01:00
Ullrich Schäfer
5e564e7562
Merge pull request #106 from trails-cool/simplify-restart-all
Simplify restart-all to workflow_dispatch only
2026-03-27 19:18:42 +01:00
Ullrich Schäfer
9a7ecd8911
Simplify restart-all: workflow_dispatch only
Remove commit message [restart-all] detection — doesn't work with
GitHub merge commits. Keep only the explicit workflow_dispatch input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:15:22 +01:00
Ullrich Schäfer
daebb63d1e
Merge pull request #105 from trails-cool/service-health-dashboard
Add service health monitoring: DB, host, container exporters + dashboard
2026-03-27 18:53:26 +01:00
Ullrich Schäfer
424e692ee0
Add service health monitoring: postgres, node, cAdvisor exporters + dashboard
Exporters:
- postgres_exporter: DB connections, transactions, cache hit ratio, query stats
- node_exporter: host CPU, memory, disk, network
- cAdvisor: per-container CPU and memory usage

PostgreSQL:
- Enable pg_stat_statements for query-level performance tracking
- Track index scans vs sequential scans, cache hit ratio

Dashboard (service-health.json):
- DB: connections, size, transactions/s, slow queries, cache hit ratio, index usage
- Host: disk gauge, CPU, memory, network I/O, disk I/O
- BRouter: request latency p50/p95/p99, container CPU + memory
- All containers: CPU and memory comparison

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:50:42 +01:00
Ullrich Schäfer
f87aca6188
Merge pull request #104 from trails-cool/fix-docker-ca-certs
Install ca-certificates in Docker images for Sentry uploads
2026-03-27 18:35:51 +01:00
Ullrich Schäfer
48fa94e51b
Install ca-certificates in Docker base image for Sentry uploads
node:25-slim doesn't include CA certificates, causing sentry-cli
SSL errors: "unable to get local issuer certificate". Also removes
debug logging from previous troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:35:46 +01:00
Ullrich Schäfer
f8957902d9
Merge pull request #103 from trails-cool/fix-sentry-token-newline
Fix Sentry auth token trailing newline
2026-03-27 18:23:29 +01:00
Ullrich Schäfer
318f0c93c1
Strip trailing newline from Sentry auth token
The token file may have a trailing newline that causes sentry-cli
API requests to fail with "API request failed".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:23:20 +01:00
Ullrich Schäfer
d121dba2c1
Merge pull request #102 from trails-cool/fix-sentry-build
Fix Sentry build: telemetry, release version, secret passing
2026-03-27 18:18:16 +01:00
Ullrich Schäfer
5d7b8c82e8
Fix Sentry build: disable telemetry, fix release version passing
- Disable Sentry Vite plugin telemetry (telemetry: false)
- Pass SENTRY_RELEASE as build-arg (not secret) since it's just the git sha
- Keep only SENTRY_AUTH_TOKEN as Docker secret

The release version was showing as *** because Docker secrets get masked
by GitHub Actions. Git sha is not sensitive — safe as a build arg.

The sourcemap warning from react-router is a known upstream issue (ships
without sourcemaps) and is harmless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:18:00 +01:00
Ullrich Schäfer
cdfd23abc2
Merge pull request #101 from trails-cool/fix-pnpm-build-scripts
Fix pnpm build scripts: use onlyBuiltDependencies
2026-03-27 18:09:51 +01:00
Ullrich Schäfer
6b147d04ec
Use pnpm.onlyBuiltDependencies for @sentry/cli and esbuild
pnpm approve-builds in Dockerfile ran after install, so the approval
wasn't picked up. Use onlyBuiltDependencies in package.json instead —
this is read during install and allows build scripts to run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:07:25 +01:00
Ullrich Schäfer
b2bf7cba28
Merge pull request #100 from trails-cool/fix-docker-sentry
Fix Sentry source map upload in Docker builds
2026-03-27 18:02:34 +01:00
Ullrich Schäfer
5e200c8b62
Fix Sentry source map upload in Docker builds
- Approve @sentry/cli and esbuild build scripts so postinstall runs
- Use --mount=type=secret instead of ARG for SENTRY_AUTH_TOKEN
  (fixes Docker warning about secrets in build args)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:01:54 +01:00
Ullrich Schäfer
87d01b4b0d
Merge pull request #99 from trails-cool/fix-cd-apps-source
Fix cd-apps: don't source env file with angle brackets
2026-03-27 17:53:22 +01:00
Ullrich Schäfer
06916868fe
Fix cd-apps: extract SENTRY_AUTH_TOKEN instead of sourcing env file
The decrypted secrets.app.env contains values with angle brackets
(SMTP_FROM) that bash interprets as redirects. The build job only
needs SENTRY_AUTH_TOKEN, so extract it with grep instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:52:41 +01:00
Ullrich Schäfer
fe87545744
Merge pull request #98 from trails-cool/fix-scp-paths
Fix SCP paths for decrypted secrets in CD workflows
2026-03-27 17:46:40 +01:00
Ullrich Schäfer
e880e3e9d9
Fix SCP path for decrypted secrets in CD workflows
Write decrypted env files to infrastructure/ instead of /tmp/ so
strip_components: 1 correctly places them on the server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:43:30 +01:00
Ullrich Schäfer
6e39d23dd4
Merge pull request #97 from trails-cool/fix-cd-environments
Add GitHub environments to CD workflows
2026-03-27 17:36:51 +01:00
Ullrich Schäfer
9673496211
Add GitHub environments to CD workflows
- cd-apps: uses `production` environment for AGE_SECRET_KEY
- cd-infra: uses `infra` environment for AGE_SECRET_KEY

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:34:28 +01:00
Ullrich Schäfer
c3e80c2dad
Merge pull request #96 from trails-cool/sops-age-split-cd
SOPS+age secrets, split CD, GitHub OAuth for Grafana
2026-03-27 17:30:26 +01:00
Ullrich Schäfer
57094323d2
SOPS+age secrets, split CD workflows, GitHub OAuth for Grafana
Secrets:
- Add .sops.yaml with age encryption config
- Add encrypted secrets.app.env (app secrets) and secrets.infra.env (Grafana OAuth)
- CD decrypts at deploy time with AGE_SECRET_KEY — all other secrets
  move out of GitHub Actions into version-controlled encrypted files

Split CD:
- cd-apps.yml: triggered by apps/packages changes, builds Docker images, deploys apps
- cd-infra.yml: triggered by infrastructure/ changes, copies configs, restarts services
- Remove monolithic cd.yml

Grafana auth:
- GitHub OAuth (trails-cool org), disable login form
- Remove Caddy basic_auth block and all GRAFANA_* env vars/secrets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:28:04 +01:00
Ullrich Schäfer
206f6ac941
Merge pull request #95 from trails-cool/fix-grafana-cd-password
Sync Grafana admin password on deploy
2026-03-27 15:13:37 +01:00
1241 changed files with 95286 additions and 6450 deletions

View file

@ -0,0 +1,275 @@
---
name: ia-review
description: Take a snapshot of the apps' information architecture (sitemap, navigation, audience-gating per route) and write or refresh `docs/information-architecture.md`. Use when the user wants to review the IA, plan a navigation/surface redesign, or check for drift since the last review.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the apps' route table + navigation surfaces, build a sitemap, surface
tensions and open questions, and capture the result in
`docs/information-architecture.md` (fresh write or refresh against the
prior snapshot).
The output is a *review document for the user* — not a unilateral plan.
Decisions are made by the user during the conversation that follows; the
doc captures the snapshot and the open questions that need answering.
---
## When to use
- The user explicitly asks for an IA review ("review the IA", "what's
the information architecture look like").
- Before a navigation/surface redesign, so the redesign is informed by
a current snapshot rather than a vibe.
- After a chunk of new features — pages, modals, settings sections —
to check whether the IA is drifting (busy navbar, duplicated surfaces,
orphaned routes).
- When the user says "we should do another IA review" after the prior
doc has aged.
---
## Steps
1. **Detect mode (fresh vs refresh)**
Check whether `docs/information-architecture.md` already exists.
- **No file:** fresh review. Build the snapshot from scratch.
- **File exists:** refresh review. Read it first; preserve the
decisions/backlog the user has accumulated and only update the
snapshot sections (sitemap, navigation, observations, open
questions). Decisions previously crossed out stay crossed out.
In refresh mode, also read the snapshot date at the top — anything
shipped *since* that date is what your refresh should focus on.
2. **Identify the apps in scope**
trails.cool ships two front-ends; the IA question lives mostly in
the Journal:
- `apps/journal/` — user accounts, social, content. Main IA
surface.
- `apps/planner/` — anonymous, ephemeral. ~5 routes; include for
completeness but don't dwell.
If the project structure has changed and there's a new app, include
it.
3. **Read the route tables**
For each app:
```
apps/<app>/app/routes.ts
```
This is the authoritative URL → route-file mapping. Both apps use
explicit registration (per CLAUDE.md), so `routes.ts` is complete.
4. **Read the navigation surfaces**
- `apps/journal/app/root.tsx` (and `apps/planner/app/root.tsx` if
it has navigation) — the top navbar lives here. Read both the
loader (to see what data the navbar consumes — counts, badges,
user fields) and the `NavBar` component (to see what entries
render).
- `apps/journal/app/components/Footer.tsx` — the footer.
- Any auth-gate / Terms-gate logic in the root loader.
5. **Sample key route loaders to understand audience**
For each top-level route, scan its loader to determine:
- Does it require a session? (loaders typically `redirect("/auth/login")`
for anonymous visitors when so.)
- Does it serve different content per session? (e.g., `home.tsx`
branches on `user`.)
- Does it have an access rule beyond auth? (locked-account 404s,
visibility checks, etc.)
You don't need to read every route — pick the top-level ones and
any that look like they might gate differently than the URL hints.
6. **Build the sitemap**
Group routes by audience: **Public surface** (anonymous-reachable)
and **Authenticated surface** (signed-in only). Within each group,
order by topic (auth, profile, content, settings, legal, etc.).
Use plain code blocks with one URL per line and a one-line gloss
per entry. Keep the format scannable; don't repeat what the URL
already says.
7. **Map navigation surfaces**
Two short tables/snippets:
- **Navbar (signed-in)** — entries left-to-right.
- **Navbar (signed-out)** — entries left-to-right.
- **Footer** — links + any meta text.
Note any entry whose visibility is conditional (badge counts, etc.).
8. **Identify "feed concepts" and other duplications**
trails.cool has historically had multiple feed-like surfaces. Any
IA review should ask: how many lists of activities are there? Is
the same data reachable from multiple URLs? Is there a URL that
shows different products to different audiences?
Capture these in a small table or section if they exist.
9. **Map cross-app linking**
Journal ↔ Planner cross-links (JWT callback URLs, "Try the
Planner" buttons, etc.). One short list.
10. **List observations**
Walk the snapshot and call out tensions worth discussing. Useful
prompts:
- **Busy clusters** — three or more controls for the same concept
side-by-side in the navbar.
- **Redundant paths** — same destination reachable from multiple
surfaces with no clear reason.
- **Dead-end routes** — pages reachable only by typing the URL,
no in-app link.
- **Missing surfaces** — common user need with no in-app path
(e.g., "find people to follow" with no `/explore`).
- **Audience mismatch** — same URL serving meaningfully different
products to anon vs auth.
- **Visual inconsistency** — adjacent navbar entries with
different treatment (icon vs text, different baselines).
- **Mobile hazards** — clusters that will wrap badly under
small viewport widths.
Each observation should be one short paragraph. Each is a
question for the user to answer, not a decision you've made.
11. **List open IA questions**
Distinct from observations: these are larger directional choices
where the answer determines what other observations even matter.
Examples: "Should `/` and `/feed` merge for signed-in users?",
"Where does an `/explore` page live, if at all?", "Mobile
pattern — hamburger? Bottom tab bar?"
Keep these as bullets the user can answer in one line each.
12. **Write the doc**
Output to `docs/information-architecture.md`. Use this top
structure:
```markdown
# Information Architecture Review
*Snapshot date: YYYY-MM-DD.* If the navbar, route table, or feed
model has shifted since then, treat this doc as stale and refresh
against `apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`.
A snapshot of where every page lives, who sees it, and how visitors
navigate between them. Intended for review — flag anything that
doesn't make sense or should change.
## Apps
[...]
## Journal sitemap
### Public surface (logged-out)
[...]
### Authenticated surface (logged-in)
[...]
### Navigation surfaces
[...]
## Logged-in vs logged-out home
[...if `/` does double duty...]
## [Any "N feed concepts" / duplication sections]
[...]
## Cross-app linking
[...]
## Planner sitemap
[...short...]
## Observations worth discussing
[...]
## Open IA questions
[...]
```
Use today's date for the snapshot. Reference the source-of-truth
files at the top so the next review knows what to compare against.
13. **In refresh mode, preserve the user's accumulated decisions**
The prior doc may already contain:
- Resolved observations (struck through with a *Resolved: ...*
note).
- An "Implementation backlog" section with streams.
- An "Open exploration" section.
These are the *user's work*, not the snapshot. Carry them forward
untouched unless one is plainly obsolete (e.g., the feature it
references no longer exists). When in doubt, leave it and let the
user prune.
If a previously-flagged observation is no longer present in the
current code (e.g., it was implemented), update its status note
rather than removing it — preserves history.
14. **Surface a ranked next-action list**
After writing the doc, summarize in 46 lines what changed since
the prior review (or what the most actionable observations are if
fresh). End with a question: which open IA question does the user
want to tackle first?
Don't start implementation work — this skill is for the
*snapshot*. The decisions and the implementation backlog grow
through the conversation that follows.
---
## What this skill is NOT
- **Not an implementation skill.** Don't write code, don't open PRs.
The doc is the deliverable; decisions and code follow in normal
conversation.
- **Not a unilateral redesign.** Observations are questions for the
user. Don't bake "decisions" into the snapshot — those go in the
backlog only when the user has actually answered the question.
- **Not a spec change.** OpenSpec specs describe what's shipped; this
doc describes the IA *as it stands* with tensions flagged. Any
resulting spec updates happen during implementation, not during the
review.
---
## Guardrails
- Always include the snapshot date at the top of the output doc — IA
drifts; future-you needs to know whether to trust the snapshot or
refresh it.
- Reference the source-of-truth files (`routes.ts`, `root.tsx`) so the
next review's diff is mechanical.
- Keep observations as questions, not decrees. The user makes the
call.
- In refresh mode, preserve the user's accumulated decisions verbatim.
Only the snapshot sections are yours to rewrite.
- Don't invent routes or features. If you can't find evidence for it
in the code, don't put it in the snapshot.
- Keep it scannable. The doc is for review; verbose explanations bury
the signal.

View file

@ -1,16 +1,19 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -30,6 +33,7 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -39,7 +43,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -51,7 +55,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,16 +1,19 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -30,6 +33,7 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -52,7 +56,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -67,19 +71,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
- If no: Move `changeRoot` to the archive directory
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
@ -98,7 +102,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.

View file

@ -1,12 +1,13 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
@ -15,6 +16,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
---
## The Stance
@ -56,10 +59,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐
│ │ State │────────▶│ State │
│ │ A │ │ B │
│ └────────┘ └────────┘
┌────────┐ ┌────────┐ │
│ State │────────▶│ State │ │
│ A │ │ B │ │
└────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -102,11 +105,10 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -114,14 +116,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
@ -227,7 +229,7 @@ User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:

View file

@ -1,12 +1,13 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
allowed-tools: Bash(openspec:*)
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.2.0"
generatedBy: "1.6.0"
---
Propose a new change - create the change and generate all artifacts in one step.
@ -20,6 +21,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
@ -37,7 +40,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -46,6 +49,7 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -63,10 +67,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

View file

@ -0,0 +1,216 @@
---
name: spec-drift-review
description: Walk every spec in `openspec/specs/`, compare it to the shipped code, and produce a categorized drift report (high/medium/low severity per spec, plus code-without-spec findings and structural suggestions). Use when the user wants to check spec drift, after a chunk of features has shipped, or when planning a spec catch-up PR.
license: MIT
metadata:
author: trails.cool
version: "1.0"
---
Walk the specs directory + the shipped code, compare them claim by
claim, and produce a structured drift report. The report is the
deliverable; fixes happen in a follow-up PR after the user has reviewed
the findings.
The goal is to keep `openspec/specs/` honest — specs are useless if they
don't describe the actual product, and worse than useless if they
contradict it.
---
## When to use
- The user explicitly asks to check spec drift ("are the specs in sync",
"review specs against code").
- After a multi-feature chunk has shipped without per-feature spec
promotion — drift accumulates fastest in catch-up phases.
- Before reorganizing the specs directory (split / merge / rename).
- When a spec contradicts the codebase and you're not sure which is
right.
---
## Steps
1. **Get the lay of the land**
Run these in parallel:
```bash
ls openspec/specs/
ls openspec/changes/ # in-flight work — NOT drift
cat openspec/CAPABILITIES.md # if it exists, it's the index
openspec list --json # any active changes that explain drift
```
**Important:** any spec referenced in an active openspec change is
*expected* to drift from current code — that drift is the work in
progress. Note these and exclude them from the report.
2. **Identify the code-side anchors per spec**
For each spec at `openspec/specs/<capability>/spec.md`, find the
primary code locations that implement it. Most capability specs map
to one or more of:
- **Routes:** `apps/journal/app/routes/*.tsx` / `*.ts`
authoritative for URL behavior, redirects, access gating.
- **Server lib:** `apps/journal/app/lib/<topic>.server.ts` — most
business logic.
- **Schema:** `packages/db/src/schema/journal.ts` — table shapes,
visibility values, defaults, indexes.
- **i18n:** `packages/i18n/src/locales/{en,de}.ts` — user-facing
strings, often telling.
- **Tests:** integration tests are a great oracle for the *intended*
behavior; mismatch with the spec usually means the spec is stale.
Don't read every file. Pick the 13 anchors per spec that the
requirements most plausibly map to.
3. **Compare claim by claim**
For each requirement in a spec, ask:
- **Does the code do this?** If not, is it because the requirement
was retired or because it was never shipped?
- **Does the code do *more* than this?** New scenarios shipped
without a spec update.
- **Does the code do this *differently*?** Different URL,
different default, different status code, different rule.
- **Does this requirement reference a name that no longer exists?**
Renamed routes, deleted helpers, removed tables.
Track findings with severity:
| Severity | What it means |
|----------|---------------|
| **High** | Spec actively misleads — claims a behavior the code does not exhibit. A reader implementing against the spec would write the wrong code. |
| **Medium** | Spec is incomplete — code has scenarios the spec doesn't describe. Reader gets less information than they should but isn't actively misled. |
| **Low** | Wording drift — comments/cross-refs mention a renamed thing, but the requirement statements are still accurate. |
4. **Find code-without-spec**
Walk the route tree in `apps/journal/app/routes.ts` and the topic
files in `apps/journal/app/lib/`. For each top-level concept ask:
- Is this concept covered by a spec?
- If yes, does the spec mention this surface?
- If no, should it be?
Genuine "no spec" cases are usually: new feature shipped without
spec promotion, or piece of infrastructure deemed too internal for a
spec. Both are valid; flag the former, leave the latter alone.
5. **Structural review**
Spec organization itself can drift:
- **Specs that grew too big** — multiple unrelated requirements
under one spec. Candidates for a split (we previously split
`account-settings` into `profile-settings`, `account-management`,
`connected-services`).
- **Specs that overlap** — the same requirement appears in two
specs, or one spec keeps cross-referencing another. Candidate for
a merge or a clearer ownership boundary.
- **Specs that should exist but don't** — a capability is shipped
and substantial enough to merit its own spec but is currently
squeezed into another. (We added `sse-broker` and `notifications`
this way.)
- **`CAPABILITIES.md` drift** — if the index exists, check that
every spec dir has an entry and every entry points at a real
spec. The index is the easy thing to forget when adding a spec.
6. **Compose the report**
Output to the conversation as a markdown structure. Don't write a
doc unless the report is unusually large and the user asks for one —
most drift reports get acted on inside a single PR and don't need a
long-lived artifact.
Suggested structure:
```markdown
# Spec drift review — YYYY-MM-DD
**Active changes excluded from this review:**
- <name> (touches: <specs>)
## High-severity drift
### `<spec-name>`
- **Requirement: <name>** says <X>; code at `<file:line>` does <Y>.
[link / one-line action]
- …
## Medium-severity drift
### `<spec-name>`
- <description + code anchor>
- …
## Low-severity drift (wording / cross-refs)
- `<spec-name>`: <description>
- …
## Code-without-spec
- <feature> at `<file>` — should this be in <spec-name>, or a new
spec? Recommendation: <…>
## Structural suggestions
- Split: <spec-name><new-1>, <new-2>
- Merge: <spec-a> + <spec-b><new>
- New spec: <name> covering <area>
- `CAPABILITIES.md` updates: <list>
```
7. **Propose next actions, then stop**
End the review with three concrete options the user can pick from:
- **Ship a catch-up PR for everything** — works when drift is
mostly low/medium and the fix is mechanical.
- **Fix high-severity first, defer the rest** — works when high
items are urgent and the rest can wait for natural per-feature
spec updates.
- **Restructure first, then catch up** — works when structural
suggestions (split / merge / new spec) are large enough that
fixing claims inside the wrong spec shape would just have to be
redone.
Don't pick for them. Don't start fixing yet.
---
## What this skill is NOT
- **Not a fixer.** This skill produces a report. Apply happens after
the user has decided which findings to act on.
- **Not a CI check.** It's interactive — judgment calls (severity,
splits, merges) are part of the value, not an automation target.
- **Not for in-flight work.** Active openspec changes legitimately
cause spec/code mismatch; flag them in the "excluded" section and
move on.
- **Not a code review.** The question is "does the spec match the
code", not "is the code good." Code-quality observations belong in
PR review, not here.
---
## Guardrails
- Always start by reading active openspec changes — drift caused by
in-flight work is not drift.
- Don't write spec edits during the review. The user picks which
findings to act on; edits happen after.
- When code and spec disagree, the prevailing rule on this project is
**code is source of truth** unless the user says otherwise. Surface
the conflict; don't preemptively decide.
- Skip generated/scaffold files (e.g. `.react-router/types/**`) — they
are derived, not product.
- Don't pad the report with low-severity wording drift unless the user
asks for it. Concentrate signal.
- Keep observations specific (file + line + claim), not abstract. A
finding without an anchor isn't actionable.

View file

@ -1,12 +1,15 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -26,6 +29,7 @@ Implement tasks from an OpenSpec change.
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
@ -35,7 +39,7 @@ Implement tasks from an OpenSpec change.
```
This returns:
- Context file paths (varies by schema)
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
@ -47,7 +51,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files**
Read the files listed in `contextFiles` from the apply instructions output.
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output

View file

@ -1,12 +1,15 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
@ -26,6 +29,7 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
@ -48,7 +52,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -63,19 +67,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
- If no: Move `changeRoot` to the archive directory
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
@ -94,7 +98,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
@ -107,7 +111,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
@ -120,7 +124,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
@ -137,7 +141,7 @@ Review the archive if this was not intentional.
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.

View file

@ -1,6 +1,7 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
@ -11,6 +12,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
@ -59,10 +62,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐
│ │ State │────────▶│ State │
│ │ A │ │ B │
│ └────────┘ └────────┘
┌────────┐ ┌────────┐ │
│ State │────────▶│ State │ │
│ A │ │ B │ │
└────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@ -107,11 +110,10 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -119,14 +121,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"

View file

@ -1,6 +1,7 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
allowed-tools: Bash(openspec:*)
category: Workflow
tags: [workflow, artifacts, experimental]
---
@ -16,6 +17,8 @@ When ready to implement, run /opsx:apply
---
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
@ -33,7 +36,7 @@ When ready to implement, run /opsx:apply
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
@ -42,6 +45,7 @@ When ready to implement, run /opsx:apply
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
@ -59,10 +63,10 @@ When ready to implement, run /opsx:apply
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"

View file

@ -0,0 +1,9 @@
---
name: EAS build credits
description: Avoid unnecessary EAS cloud builds — they consume limited credits
type: feedback
---
Don't trigger EAS builds casually. They consume limited cloud build credits.
**Why:** Free tier has a monthly cap; each `eas build` / `eas build:dev` costs credits.
**How to apply:** Only suggest or run EAS builds when adding/changing native dependencies. JS-only changes don't need a new build — the dev client hot-reloads via the bundler.

1
.claude/skills Symbolic link
View file

@ -0,0 +1 @@
../.agents/skills

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
# Keep Docker build contexts small and hermetic. CI builds from a clean
# checkout so this mostly matters for local builds (e2e/federation
# harness), where node_modules would otherwise bloat the context to
# gigabytes — and worse, `COPY . .` would overlay host-installed
# node_modules over the image's own pnpm install.
**/node_modules
**/.turbo
**/build
**/.react-router
.git
e2e/results
playwright-report
test-results
**/*.log

10
.env.development.example Normal file
View file

@ -0,0 +1,10 @@
# Root-level local dev defaults.
# Copy to `.env.development` (gitignored) to override.
# All values here work out of the box — no changes needed for standard local dev.
DATABASE_URL=postgres://trails:trails@localhost:5432/trails
BROUTER_URL=http://localhost:17777
# Change these for any environment that is not purely local.
JWT_SECRET=dev-secret-not-for-production
SESSION_SECRET=dev-secret-not-for-production

3
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,3 @@
# trails.cool — Copilot Instructions
For full project context — including architecture, principles, tech stack, dev commands, testing strategy, code conventions, git workflow, and deployment — see [`CLAUDE.md`](../CLAUDE.md) at the repository root.

View file

@ -12,6 +12,30 @@ updates:
development:
dependency-type: "development"
open-pull-requests-limit: 10
ignore:
# Keep @types/* aligned with the runtime major. Mismatched type
# packages (e.g. @types/jest 30 while jest is still 29) break
# typecheck without buying us anything.
- dependency-name: "@types/jest"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/node"
update-types: ["version-update:semver-major"]
# These packages are version-pinned by the Expo SDK (see
# expo/bundledNativeModules.json) — upgrade them via an Expo SDK
# bump / `npx expo install --fix`, never on their own. Dependabot
# bumping them past the SDK's expected version broke the native
# build (expo-modules-core macro mismatch, June 2026) because CI
# never compiles native code. `react` stays unignored: the web
# apps own its version via the workspace catalog, and apps/mobile
# excludes it from expo version checks.
- dependency-name: "react-native"
- dependency-name: "react-native-gesture-handler"
- dependency-name: "react-native-reanimated"
- dependency-name: "react-native-safe-area-context"
- dependency-name: "react-native-screens"
- dependency-name: "react-native-worklets"
- dependency-name: "@sentry/react-native"
- dependency-name: "jest-expo"
- package-ecosystem: "github-actions"
directory: "/"

205
.github/workflows/cd-apps.yml vendored Normal file
View file

@ -0,0 +1,205 @@
name: CD Apps
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
concurrency:
group: deploy-apps
cancel-in-progress: true
# Public Sentry DSNs for the trails.cool flagship instance. Public by
# design — Sentry DSNs are transmitted unencrypted from the client JS
# bundle, embedding them in this workflow is no worse than embedding
# them in the runtime env. Self-hosted forks should either replace
# these with their own DSNs or remove the lines to ship without Sentry.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
build-images:
name: Build & Push Docker Images
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt Sentry auth token
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
# VITE_SENTRY_DSN bakes the client-side DSN into the journal's
# built bundle. Only journal has a client Sentry init; planner
# ignores the build-arg if present.
build-args: |
SENTRY_RELEASE=${{ github.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
deploy:
name: Deploy Apps
needs: [build-images]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env
echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
# Federation on (social-federation rollout 12.5, flipped
# 2026-06-07 after the staging + Mastodon soak). The
# FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env
# decrypted above. Rollback: delete these two lines, merge,
# rerun cd-apps — instant off, federation surfaces 404.
echo "FEDERATION_ENABLED=true" >> infrastructure/app.env
echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env
# Sentry DSNs (public — see workflow top-level env for context).
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/caddy"
target: /opt/trails-cool
strip_components: 1
- name: Copy secrets to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/app.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort the deploy on the first failure. Without this, a failed
# schema push deploys new code against an old schema (the
# 2026-06-06 schema-drift incident).
set -euo pipefail
cd /opt/trails-cool
# Login to ghcr.io
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN app.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Pull and deploy app containers
docker compose --env-file app.env pull journal planner
# Hand-written data migrations (idempotent) run BEFORE drizzle-kit
# push so unique-key reshapes can collapse duplicate rows first.
docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt it can't show (no TTY in CI) — that exact lie hid a
# month of staging schema drift. Treat any Error in its output
# as a failed deploy.
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
# --remove-orphans cleans up containers whose service was deleted
# from the compose file, matching cd-infra's behaviour.
docker compose --env-file app.env up -d --remove-orphans journal planner
# Gate on container health: a deploy that leaves journal or
# planner unhealthy must fail loudly, not report green.
for svc in journal planner; do
for i in $(seq 1 24); do
status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$svc did not become healthy (last status: $status)"
docker compose --env-file app.env logs "$svc" --tail 50 || true
exit 1
fi
done
# Reload Caddy with the Caddyfile we just scp'd. cd-apps
# ships infrastructure/Caddyfile alongside docker-compose.yml
# (see scp step above), but containers don't auto-pick-up
# config changes. cd-infra reloads Caddy as part of its
# deploy; cd-apps did NOT, which meant any Caddyfile change
# touching only `apps/`/`packages/` paths sat on disk
# unapplied until the next cd-infra run. The reload is
# idempotent (Caddy validates first, swaps live, no
# downtime) so doing it on every cd-apps deploy is safe
# even when Caddyfile is unchanged. `|| true` keeps the
# deploy from failing if Caddy itself is unhealthy.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Clean up (best-effort). This runs AFTER the containers are
# swapped + Caddy reloaded, so the deploy already succeeded —
# a transient "a prune operation is already running" collision
# with a concurrent deploy/disk-maintenance prune must NOT fail
# an otherwise-green deploy. disk-maintenance.yml is the real
# image-prune safety net.
docker image prune -af || true
docker compose ps
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
# in secrets.infra.env (decrypted by cd-infra.yml into the
# merged /opt/trails-cool/.env on the server). cd-apps's
# own app.env intentionally does NOT carry it — apps don't
# need it at runtime. So we read from the merged `.env`
# that cd-infra populated. If cd-infra has never run on
# this host, .env may not exist; the `2>/dev/null` and
# the `if -n` guard make the annotation a silent no-op
# rather than a deploy failure in that case.
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Deploy ${{ github.sha }}","tags":["deploy","apps"]}' \
http://localhost:3000/api/annotations || true
fi

135
.github/workflows/cd-brouter.yml vendored Normal file
View file

@ -0,0 +1,135 @@
name: CD BRouter
on:
push:
branches: [main]
paths:
- "docker/brouter/**"
- "infrastructure/brouter-host/**"
workflow_dispatch: {}
concurrency:
group: deploy-brouter
cancel-in-progress: true
jobs:
build:
name: Build & Push BRouter Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: docker/brouter
push: true
tags: |
ghcr.io/trails-cool/brouter:latest
ghcr.io/trails-cool/brouter:${{ github.sha }}
deploy:
name: Deploy BRouter to dedicated host
needs: [build]
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v7
- name: Decrypt shared secret
id: decrypt
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Extract ONLY BROUTER_AUTH_TOKEN from secrets.app.env — the
# rest of that file is app-only and has no business reaching
# the BRouter host.
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \
| grep '^BROUTER_AUTH_TOKEN=' > infrastructure/brouter-host/.env
chmod 0600 infrastructure/brouter-host/.env
# GHCR pull credential — not shipped to the host's filesystem;
# passed to the SSH step as an env var so it only lives in
# memory during `docker login`.
GHCR_TOKEN=$(SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \
| grep '^DEPLOY_GHCR_TOKEN=' | cut -d= -f2-)
echo "::add-mask::$GHCR_TOKEN"
echo "GHCR_TOKEN=$GHCR_TOKEN" >> $GITHUB_ENV
- name: Deploy to dedicated host (single SSH connection)
# Consolidated into one SSH session on purpose: UFW on the
# dedicated host rate-limits port 2232 via `LIMIT` (~6 conns /
# 30s). `appleboy/scp-action` burns that budget across
# scp+mkdir+untar+cleanup conns and the final cleanup times
# out. One SSH here sidesteps the limit entirely. The tarball
# is piped in as a base64 env var so we don't fight over
# stdin (reserved for the heredoc script).
env:
SSH_KEY: ${{ secrets.BROUTER_DEPLOY_SSH_KEY }}
HOST: ${{ secrets.BROUTER_DEPLOY_HOST }}
PORT: ${{ secrets.BROUTER_DEPLOY_SSH_PORT }}
run: |
set -euo pipefail
# SSH key to a locked-down file (env-var keys break openssh)
install -m 0600 /dev/null ~/id_deploy
printf '%s\n' "$SSH_KEY" > ~/id_deploy
# Bundle the compose project
TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \
docker-compose.yml Caddyfile promtail-config.yml \
download-segments.sh .env \
poi-extract \
| base64 -w0)
# One SSH session: untar, (re)start containers, report status
ssh -i ~/id_deploy -p "$PORT" \
-o StrictHostKeyChecking=accept-new \
-o UserKnownHostsFile=/tmp/known_hosts \
-o ConnectTimeout=30 \
trails@"$HOST" \
"TARBALL_B64='$TARBALL_B64' GHCR_TOKEN='$GHCR_TOKEN' bash -s" <<'REMOTE'
set -euo pipefail
mkdir -p ~/brouter && cd ~/brouter
echo "$TARBALL_B64" | base64 -d | tar -xzf -
chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py
# Segment seeding is a one-shot operator task (~10 GB, a few
# minutes). CD must not rerun it on every deploy.
if [ ! -d segments ] || [ -z "$(ls -A segments 2>/dev/null)" ]; then
echo "WARNING: segments/ is empty. BRouter will start but return 404 until segments are seeded."
mkdir -p segments
fi
# GHCR brouter image is private; log in so pull works.
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
REMOTE
- name: Annotate deploy in flagship Grafana
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/trails-cool
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Deploy brouter ${{ github.sha }}","tags":["deploy","brouter"]}' \
http://localhost:3000/api/annotations || true
fi

166
.github/workflows/cd-infra.yml vendored Normal file
View file

@ -0,0 +1,166 @@
name: CD Infra
on:
push:
branches: [main]
paths:
- "infrastructure/**"
workflow_dispatch:
inputs:
restart_all:
description: "Restart all containers (not just infra)"
type: boolean
default: false
concurrency:
group: deploy-infra
cancel-in-progress: true
jobs:
deploy:
name: Deploy Infrastructure
runs-on: ubuntu-latest
environment: infra
steps:
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Merge app + infra secrets into one .env for docker-compose
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env
echo "DOMAIN=trails.cool" >> infrastructure/.env
# Flagship marker — the Journal home renders the project
# marketing block when this is "true" and the self-host
# footer link when it's unset.
echo "IS_FLAGSHIP=true" >> infrastructure/.env
- name: Copy configs to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts"
target: /opt/trails-cool
strip_components: 1
- name: Copy secrets to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
# Abort on first failure. The 2026-06-06/07 outage: a network
# recreation stopped postgres, a later step failed, and the
# deploy left production down for ~9h while the job's partial
# progress looked plausible. Fail fast, verify health at the end.
set -euo pipefail
cd /opt/trails-cool
# .env was placed by the SCP step (decrypted app + infra secrets)
# Login to ghcr.io (for pulling monitoring images)
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true
# Setup Grafana read-only DB user (idempotent)
docker compose exec -T postgres psql -U trails -d trails -f /docker-entrypoint-initdb.d/init-grafana-user.sql 2>/dev/null || true
GRAFANA_DB_PW=$(grep GRAFANA_DB_PASSWORD .env | cut -d= -f2-)
if [ -n "$GRAFANA_DB_PW" ]; then
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
fi
# Capture whether prometheus was recreated. A fresh container already
# reads the new config on startup; sending it SIGHUP immediately can
# kill Prometheus 3.10 during early boot (exit 2 observed on
# 2026-06-09).
PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
docker compose --env-file .env up -d --remove-orphans
else
# Restart infra services (config reloads handled below).
# --remove-orphans cleans up containers whose service was deleted
# from the compose file (e.g., the flagship `brouter` removal in
# PR #297 left an orphan that had to be removed by hand).
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
fi
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Apply config-only changes that `up -d` skips — it recreates a
# container only when its compose *definition* changes, not when a
# mounted config file's content changes. The configs are mounted as
# directories (not single files), so a reload/restart re-reads the
# freshly scp'd file; a single-file mount would have pinned the old
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
# the same container stayed up; a recreated container already loaded
# the new config on startup. Loki and Promtail reload their main
# config only on restart; Caddy reloads gracefully (validates, swaps
# live, no downtime).
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
docker compose --env-file .env kill -s SIGHUP prometheus
fi
docker compose --env-file .env restart loki promtail
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
docker compose ps
# Gate on the stack actually being up: postgres healthy, journal
# back to healthy after the DB bounce, and Prometheus answering
# /-/ready. A deploy that leaves any of them down must fail loudly
# (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus
# startup/HUP regression).
for ctr in trails-cool-postgres-1 trails-cool-journal-1; do
for i in $(seq 1 36); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
[ "$status" = "healthy" ] && break
sleep 5
done
if [ "$status" != "healthy" ]; then
echo "$ctr did not become healthy (last status: $status)"
exit 1
fi
done
PROMETHEUS_READY=
for i in $(seq 1 36); do
prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing)
if [ "$prom_status" = "running" ]; then
prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1)
if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then
PROMETHEUS_READY=1
break
fi
fi
sleep 5
done
if [ -z "$PROMETHEUS_READY" ]; then
echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)"
exit 1
fi
# Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Deploy infra ${{ github.sha }}","tags":["deploy","infra"]}' \
http://localhost:3000/api/annotations || true
fi

520
.github/workflows/cd-staging.yml vendored Normal file
View file

@ -0,0 +1,520 @@
name: CD Staging
# Builds, deploys, and tears down the persistent staging stack and per-PR
# preview environments. See `openspec/changes/staging-environments/` for the
# design (decisions on shared Postgres, port allocation, per-PR Caddyfile
# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view.
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
# Also redeploy when the staging plumbing itself changes, so a port
# bump or compose edit doesn't sit unapplied until the next apps/ push.
- "infrastructure/docker-compose.staging.yml"
- ".github/workflows/cd-staging.yml"
pull_request:
# `labeled`/`unlabeled` so toggling the `preview` label on an existing PR
# starts / tears down its preview (see the opt-in gate on the jobs below).
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
# Per-target concurrency: persistent staging deploys serialize against
# themselves, each PR's preview lifecycle serializes against itself.
concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true
# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the
# "public by design" rationale.
env:
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
jobs:
# ── Build ─────────────────────────────────────────────────────────────
# Tags:
# main push → :staging + :<sha>
# PR open/sync/reopen → :pr-<N> + :pr-<N>-<sha>
# Skipped entirely on PR close (teardown doesn't need new images).
build-images:
name: Build & Push Docker Images
# PR previews are opt-in to keep flagship disk in check (each preview is a
# journal container + database): build a PR's images only when it carries
# the `preview` label or a `<!-- preview -->` marker in its description.
# Main-push / dispatch always build.
if: >
github.event_name != 'pull_request' ||
(github.event.action != 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->')))
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
outputs:
tag_primary: ${{ steps.tags.outputs.primary }}
tag_sha: ${{ steps.tags.outputs.sha }}
steps:
- uses: actions/checkout@v7
- id: tags
name: Compute image tags
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
PRIMARY="pr-${{ github.event.number }}"
SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}"
else
PRIMARY="staging"
SHA="${{ github.sha }}"
fi
echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt Sentry auth token
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }}
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
# ── Deploy persistent staging (main push or manual dispatch) ─────────
deploy-staging:
name: Deploy Staging
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
needs: [build-images]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v7
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{
echo "DOMAIN=staging.trails.cool"
echo "STAGING_DATABASE=trails_staging"
# Loki binds 10.0.0.2:3100 on the vSwitch interface, so the
# staging stack can't share 3100 — see CLAUDE.md "Staging &
# Previews" port table.
echo "JOURNAL_HOST_PORT=3110"
echo "PLANNER_HOST_PORT=3111"
echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation soak on persistent staging only (social-federation
# rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above; previews never set this flag.
echo "FEDERATION_ENABLED=true"
# Verbose Fedify logs during the soak (signature verification
# detail). Dial down to info once federation is proven out.
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging.env
- name: Copy compose file + env to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
cd /opt/trails-cool
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Bootstrap the shared network so cd-staging works regardless of
# whether cd-infra has already run with the new docker-compose.yml.
# Once cd-infra runs, postgres is permanently joined via compose;
# until then, attach it imperatively.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Ensure trails_staging database exists with postgis. Production
# init scripts only run on first data-dir init, so a freshly
# created database has no extensions.
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='trails_staging'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails trails_staging
docker compose exec -T postgres psql -U trails -d trails_staging -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull and deploy staging containers (journal + planner via "persistent" profile)
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull
# drizzle-kit exits 0 even when it aborts on an interactive
# prompt (no TTY in CI) — set -e alone can't catch it. That lie
# hid a month of staging schema drift (2026-06-06 incident).
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans
# Reload Caddy so new staging routes (or Caddyfile changes shipped
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Reclaim superseded image layers. cd-apps prunes after its own
# deploys, but a day of staging/preview deploys while cd-apps is
# red can fill the disk on its own (2026-06-07: 100% full,
# postgres down). The 1h filter avoids racing layers another
# in-flight deploy just pulled.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
deploy-preview:
name: Deploy PR Preview
# Opt-in only (see build-images): the `preview` label or a `<!-- preview -->`
# marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there
# is no app image to preview.
if: >
github.event_name == 'pull_request' &&
github.event.action != 'closed' &&
!startsWith(github.head_ref, 'dependabot/github_actions/') &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))
needs: [build-images]
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v7
- id: ports
name: Compute preview ports + project name
run: |
PR=${{ github.event.number }}
# journal = 3200 + 2N, planner unused (PR previews are journal-only)
JOURNAL_PORT=$((3200 + 2 * PR))
PLANNER_PORT=$((3201 + 2 * PR))
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT"
echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT"
echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- name: Decrypt secrets + assemble env
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Use a per-PR filename so concurrent SCP transfers don't overwrite each other.
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=${{ steps.ports.outputs.host }}"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}"
echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}"
echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
# PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
# Federation on previews too (social-federation rollout 12.4):
# makes any PR preview a second live trails instance that
# persistent staging can follow across — the trails-to-trails
# soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the
# SOPS env decrypted above. Preview teardown orphans remote
# follower rows on the other side; remotes handle dead
# instances via ordinary delivery-failure expiry.
echo "FEDERATION_ENABLED=true"
echo "FEDERATION_LOG_LEVEL=debug"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Generate per-PR Caddyfile snippet
run: |
mkdir -p infrastructure/sites
cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile <<EOF
# Auto-generated by cd-staging.yml for PR ${{ steps.ports.outputs.pr }}. Do not edit.
${{ steps.ports.outputs.host }} {
import security_headers
import block_scanners
log {
output stdout
format json
}
reverse_proxy host.docker.internal:${{ steps.ports.outputs.journal_port }} {
lb_try_duration 30s
lb_try_interval 250ms
}
}
EOF
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
target: /opt/trails-cool
strip_components: 1
- name: Deploy preview via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB,JOURNAL_PORT
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Serialize all preview deploys with a server-side lock so concurrent
# CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state.
exec 9>/tmp/trails-preview-deploy.lock
flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; }
# Same network bootstrap as deploy-staging — see comment there.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
if [ -n "$PG_CONTAINER" ]; then
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
fi
# Concurrent preview limit (max 3): if we're at the cap and this
# PR isn't already running, evict the oldest preview project.
ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true)
if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then
COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true)
if [ "$COUNT" -ge 3 ]; then
# Pick the oldest by container CreatedAt of any service in the project.
OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \
| awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \
| sort -k2 \
| head -1 \
| awk '{print $1}')
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
echo "At cap; evicting oldest preview: $OLDEST"
OLD_PR=${OLDEST#trails-pr-}
OLD_ENV="staging-pr-${OLD_PR}.env"
# Fall back to base secrets if the per-PR env was already cleaned up.
EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" )
docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV"
fi
fi
fi
# Ensure per-PR database exists with postgis. (See deploy-staging
# for why we have to enable the extension explicitly.)
docker compose exec -T postgres psql -U trails -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$DB'" \
| grep -q 1 \
|| docker compose exec -T postgres createdb -U trails "$DB"
docker compose exec -T postgres psql -U trails -d "$DB" -c \
"CREATE EXTENSION IF NOT EXISTS postgis"
# Pull, migrate, deploy (journal-only — no --profile means planner skipped)
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal
# Same drizzle-kit exit-code-0-on-error guard as the persistent
# staging deploy above.
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
if grep -q "Error:" /tmp/drizzle-push.log; then
echo "drizzle-kit push reported an error — failing the preview deploy"
exit 1
fi
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
# Same disk-hygiene prune as the persistent staging deploy —
# preview pushes are the highest-volume image source.
docker image prune -af --filter "until=1h" || true
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather
# than spamming a new one each push. The marker line at the bottom of
# the body is what `body-includes` matches on.
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Upsert preview comment on PR
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.number }}
edit-mode: replace
body: |
🚀 **PR preview deployed**
- **Journal:** https://${{ steps.ports.outputs.host }}
- **Planner (shared staging):** https://planner.staging.trails.cool
- **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging)
- **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}`
Updates automatically on push. Tears down when this PR closes.
<!-- cd-staging:preview -->
# ── PR preview teardown ──────────────────────────────────────────────
teardown-preview:
name: Tear Down PR Preview
# Tear down on close, or when the `preview` opt-in is removed (label pulled
# and no `<!-- preview -->` marker left) so a de-flagged PR doesn't orphan
# its preview stack on the flagship.
if: >
github.event_name == 'pull_request' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' &&
!(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.body, '<!-- preview -->'))))
runs-on: ubuntu-latest
environment: production
permissions:
pull-requests: write
steps:
- id: ports
name: Compute project + database name
run: |
PR=${{ github.event.number }}
echo "pr=$PR" >> "$GITHUB_OUTPUT"
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
- name: Decrypt secrets (needed to satisfy compose env vars during down)
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
{
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
- name: Copy compose + env (teardown still needs the file)
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env"
target: /opt/trails-cool
strip_components: 1
- name: Tear down via SSH
uses: appleboy/ssh-action@v1
env:
PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }}
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB
script: |
set -euo pipefail
cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
# Stop and remove containers + volumes for this PR
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true
# Drop the per-PR database (idempotent)
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
# Remove the per-PR Caddy snippet, env file, and reload
rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE"
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
- name: Find existing preview comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.number }}
comment-author: "github-actions[bot]"
body-includes: "<!-- cd-staging:preview -->"
- name: Update preview comment on close
if: steps.find-comment.outputs.comment-id
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}).
Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared.
[Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
<!-- cd-staging:preview -->

View file

@ -1,131 +0,0 @@
name: CD
# Triggers:
# - push: merges via merge queue or manual gh pr merge
# - workflow_dispatch: manual trigger via gh workflow run cd.yml
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: deploy
cancel-in-progress: false
jobs:
build-images:
name: Build & Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
build-args: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ github.sha }}
build-brouter:
name: Build & Push BRouter Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: docker/brouter
push: true
tags: |
ghcr.io/trails-cool/brouter:latest
ghcr.io/trails-cool/brouter:${{ github.sha }}
deploy:
name: Deploy to Hetzner
needs: [build-images, build-brouter]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
target: /opt/trails-cool
strip_components: 1
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
cd /opt/trails-cool
# Login to ghcr.io to pull private images
echo "${{ secrets.DEPLOY_GHCR_TOKEN }}" | docker login ghcr.io -u stigi --password-stdin
# Download BRouter segments if missing
if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then
mkdir -p /opt/trails-cool/segments
echo "Downloading Germany BRouter segments..."
for tile in E5_N45 E5_N50 E10_N45 E10_N50; do
[ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \
wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5"
done
fi
# Pull latest images
export SENTRY_RELEASE="${{ github.sha }}"
export SMTP_URL="${{ secrets.SMTP_URL }}"
export SMTP_FROM="${{ secrets.SMTP_FROM }}"
export GRAFANA_USER="${{ secrets.GRAFANA_USER }}"
export GRAFANA_PASSWORD="${{ secrets.GRAFANA_PASSWORD }}"
export GRAFANA_PASSWORD_HASH='${{ secrets.GRAFANA_PASSWORD_HASH }}'
docker compose pull
# Push database schema (starts postgres, waits for healthy)
docker compose run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# Start all services
docker compose up -d --remove-orphans
# Clean up old images to prevent disk full
docker image prune -af
# Sync Grafana admin credentials (env vars only work on first boot)
sleep 10
docker compose exec -T grafana grafana cli admin reset-admin-password "$GRAFANA_PASSWORD" || true
# Verify services are running
docker compose ps

View file

@ -19,15 +19,16 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Gitleaks
if: github.actor != 'dependabot[bot]'
uses: trails-cool/gitleaks-action@4cbc857b9cfa2a3297fe2be1078e196d30d1b424
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
- uses: pnpm/action-setup@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -37,12 +38,32 @@ jobs:
run: pnpm audit --audit-level=high
continue-on-error: true
dockerfile-check:
name: Dockerfile Package Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: bash scripts/check-dockerfiles.sh
openspec:
name: OpenSpec Validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm openspec validate --all --strict --no-interactive
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -54,8 +75,8 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -67,8 +88,8 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -80,8 +101,8 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@ -89,6 +110,72 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
visual-tests:
name: Visual Tests
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright Chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Install Playwright deps only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium
- name: Run visual regression tests
id: visual-tests
run: pnpm --filter @trails-cool/planner test:visual
- name: Post diff comment on PR
if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort)
if [ -z "$diffs" ]; then exit 0; fi
artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
body="## Visual regression failures"$'\n\n'
body+="The following tests produced screenshot diffs:"$'\n\n'
for diff in $diffs; do
name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g')
body+="- \`$name\`"$'\n'
done
body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n'
body+="To update snapshots if the change is intentional:"$'\n'
body+="\`\`\`"$'\n'
body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n'
body+="\`\`\`"
gh pr comment ${{ github.event.pull_request.number }} --body "$body"
- name: Upload screenshots on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: visual-snapshots-diff
path: apps/planner/.vitest-attachments/
include-hidden-files: true
retention-days: 7
e2e:
name: E2E Tests
needs: build
@ -96,72 +183,20 @@ jobs:
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache PostGIS Docker image
id: postgis-cache
uses: actions/cache@v5
with:
path: /tmp/postgis-image.tar
key: postgis-16-3.4
- name: Load or pull PostGIS image
run: |
if [ -f /tmp/postgis-image.tar ]; then
docker load < /tmp/postgis-image.tar
else
docker pull postgis/postgis:16-3.4
docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar
fi
- name: Start PostgreSQL
run: |
docker run -d --name postgres \
-e POSTGRES_USER=trails \
-e POSTGRES_PASSWORD=trails \
-e POSTGRES_DB=trails \
-p 5432:5432 \
postgis/postgis:16-3.4
# Wait for pg_isready
for i in $(seq 1 30); do
docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break
sleep 1
done
# Wait for PostGIS extension to be ready
for i in $(seq 1 10); do
docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break
sleep 1
done
- name: Push database schema
run: pnpm db:push
- name: Build and cache BRouter
id: brouter-cache
uses: actions/cache@v5
with:
path: /tmp/brouter
key: brouter-1.7.8
- name: Download BRouter
if: steps.brouter-cache.outputs.cache-hit != 'true'
run: |
mkdir -p /tmp/brouter
wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip
cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip
- name: Cache BRouter segment
id: segment-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: /tmp/brouter-segments
key: brouter-segment-E10_N50
key: brouter-segment-E10_N50-v1.7.9
- name: Download Berlin segment
if: steps.segment-cache.outputs.cache-hit != 'true'
@ -169,26 +204,54 @@ jobs:
mkdir -p /tmp/brouter-segments
wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5
- name: Start BRouter
- name: Pre-seed BRouter segment volume
run: |
cd /tmp/brouter
java -Xmx256M -Xms64M \
-DmaxRunningTime=300 \
-cp brouter-1.7.8-all.jar \
btools.server.RouteServer \
/tmp/brouter-segments profiles2 profiles2 \
17777 2 &
# Wait for BRouter to start
for i in $(seq 1 30); do
curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break
sleep 2
done
docker volume create trails_brouter_segments
docker run --rm \
-v /tmp/brouter-segments:/src:ro \
-v trails_brouter_segments:/dst \
alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5"
- name: Start services
run: docker compose -f docker-compose.dev.yml up -d --wait --build
env:
BROUTER_URL: http://localhost:17777
- name: Wait for BRouter routing
run: |
for i in $(seq 1 60); do
curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break
[ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1
sleep 2
done
- name: Push database schema
run: pnpm db:push
- name: Seed database
run: pnpm db:seed
- name: Run integration tests
# These talk to real Postgres. The unit-test job has no DB so
# the `*.integration.test.ts` files skip there; this job has
# the DB up + schema pushed, so flip the gate env vars to "1"
# and let them run. Each gate is read by one file — see
# `runIntegration` in each test.
#
# --no-file-parallelism: integration tests share the journal
# schema and clean up by `DELETE FROM ... WHERE email LIKE
# '%@example.test'`. Parallel files step on each other's rows
# and trip FK constraints. Running sequentially is still <3s.
run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts
env:
EXPLORE_INTEGRATION: "1"
FOLLOW_INTEGRATION: "1"
DEMO_BOT_INTEGRATION: "1"
NOTIFICATIONS_INTEGRATION: "1"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
@ -210,6 +273,12 @@ jobs:
run: pnpm test:e2e
env:
BROUTER_URL: http://localhost:17777
# E2E=true is the explicit opt-out from the fail-loud
# requireSecret() / getDatabaseUrl() guards — playwright boots
# the server via `react-router serve` (NODE_ENV=production) but
# against the local dev Postgres + local cookie secrets.
E2E: "true"
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
- name: Playwright job summary
if: ${{ !cancelled() }}
@ -247,3 +316,87 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 30
journal-image-smoke:
# Build the journal's *production* Docker image (the `runtime` stage)
# and actually boot it. Nothing else in CI does this: typecheck /
# lint / test / build all run against the source tree, and the e2e
# job boots the journal via `react-router-serve`, not the production
# `node server.ts` entrypoint. The runtime stage copies source files
# in by name (server.ts, app/lib, serve-static.ts, ...), 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, app/jobs, serve-static.ts). Booting the real image and
# hitting /api/health closes that gap: a missing static OR dynamic
# import never reaches a healthy 200.
name: Journal Image Smoke Test
runs-on: ubuntu-latest
services:
postgres:
image: imresamu/postgis:16-3.4
env:
POSTGRES_USER: trails
POSTGRES_PASSWORD: trails
POSTGRES_DB: trails
ports:
- 5432:5432
# The postgis image restarts mid-init while it creates the
# extension; the health check only passes once the final server
# is up, so dependents don't race the init restart.
options: >-
--health-cmd "pg_isready -U trails"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# seedOAuthClient + the demo/notifications job worker run on boot
# and write to real tables, so the image needs a schema to come up
# healthy. The postgis extension is auto-created by the image.
- name: Push database schema
run: pnpm db:push
- name: Build journal runtime image
run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke .
- name: Boot image and wait for healthy
run: |
# --network host: reach the service Postgres at localhost:5432
# and publish the server on localhost:3000 in one shot.
# E2E=true is the documented opt-out from the fail-loud
# getDatabaseUrl() prod guard (CI points at a local Postgres).
docker run -d --name journal-smoke --network host \
-e NODE_ENV=production -e E2E=true \
-e DATABASE_URL="$DATABASE_URL" \
journal-smoke
code=000
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000)
echo "attempt $i: /api/health -> $code"
[ "$code" = "200" ] && break
if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then
echo "::error::journal container exited during boot"
break
fi
sleep 2
done
if [ "$code" != "200" ]; then
echo "::error::journal production image failed to boot healthy (see logs below)"
docker logs journal-smoke 2>&1 || true
exit 1
fi
echo "journal production image booted healthy"
- name: Container logs
if: always()
run: docker logs journal-smoke 2>&1 | tail -40 || true

View file

@ -0,0 +1,94 @@
name: Dependabot auto-fix
# Post-processing that runs on every dependabot PR and pushes the result
# back to the PR branch. Two fixups, in one workflow so there is a single
# checkout / commit / push (two workflows racing to push the same branch
# would collide on a non-fast-forward):
#
# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a
# package (e.g. two versions of i18next, each holding their own
# singleton state). That split caused a hydration mismatch on #272
# until a manual `pnpm dedupe` collapsed them.
#
# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*)
# and opsx slash commands (.claude/commands/opsx/*) are generated files
# stamped with the CLI version that produced them. When dependabot bumps
# @fission-ai/openspec they go stale until regenerated (see PR #567,
# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force`
# rewrites them to match the freshly-installed CLI.
#
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
# fine-grained PAT (or GitHub App token) with `contents: write` on
# this repo. The default `GITHUB_TOKEN` would work for the push but
# would NOT trigger a subsequent CI run on that push (GitHub's
# anti-loop safeguard), leaving the PR with stale green CI from
# before the fixup commit. A PAT re-triggers CI so the reviewer
# sees test results for the state they'd actually be merging.
on:
pull_request:
branches: [main]
permissions:
contents: write
jobs:
autofix:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Require DEPENDABOT_DEDUPE_TOKEN
env:
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
echo "::error::requires a PAT so the fixup commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-auto-fix.yml header."
exit 1
fi
- uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
# With `persist-credentials: true`, this PAT is stashed by the
# action (under $RUNNER_TEMP in recent versions) and made
# available to subsequent git operations in this workspace —
# so the later `git push` authenticates as the PAT, which is
# what gets CI to re-trigger on the fixup commit. `true` is
# the checkout default; pinned explicitly here because it's
# load-bearing for this workflow.
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile=false
- name: Dedupe lockfile
run: pnpm dedupe
- name: Regenerate OpenSpec tool files
# --force so the generated files always match the installed CLI,
# even if `update` would otherwise consider them up to date. No-op
# (no diff) when @fission-ai/openspec wasn't bumped in this PR.
run: pnpm exec openspec update --force
- name: Commit fixups
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx
if git diff --cached --quiet; then
echo "Nothing to fix up — lockfile deduped and OpenSpec files current."
exit 0
fi
# Build a message naming only the parts that actually changed.
parts=""
git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe"
if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then
parts="${parts:+$parts + }openspec update"
fi
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git commit -m "[github-actions] $parts"
git push
echo "Pushed fixups: $parts"

56
.github/workflows/disk-maintenance.yml vendored Normal file
View file

@ -0,0 +1,56 @@
name: Disk Maintenance
# Daily safety net for flagship disk usage. The deploy workflows prune
# superseded image layers after their own runs, but that protection
# disappears exactly when it's needed most: a deploy that fails early
# never reaches its prune step, while other workflows keep pulling
# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
# deploys, disk 100% full, postgres down on a Saturday morning).
#
# Also doubles as a redundant alert channel: the run FAILS when the
# disk is still above the threshold after pruning, so a scheduled-run
# failure email lands even if the Grafana disk alert drowns in other
# noise (which is what happened during the incident).
on:
schedule:
# Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
- cron: "30 4 * * *"
workflow_dispatch: {}
concurrency:
group: disk-maintenance
cancel-in-progress: false
jobs:
prune-flagship:
name: Prune unused images (flagship)
runs-on: ubuntu-latest
environment: production
steps:
- name: Prune and check disk headroom
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
echo "before: $(df -h / | tail -1)"
# 12h filter: never touch layers a same-day deploy may still
# be assembling; running containers' images are never pruned.
# `|| true`: a concurrent deploy's prune can make this collide
# with "a prune operation is already running" — that's benign
# (the other prune is freeing space too), and the disk-% gate
# below is the real assertion, so don't fail on the collision.
docker image prune -af --filter "until=12h" || true
echo "after: $(df -h / | tail -1)"
PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$PCT" -ge 85 ]; then
echo "Disk still at ${PCT}% after pruning — needs a human."
echo "Largest docker consumers:"
docker system df
exit 1
fi
echo "Disk at ${PCT}% — healthy."

112
.github/workflows/staging-cleanup.yml vendored Normal file
View file

@ -0,0 +1,112 @@
name: Staging Cleanup
# Sweeps the production server for orphaned PR-preview resources whose PRs
# have closed without the cd-staging teardown job running (e.g., the
# teardown failed, the workflow file was changed mid-flight, or the PR was
# closed while runners were down). Runs weekly and can be triggered ad-hoc.
on:
schedule:
# Every Monday at 04:00 UTC
- cron: "0 4 * * 1"
workflow_dispatch: {}
concurrency:
group: staging-cleanup
cancel-in-progress: false
jobs:
cleanup:
name: Sweep orphaned previews
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
pull-requests: read
steps:
- name: List active preview projects
id: list
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
script_stop: true
script: |
cd /opt/trails-cool
# Emit one project name per line, e.g. "trails-pr-123"
docker compose ls --format json --filter "name=trails-pr-" \
| python3 -c 'import json,sys
try:
data = json.load(sys.stdin)
except Exception:
data = []
for d in data:
n = d.get("Name","")
if n.startswith("trails-pr-"):
print(n)' \
|| true
- name: Determine which PRs are still open
id: orphans
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PROJECTS: ${{ steps.list.outputs.stdout }}
run: |
set -euo pipefail
ORPHANS=()
if [ -z "${PROJECTS:-}" ]; then
echo "No active preview projects."
echo "orphans=" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r project; do
[ -z "$project" ] && continue
pr="${project#trails-pr-}"
# If gh can't find the PR (deleted) or it's not OPEN, treat as orphan.
state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING")
if [ "$state" != "OPEN" ]; then
echo "Orphan: PR #$pr (state=$state) → tear down $project"
ORPHANS+=("$pr")
fi
done <<< "${PROJECTS}"
IFS=,
echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT"
- name: Tear down orphans
if: steps.orphans.outputs.orphans != ''
env:
ORPHANS: ${{ steps.orphans.outputs.orphans }}
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: ORPHANS
script: |
set -euo pipefail
cd /opt/trails-cool
IFS=, read -ra PRS <<< "$ORPHANS"
for PR in "${PRS[@]}"; do
[ -z "$PR" ] && continue
PROJECT="trails-pr-$PR"
DB="trails_pr_$PR"
echo "→ tearing down $PROJECT"
# `down` needs the same env file the deploy used; staging.env on
# disk may belong to a different PR, so synthesize a minimal one.
cat > /tmp/cleanup.env <<EOF
DOMAIN=pr-$PR.staging.trails.cool
STAGING_DATABASE=$DB
JOURNAL_HOST_PORT=$((3200 + 2 * PR))
PLANNER_HOST_PORT=$((3201 + 2 * PR))
JWT_SECRET=cleanup
SESSION_SECRET=cleanup
BROUTER_URL=http://placeholder
BROUTER_AUTH_TOKEN=placeholder
EOF
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env"
done
rm -f /tmp/cleanup.env
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true

View file

@ -0,0 +1,103 @@
name: Update visual snapshots
# How to use this workflow
# ========================
#
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
# committed to the repo. They are generated by Vitest browser mode running
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
#
# When to update snapshots:
# - You intentionally changed the look of the elevation chart (new color mode,
# layout change, etc.) and the old snapshots are now wrong.
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
#
# Two ways to trigger this workflow:
#
# 1. Manual dispatch (workflow_dispatch):
# Go to Actions → "Update visual snapshots" → "Run workflow".
# Choose the branch you want updated. The workflow will commit the new
# snapshots back to that branch.
#
# 2. PR label (update-snapshots):
# Add the `update-snapshots` label to any PR. The workflow will update
# snapshots on the PR's head branch. Remove the label after to avoid
# re-triggering on every subsequent push.
#
# After the workflow commits updated snapshots, pull the branch locally:
# git pull origin <your-branch>
#
# Running locally:
# pnpm --filter @trails-cool/planner test:visual # run tests
# pnpm --filter @trails-cool/planner test:visual:update # update snapshots
#
# Platform note:
# Snapshots are generated on ubuntu-latest to keep CI and local results
# consistent. Snapshots generated on macOS or Windows will have subtle
# font-rendering differences and will fail on CI. Always use this workflow
# (or a Linux machine / Docker) to produce the canonical snapshots.
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to update snapshots on"
required: false
default: ""
pull_request:
types: [labeled]
jobs:
update-snapshots:
# Only run for manual dispatch, or when the label is "update-snapshots"
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots')
runs-on: ubuntu-latest
permissions:
contents: write # needed to push snapshot commits back
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
# Use a token with push rights so the commit-back step can push
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm exec playwright install chromium --with-deps
- name: Update visual snapshots
run: pnpm --filter @trails-cool/planner test:visual:update
- name: Commit updated snapshots
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: update visual snapshots [skip ci]"
file_pattern: "apps/planner/app/**/__screenshots__/**"
commit_user_name: "github-actions[bot]"
commit_user_email: "github-actions[bot]@users.noreply.github.com"
- name: Upload snapshots as artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: visual-snapshots
path: apps/planner/app/**/__screenshots__/
if-no-files-found: ignore

3
.gitignore vendored
View file

@ -10,7 +10,10 @@ dist/
.crit.json
e2e/results/
test-results/
.env.development
playwright-report/
playwright-results.json
.claude/worktrees/
.claude/settings.local.json
.claude/scheduled_tasks.lock
docs/reviews/internal/

3
.sops.yaml Normal file
View file

@ -0,0 +1,3 @@
creation_rules:
- path_regex: secrets\..*\.env$
age: age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n

140
CLAUDE.md
View file

@ -9,7 +9,9 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with
Full architecture: `docs/architecture.md`
Philosophy: `docs/philosophy.md`
OpenSpec change: `openspec/changes/phase-1-mvp/`
Roadmap: `docs/roadmap.md`
Ideas (pre-spec explorations): `docs/ideas/`
OpenSpec changes: `openspec/changes/`
## Principles
@ -25,7 +27,7 @@ OpenSpec change: `openspec/changes/phase-1-mvp/`
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
- **Maps**: Leaflet + OpenStreetMap tiles
- **CRDT**: Yjs + y-websocket (Planner only)
- **Federation**: Fedify (Journal only, Phase 2)
- **Federation**: Fedify (Journal only)
- **Database**: PostgreSQL + PostGIS
- **Media storage**: S3-compatible (Garage)
- **Routing engine**: BRouter (Java, runs as separate Docker container)
@ -39,11 +41,15 @@ apps/
planner/ — Planner app (React Router 7)
journal/ — Journal app (React Router 7 + Fedify)
packages/
types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
ui/ — Shared React components (Tailwind)
map/ — Leaflet map wrappers and tile layer configs
types/ — Shared wire types both apps exchange (Waypoint)
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
gpx/ — GPX parsing, generation, validation
fit/ — FIT file generation (Wahoo route push)
i18n/ — react-i18next config + translations
api/ — Shared API contracts (endpoints, pagination, error types, versioning)
db/ — Drizzle schema, database client, migration helpers
jobs/ — pg-boss setup, worker, and background job types
sentry-config/ — Shared Sentry configuration
infrastructure/ — Terraform + Docker Compose
openspec/ — OpenSpec specs and changes
docs/ — Architecture, philosophy, tooling docs
@ -68,6 +74,40 @@ pnpm db:push # Push Drizzle schema to local PostgreSQL
pnpm db:studio # Open Drizzle Studio (DB browser)
```
### Local HTTPS dev (rare — most contributors never need this)
The default dev loop runs the journal on plain HTTP at
`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the
Terms gate, SSE — everything works over HTTP because the WebAuthn spec
treats `localhost` as a secure context regardless of scheme. CI's e2e
suite runs over plain HTTP too.
There is exactly one feature that requires local HTTPS: **Wahoo OAuth**.
Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the
`/api/sync/connect/wahoo` callback flow can only complete against an
HTTPS dev server. To run that flow:
```bash
HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev
```
`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN
HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the
WebAuthn server expect the HTTPS origin (set this together with HTTPS=1
or you'll get origin-mismatch errors). Use `pnpm --filter` (not
`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to
its `globalPassThroughEnv``pnpm --filter` bypasses turbo entirely.
**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env`
unless you intend to always run with `HTTPS=1`.** Mismatched values
break the e2e suite and generic dev. See
`apps/journal/.env.example` for what each var means.
If you find yourself wanting `HTTPS=1` for any reason other than Wahoo
testing, write it down here so the assumption stays auditable —
"everything but Wahoo works over HTTP locally" is what keeps CI and
local config symmetric.
## Testing Strategy
- **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic.
@ -81,9 +121,10 @@ pnpm db:studio # Open Drizzle Studio (DB browser)
## Code Conventions
- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build.
- All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings)
- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
- Map components go in `@trails-cool/map`, not in individual apps
- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types`
- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them
- GPX parsing/generation goes in `@trails-cool/gpx`
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
@ -140,6 +181,91 @@ before pushing to an existing PR branch.
### Emergency override
Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a hotfix). Document the reason in the commit message.
## Deployment
Five CD workflows triggered by path or event:
| Workflow | Triggers on | Deploys | Target |
|----------|-------------|---------|--------|
| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) |
| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) |
| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) |
| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) |
| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship |
### Hosts
trails.cool runs on two Hetzner boxes in the same Falkenstein datacenter:
- **Flagship** — Hetzner Cloud `cx23`, public IP + vSwitch IP `10.0.0.2`. Runs Journal, Planner, Postgres, Caddy, Prometheus, Loki, Grafana.
- **BRouter host** — Hetzner Dedicated `ullrich.is`, public IP `176.9.150.227` + vSwitch IP `10.0.1.10`. Shared self-hosted box; trails.cool owns only a non-root `trails` user with docker-group rights, scoped to `~trails/brouter/`. SSH is on port **2232**.
The two hosts are bridged via Hetzner vSwitch #80672 (VLAN 4000). Planner → BRouter traffic crosses it; BRouter → Loki traffic (for log shipping) crosses it back.
### Secrets
All secrets are SOPS-encrypted: `infrastructure/secrets.app.env` (apps + BRouter shared token), `infrastructure/secrets.infra.env` (flagship infra only). Edit with `sops infrastructure/secrets.app.env`. GitHub Actions secrets: `AGE_SECRET_KEY`, `DEPLOY_HOST` / `DEPLOY_SSH_KEY` (flagship), `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` / `BROUTER_DEPLOY_SSH_PORT` (dedicated).
### Full restart
To restart **all** containers on the flagship (not just the ones a workflow normally touches):
```bash
gh workflow run cd-infra.yml -f restart_all=true
```
### Server access
```bash
# Flagship — root, standard port, deploy key
ssh -i ~/.ssh/trails-cool-deploy root@trails.cool
# BRouter host — trails user, non-standard port, different deploy key
ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is
```
### Grafana
`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org)
### Staging & Previews
A persistent staging stack and ephemeral PR previews share the flagship server with production.
| Surface | URL | Database | Triggered by |
|---------|-----|----------|--------------|
| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` |
| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` |
| PR preview journal | `https://pr-<N>.staging.trails.cool` | `trails_pr_<N>` | PR open/sync |
PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it.
**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`):
- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface)
- PR `<N>` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews)
**Compose project namespacing** keeps each preview isolated:
- Persistent staging: `-p trails-staging`
- PR `<N>`: `-p trails-pr-<N>`
The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile.
**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-<N>.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert.
**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews.
**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying.
**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran.
**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
## Agent Skills & Config
Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink).
```
.agents/skills/ ← canonical location
.claude/skills ← symlink → ../.agents/skills
```
Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup.
## OpenSpec Workflow
Specs live in `openspec/`. Use these slash commands:

198
CONTEXT.md Normal file
View file

@ -0,0 +1,198 @@
# trails.cool domain glossary
This file names the domain concepts used in the codebase. New terms get added
here as decisions crystallize during architecture work; the goal is that one
concept has one name everywhere — specs, code, conversations.
If you're naming a new module, a new column, or a new UI surface, look here
first. If the term you need isn't here, propose it (don't invent a synonym).
---
## GPX Save
The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction.
### gpx-save module
`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly.
### GpxValidationError
Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat 90..90, lon 180..180). Callers catch this to return a user-facing 400.
### validateGpx
`(gpx: string) → Promise<ParsedGpx>`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write.
### atomic GPX save
The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path.
---
## Connected Services
The user-facing surface for linking external accounts and devices to a Journal
account. Spec: `openspec/specs/connected-services/`.
### ConnectedService
A single linked external account or device, owned by one user. Stored in the
`connected_services` table (renamed from `sync_connections`). At most one row
per `(user_id, provider)`.
### provider
String identifier for the external system: `wahoo`, `komoot`, `apple-health`,
and future `coros`, `garmin`, `strava`. The provider determines the
`credential_kind` and which capabilities (import / push / webhook) the
connection has, via the provider's manifest.
### credential kind
Discriminator on `connected_services` describing the credential shape stored
in the `credentials` JSONB blob. Three kinds today:
- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the
expected shape for Coros / Garmin / Strava.
- **web-login** — email + encrypted password + session jar. Komoot. No
official API; we authenticate against the provider's normal web login and
reuse the resulting session cookies. Refresh = re-login. Web-login
breakage (form changes, captchas, password rotation) surfaces at the
import layer, not at the credential layer.
- **device** — no remote credential. Apple Health (and future Health Connect
on Android). Data arrives via authenticated mobile API uploads, not
server-initiated pulls. The `credentials` blob is empty; the connection
exists so the UI can show "Apple Health is paired."
Credential kind is determined by the provider via its manifest, but stored
explicitly on the row so queries don't need to join the manifest.
### granted_scopes
Column on `connected_services`, populated only for `credential_kind = oauth`,
NULL otherwise. Lists the OAuth scopes the user actually granted (e.g.
`routes_write`). Feature gates query this column directly; missing a scope
triggers re-authorization.
### provider_user_id
The external service's identifier for the user. Used to route incoming
webhooks to the right local user. Nullable (Apple Health has none in the
remote-id sense).
### CredentialAdapter
Per-kind module that knows how to maintain credentials of that kind:
- `oauth.refresh(creds) → creds | NeedsRelink`
- `web-login.relogin(creds) → creds | InvalidCredentials`
- `device` — no-op
Adapters do not import data, push routes, or handle webhooks. They only own
the credential lifecycle.
### ConnectedServiceManager
The deep module callers see. Owns:
- `link(userId, provider, credentials)` / `unlink(serviceId)`
- `withFreshCredentials(serviceId, fn)` — refreshes via the right
`CredentialAdapter` if expired, calls `fn(creds)`, marks the connection
`needs_relink` if refresh fails.
- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook
layers when they observe a credential failure (e.g. Komoot web-login
fails, Wahoo returns 401 after a successful refresh).
Per-provider importers / pushers / webhook handlers always go through
`withFreshCredentials` — they never read the `credentials` JSONB directly.
### provider manifest
Per-provider declaration co-located with the provider's code
(`providers/wahoo/manifest.ts`, etc.). Declares:
- the provider's `credential_kind`
- which capabilities the provider implements (import? push? webhook?)
- references to the per-capability modules
A small `providers/registry.ts` imports each manifest. Adding a provider is
one new directory plus one import line.
## Sync Capabilities
Three orthogonal capabilities a provider may implement. Each is its own seam
when there are ≥2 adapters; today most are single-adapter and held to a
named shape so the second adapter doesn't reshape the interface.
### Importer
Pulls workouts / activities from the external service into the Journal.
Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all
implement this, with very different mechanics. Dedup via `sync_imports`.
### RoutePusher
Pushes a Journal route out to the external service. One adapter today
(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it.
The public seam is `pushRoute(connectedService, route) → {remoteId, version}`.
Provider-specific concerns — FIT Course conversion, the `route:<id>`
`external_id` convention, the PUT→POST-on-404 fallback — are **handled
internally by the adapter**, not exposed on the seam. Idempotency is tracked
via `sync_pushes`.
### WebhookReceiver
Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming
webhooks to the right user via `provider_user_id`. Unknown
`provider_user_id` returns 200 and is silently dropped (no leak).
## Storage tables
- `connected_services` — user ↔ provider links (renamed from
`sync_connections`).
- `sync_imports` — dedup cache for imported workouts, keyed by
`(user_id, provider, workout_id)`.
- `sync_pushes` — push state per `(user_id, route_id, provider)`
`remote_id`, `last_pushed_version`.
---
## Authentication
User identity in the Journal. Two **authentication methods** are supported
and are intentionally the entire surface (see ADR-0005): **passkey**
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
(email) as a fallback for users without passkey support or for cross-device
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
already deliver the one-tap UX, and adding centralized identity providers
would conflict with the privacy-first ethos and ActivityPub federation.
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
method. It is a **session transport** for native clients: users still
authenticate via passkey or magic-link in a WebView, then the mobile app
exchanges the resulting authorization code for long-lived bearer tokens.
The peer of OAuth2 transport is the cookie session, not passkey or
magic-link.
### completeAuth
The single chokepoint for the post-verify orchestration of every web
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
ADR-0004). Called by every route handler that has just verified a
user's identity (passkey login finish, magic-link code verify, magic
link consumer). Does three things in order:
1. If `isNewRegistration`, records the accepted Terms version
(`recordTermsAcceptance`).
2. Creates the session cookie via `createSession`.
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
header attached.
Identity-method-specific work (WebAuthn ceremony verification, magic
token consumption) stays in the per-method functions and runs *before*
`completeAuth`. The chokepoint deliberately knows nothing about how
identity was proved.
### Terms gate
Cross-cutting middleware enforcing that `users.terms_version` matches
the current `TERMS_VERSION` constant before any non-allow-listed
authenticated request succeeds. Two enforcement points:
- **Web (cookie sessions)**: the root loader redirects stale-terms users
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
allow-list, so OAuth code issuance is gated by this same redirect
before mobile sees an authorization code.
- **API (bearer tokens)**: `requireApiUser` returns
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
`completeAuth` only **records** terms on registration; it does not
enforce them. Enforcement remains middleware's job.

198
FEDERATION.md Normal file
View file

@ -0,0 +1,198 @@
# Federation protocol
trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/),
implemented with [Fedify](https://fedify.dev). This document describes the
wire protocol precisely enough for another implementation to interoperate
deliberately — actor discovery, the object and activity types we emit and
accept, addressing, signatures, deduplication, delivery retry, and
moderation. Examples use `trails.example` for our instance and
`remote.example` for a peer.
Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off,
every federation surface returns 404 — a disabled instance is
indistinguishable from one without the feature. Only users with
`profile_visibility = 'public'` federate; a private user's actor,
WebFinger, inbox, and outbox all 404, so their existence never leaks.
## Actor discovery
### WebFinger
`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a
handle to an actor IRI:
```json
{
"subject": "acct:alice@trails.example",
"links": [
{ "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
]
}
```
### Actor
`GET https://trails.example/users/alice` with `Accept: application/activity+json`
returns a `Person`. The actor IRI and the human profile `url` are the same
by design (browsers get HTML at that URL via content negotiation):
```json
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://trails.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"summary": "trail runner",
"url": "https://trails.example/users/alice",
"inbox": "https://trails.example/users/alice/inbox",
"outbox": "https://trails.example/users/alice/outbox",
"publicKey": {
"id": "https://trails.example/users/alice#main-key",
"owner": "https://trails.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
},
"assertionMethod": [ { "type": "Multikey", "…": "…" } ],
"attachment": [
{ "type": "PropertyValue", "name": "🥾 trails.cool", "value": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
]
}
```
`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification;
`assertionMethod` carries the same keys as Multikeys for newer stacks.
### NodeInfo (software discovery)
`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`:
```json
{
"version": "2.1",
"software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
"protocols": ["activitypub"],
"usage": { "users": {}, "localPosts": 0, "localComments": 0 }
}
```
`software.name` is the machine-readable "this is a trails instance" marker
used by the trails-to-trails outbound check. Usage counts are deliberately
zeroed — publishing per-instance counts is a privacy decision we have not
made.
## Objects and activities
Activities correspond to a user's journal entries. The object model is
deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content`
summarizes the activity and whose `url` links to the journal detail page.
(A first-class `trails:Route` object type is planned with route-federation;
today everything is a Note.)
### Note
```json
{
"id": "https://trails.example/activities/01H…",
"type": "Note",
"attributedTo": "https://trails.example/users/alice",
"content": "<p>Morning trail run — 12.4 km, 480 m up</p>",
"url": "https://trails.example/activities/01H…",
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
```
The Note IRI (`/activities/<id>`) is dereferenceable and serves
`application/activity+json` — Mastodon's search-fetch and strict re-fetch
of pushed objects both rely on this.
### Create / Delete
A publish is a `Create` wrapping the Note; the activity id is the object IRI
with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone`
at the same object IRI:
```json
{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
"actor": "https://trails.example/users/alice",
"object": { "…": "the Note above" },
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"] }
```
Note that a `Delete` poisons the object URI on the remote forever (remotes
tombstone it); re-publishing the same URI after a Delete is silently
refused by strict remotes.
### Follow graph
The inbox is **narrow** — only follow-graph activities are processed;
anything else is acknowledged (`202`) and dropped.
| Inbound | Effect |
|---|---|
| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` |
| `Undo(Follow)` | removes the follow |
| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll |
| `Reject(Follow)` | drops our pending outgoing Follow |
## Addressing
Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public`
and **push-delivered** to each accepted remote follower's inbox (fan-out,
one delivery per follower). We do not implement shared-inbox delivery.
Remotes do not backfill history — only pushed or individually-fetched
objects appear on a peer.
## Signatures
All inbound activities must carry a valid HTTP Signature; Fedify verifies it
against the sending actor's `publicKey` (fetched and cached). Unsigned or
badly-signed requests are rejected. Outbound deliveries are signed with the
sending user's key. An actor changing keys requires the remote to re-fetch
the actor document.
## Deduplication
Delivery is at-least-once, so receivers must be idempotent. trails dedups
inbound activities two ways:
- **`Create(Note)`** — idempotent via a unique constraint on the activity's
origin IRI (`remote_origin_iri`); a redelivered Create is a no-op.
- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the
activity IRI is recorded in `federation_processed_activities` on first
receipt (insert-or-drop before any side effect); a redelivery is dropped
and counted (`federation_inbox_dropped_total{reason="duplicate"}`).
Records are retained ≥ 30 days, which comfortably exceeds the
HTTP-Signature date-freshness window, then swept.
## Delivery retry
Delivery queueing and retry state are **durable** — they survive process
restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
retry policy). On a `5xx` or timeout, a delivery retries with exponential
backoff, giving up after a bounded budget (~8 attempts spanning roughly a
day) before a permanent failure is logged. Deliveries are paced to at most
1 request/second per remote host. Metrics:
`federation_delivery_total{outcome}` and `federation_queue_depth`.
## Moderation
An operator can block a federation instance by domain (exact-host match).
A blocked instance is **inert in both directions**:
- its inbound activities are silently dropped (`202`, no error oracle) and
counted (`federation_inbox_dropped_total{reason="blocked"}`);
- it receives no deliveries (blocked recipients are filtered from fan-out);
- we never fetch its actors or outboxes.
Blocking is effective immediately (checked per request / per job, no cache).
The operator procedure (a SQL insert/delete against
`journal.federation_blocked_instances`) is documented in the
[deployment runbook](docs/deployment.md#blocking-an-instance).
---
*Kept current as federation capabilities change. Specs:
`openspec/specs/social-federation` and `openspec/specs/federation-operations`.*

View file

@ -79,6 +79,7 @@ This project uses AI-assisted, spec-driven development. See
|------|---------|
| [cmux](https://cmux.dev) | Native macOS terminal for running multiple AI coding sessions |
| [Claude Code](https://claude.ai/claude-code) | AI coding assistant (primary development tool) |
| [GitHub Copilot](https://github.com/features/copilot) | AI coding assistant |
| [Crit](https://github.com/tomasz-tomczyk/crit) | Browser-based inline code review |
| [OpenSpec](https://openspec.dev) | Spec-driven development workflow |
@ -95,6 +96,14 @@ docker compose up -d
See [docs/architecture.md](docs/architecture.md) for details on self-hosting
configuration.
## Federation
The Journal federates over ActivityPub. The wire protocol — actor
discovery, object/activity types with JSON examples, addressing,
signatures, deduplication, delivery retry, and moderation — is documented
in [FEDERATION.md](FEDERATION.md), which is precise enough for another
implementation to interoperate against.
## Philosophy
- **Privacy by design** — The Planner collects zero user data

63
apps/journal/.env.example Normal file
View file

@ -0,0 +1,63 @@
# Journal local-dev environment.
# Copy to `.env` (gitignored) and edit. Most contributors don't need
# anything in this file — the journal app boots on plain HTTP at
# http://localhost:3000 with sensible defaults.
# ────────────────────────────────────────────────────────────────────
# DO NOT SET unless you know you need it
# ────────────────────────────────────────────────────────────────────
#
# `ORIGIN` is what the WebAuthn ceremony uses as `expectedOrigin`.
# When unset it defaults to `http://localhost:3000`, which is what
# Playwright sends and what the browser sees in plain-HTTP dev.
#
# The ONLY reason to set this is if you're running the dev server
# over HTTPS (see HTTPS=1 below) and want passkey registration to
# succeed against the HTTPS origin. If you set it to `https://...`
# without also running the server over HTTPS, you'll get
# "Unexpected registration response origin" errors in dev and the
# local e2e suite (which always hits HTTP) will fail registration.
#
# ORIGIN=https://localhost:3000
# Flagship marker. Renders the project marketing block on the
# anonymous home and gates a few "this is the canonical instance"
# behaviors. Self-hosted instances leave this unset; flagship CI
# sets it to `true`. Either is fine for local dev — set it if you
# want to see the full marketing layout, leave it unset if you
# want the self-host home view.
# IS_FLAGSHIP=true
# ────────────────────────────────────────────────────────────────────
# Wahoo OAuth (only needed when actively testing the Wahoo import)
# ────────────────────────────────────────────────────────────────────
#
# Wahoo's OAuth flow is the one feature that genuinely needs HTTPS
# locally: the provider rejects `http://` redirect URIs. To exercise
# the connect/disconnect/import flow end-to-end, you need:
#
# 1. Local HTTPS dev server: run `HTTPS=1 pnpm --filter
# @trails-cool/journal dev` (the basic-ssl plugin in
# vite.config.ts wires up the cert; see the comment there for
# the ALPN workaround).
# 2. ORIGIN=https://localhost:3000 (uncomment above).
# 3. Wahoo client credentials below, and a redirect URI of
# `https://localhost:3000/api/sync/callback/wahoo` registered
# in your Wahoo developer dashboard.
#
# WAHOO_CLIENT_ID=
# WAHOO_CLIENT_SECRET=
# WAHOO_WEBHOOK_TOKEN=
# Garmin Connect Developer Program credentials (spec: garmin-import).
# Requires an approved program application; without these the Garmin
# provider is hidden on /settings/connections. The OAuth callback to
# register with Garmin is `<origin>/api/sync/callback/garmin`, the
# notification endpoint `<origin>/api/sync/webhook/garmin`.
# GARMIN_CLIENT_ID=
# GARMIN_CLIENT_SECRET=
# Integration test secret (only needed if running the integration
# test suite that drives the API directly). Generate with
# `openssl rand -hex 32`.
# INTEGRATION_SECRET=

View file

@ -1,4 +1,7 @@
FROM node:25-slim AS base
FROM node:26-slim AS base
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
# trixie-slim and ships neither curl nor wget by default).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
RUN npm install -g pnpm@10.6.5
WORKDIR /app
@ -6,25 +9,40 @@ FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/journal/package.json apps/journal/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/
COPY packages/ui/package.json packages/ui/
COPY packages/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/
COPY packages/fit/package.json packages/fit/
RUN pnpm install --frozen-lockfile
FROM base AS build
ARG SENTRY_AUTH_TOKEN
ARG SENTRY_RELEASE
# Client-side Sentry DSN baked into the bundle at build time. Empty (or
# unset) produces a Sentry-free client. Public-by-design: the DSN
# appears in the shipped client JS regardless.
ARG VITE_SENTRY_DSN=""
COPY --from=deps /app/ ./
COPY . .
RUN pnpm --filter @trails-cool/journal build
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \
SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
SENTRY_RELEASE="$SENTRY_RELEASE" \
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
pnpm --filter @trails-cool/journal build
FROM base AS runtime
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules
COPY --from=build /app/apps/journal/build ./apps/journal/build
COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts
COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts
COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib
COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json
COPY --from=build /app/packages ./packages
@ -32,4 +50,4 @@ WORKDIR /app/apps/journal
USER app
EXPOSE 3000
ENV PORT=3000
CMD ["npx", "react-router-serve", "./build/server/index.js"]
CMD ["node", "--experimental-strip-types", "server.ts"]

View file

@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { Form, Link } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
export function AccountDropdown({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Click-outside + Escape close. Mounted only while open to keep the
// listener cost zero in the steady state.
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={containerRef} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={user.displayName ?? user.username}
onClick={() => setOpen((o) => !o)}
className="inline-flex items-center rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Avatar displayName={user.displayName} username={user.username} size="md" />
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-20 mt-2 w-56 origin-top-right rounded-md border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
>
<div className="border-b border-gray-100 px-3 py-2">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
<Link
to={`/users/${user.username}`}
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.profile")}
</Link>
<Link
to="/settings"
role="menuitem"
onClick={() => setOpen(false)}
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
role="menuitem"
className="block w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export function AlphaBanner() {
const { t } = useTranslation("journal");
return (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm text-amber-900">
<p>{t("alpha.message")}</p>
</div>
);
}

View file

@ -0,0 +1,42 @@
// Initials-only avatar. We don't have an image-upload story yet (the
// `users` table has no avatar URL column), so initials are the
// implementation. When images land, this component is the single
// place to add the image fallback.
interface Props {
displayName: string | null;
username: string;
size?: "sm" | "md" | "lg";
className?: string;
}
function initialsOf(displayName: string | null, username: string): string {
const source = (displayName ?? username).trim();
if (source.length === 0) return "?";
// Two-letter initials: first letter of the first two whitespace-
// separated words, falling back to the first two characters when
// the source is a single token.
const parts = source.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
return source.slice(0, 2).toUpperCase();
}
const SIZE_CLASS: Record<NonNullable<Props["size"]>, string> = {
sm: "h-7 w-7 text-[11px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
export function Avatar({ displayName, username, size = "md", className = "" }: Props) {
const initials = initialsOf(displayName, username);
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-700 ${SIZE_CLASS[size]} ${className}`}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -1,15 +1,16 @@
import { useState, useEffect } from "react";
import { useLocale } from "./LocaleContext";
/**
* Renders a date only on the client to avoid SSR hydration mismatches.
* Server renders empty, client fills in with user's locale.
* Renders a date formatted with the server-detected locale,
* ensuring SSR and client output match (no hydration flicker).
* Pass `withTime` for surfaces where the hour/minute matter
* (e.g. notifications), keeping plain dates as the default.
*/
export function ClientDate({ iso }: { iso: string }) {
const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US"));
useEffect(() => {
setFormatted(new Date(iso).toLocaleDateString());
}, [iso]);
return <time dateTime={iso}>{formatted}</time>;
export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) {
const locale = useLocale();
const d = new Date(iso);
const text = withTime
? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" })
: d.toLocaleDateString(locale);
return <time dateTime={iso}>{text}</time>;
}

View file

@ -0,0 +1,21 @@
import { useState, useEffect, type ComponentProps } from "react";
type MapProps = ComponentProps<typeof import("./RouteMapThumbnail.client").RouteMapThumbnail>;
export function ClientMap(props: MapProps) {
const [Component, setComponent] = useState<React.ComponentType<MapProps>>();
useEffect(() => {
import("./RouteMapThumbnail.client").then((m) => setComponent(() => m.RouteMapThumbnail));
}, []);
if (!Component) {
return (
<div className={`flex items-center justify-center bg-gray-100 text-gray-500 ${props.className ?? "h-36 w-full rounded"}`}>
Loading map...
</div>
);
}
return <Component {...props} />;
}

View file

@ -0,0 +1,89 @@
import { useTranslation } from "react-i18next";
interface Entry {
username: string;
displayName: string | null;
domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
}
interface Props {
kind: "followers" | "following";
user: { username: string; displayName: string | null };
entries: Entry[];
page: number;
total: number;
}
const PAGE_SIZE = 50;
export function CollectionPage({ kind, user, entries, page, total }: Props) {
const { t } = useTranslation("journal");
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const heading = t(`social.${kind}.heading`, {
user: user.displayName ?? user.username,
});
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<nav className="mb-4 text-sm text-gray-500">
<a href={`/users/${user.username}`} className="hover:text-gray-700 hover:underline">
@{user.username}
</a>
{" / "}
<span>{kind === "followers" ? t("social.followers.label") : t("social.following.label")}</span>
</nav>
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
<p className="mt-1 text-sm text-gray-500">{t(`social.${kind}.count`, { count: total })}</p>
{entries.length === 0 ? (
<p className="mt-8 text-center text-gray-500">
{t(`social.${kind}.empty`)}
</p>
) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => (
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
<a
href={entry.profileUrl}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center justify-between hover:underline"
>
<span className="text-sm font-medium text-gray-900">
{entry.displayName ?? entry.username}
</span>
<span className="text-xs text-gray-500">@{entry.username}@{entry.domain}</span>
</a>
</li>
))}
</ul>
)}
{totalPages > 1 && (
<nav className="mt-6 flex items-center justify-between text-sm">
{page > 1 ? (
<a
href={`?page=${page - 1}`}
className="text-blue-600 hover:underline"
>
{t("social.prevPage")}
</a>
) : <span />}
<span className="text-gray-500">
{t("social.pageOfTotal", { page, totalPages })}
</span>
{page < totalPages ? (
<a
href={`?page=${page + 1}`}
className="text-blue-600 hover:underline"
>
{t("social.nextPage")}
</a>
) : <span />}
</nav>
)}
</div>
);
}

View file

@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { ElevationProfile } from "./ElevationProfile.tsx";
import type { ElevationSample } from "@trails-cool/gpx";
afterEach(cleanup);
const labels = { highest: "Highest", lowest: "Lowest" };
const series: ElevationSample[] = [
{ d: 0, e: 100, lat: 0, lng: 0 },
{ d: 500, e: 160, lat: 0, lng: 0.005 },
{ d: 1000, e: 120, lat: 0, lng: 0.01 },
];
describe("ElevationProfile", () => {
it("renders nothing for a too-short series", () => {
const { container } = render(
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).toBeNull();
});
it("renders the chart and highest/lowest summary", () => {
const { container, getByText } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).not.toBeNull();
// area + line paths
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2);
expect(getByText("160 m")).toBeTruthy(); // highest
expect(getByText("100 m")).toBeTruthy(); // lowest
});
it("draws an active marker when activeIndex is set", () => {
const { container } = render(
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("circle")).not.toBeNull();
expect(container.querySelector("line")).not.toBeNull();
});
it("reports seek on pointer down", () => {
const onSeek = vi.fn();
const { container } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={onSeek} labels={labels} />,
);
const svg = container.querySelector("svg")!;
// jsdom getBoundingClientRect returns zeros; we only assert the handler fires.
fireEvent.pointerDown(svg, { clientX: 10 });
expect(onSeek).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,122 @@
import { useRef } from "react";
import type { ElevationSample } from "@trails-cool/gpx";
import { formatElevationM, formatDistanceKm } from "~/lib/stats";
const W = 1000;
const H = 220;
const PAD = { top: 16, right: 8, bottom: 22, left: 46 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
/**
* Read-only elevation profile chart (SVG, responsive via viewBox). Distance on
* x, elevation on y, with a gradient area fill. Reports the hovered sample
* index via `onActive` (for the map marker) and the clicked index via `onSeek`
* (centre the map), and draws a marker at `activeIndex` (set by the map when the
* route line is hovered). Renders nothing for an empty/too-short series.
*/
export function ElevationProfile({
series,
activeIndex,
onActive,
onSeek,
labels,
className,
}: {
series: ElevationSample[];
activeIndex: number | null;
onActive: (index: number | null) => void;
onSeek: (index: number) => void;
labels: { highest: string; lowest: string };
className?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
if (series.length < 2) return null;
const maxD = series[series.length - 1]!.d || 1;
let minE = Infinity;
let maxE = -Infinity;
for (const s of series) {
if (s.e < minE) minE = s.e;
if (s.e > maxE) maxE = s.e;
}
const eRange = Math.max(1, maxE - minE);
const baseY = PAD.top + PLOT_H;
const x = (d: number) => PAD.left + (d / maxD) * PLOT_W;
const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H;
const linePath = series
.map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`)
.join(" ");
const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`;
const active = activeIndex != null ? series[activeIndex] : null;
function indexFromClientX(clientX: number): number {
const rect = ref.current!.getBoundingClientRect();
const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units
const d = ((px - PAD.left) / PLOT_W) * maxD;
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const delta = Math.abs(series[i]!.d - d);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
}
return (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
{" · "}
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
</span>
{active && (
<span className="tabular-nums">
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
</span>
)}
</div>
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-40 w-full touch-none select-none"
role="img"
aria-label="Elevation profile"
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
onPointerLeave={() => onActive(null)}
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
>
<defs>
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#elev-fill)" />
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{active && (
<g>
<line
x1={x(active.d)}
y1={PAD.top}
x2={x(active.d)}
y2={baseY}
stroke="#9ca3af"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
interface FollowState {
following: boolean;
pending: boolean;
}
interface Props {
username: string;
// Whether the followed profile is private/locked. Drives the "Request to
// follow" label vs. plain "Follow" before any click happens.
isPrivateTarget: boolean;
initialState: FollowState | null;
}
type Display = "follow" | "request" | "pending" | "unfollow";
function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display {
if (state?.following) return "unfollow";
if (state?.pending) return "pending";
return isPrivateTarget ? "request" : "follow";
}
export function FollowButton({ username, isPrivateTarget, initialState }: Props) {
const { t } = useTranslation("journal");
const [state, setState] = useState<FollowState>(
initialState ?? { following: false, pending: false },
);
const [isInFlight, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const display = displayFor(state, isPrivateTarget);
const onClick = () => {
setError(null);
startTransition(async () => {
// For "pending" we treat the click as cancel-request: same /unfollow
// endpoint deletes the row whether it's accepted or pending.
const path = state.following || state.pending
? `/api/users/${username}/unfollow`
: `/api/users/${username}/follow`;
try {
const res = await fetch(path, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error ?? "Failed");
return;
}
const body = (await res.json()) as { following: boolean; pending: boolean };
setState({ following: body.following, pending: body.pending });
} catch (e) {
setError((e as Error).message);
}
});
};
const label = (() => {
switch (display) {
case "unfollow":
return t("social.unfollow");
case "pending":
return t("social.pendingCancel");
case "request":
return t("social.requestToFollow");
case "follow":
default:
return t("social.follow");
}
})();
const baseClass = display === "follow" || display === "request"
? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
: "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50";
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onClick}
disabled={isInFlight}
className={baseClass}
>
{isInFlight ? "…" : label}
</button>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
export function Footer() {
const { t } = useTranslation("journal");
return (
<footer className="mt-16 border-t border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-500">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<a href="/legal/imprint" className="hover:text-gray-700">
{t("footer.imprint")}
</a>
<a href="/legal/privacy" className="hover:text-gray-700">
{t("footer.privacy")}
</a>
<a href="/legal/terms" className="hover:text-gray-700">
{t("footer.terms")}
</a>
<a
href="https://github.com/trails-cool/trails"
className="hover:text-gray-700"
target="_blank"
rel="noopener noreferrer"
>
{t("footer.source")}
</a>
<span className="ml-auto text-xs text-gray-400">
{t("footer.alpha")}
</span>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
const LocaleContext = createContext<string>("en");
export function LocaleProvider({
locale,
children,
}: {
locale: string;
children: React.ReactNode;
}) {
return <LocaleContext value={locale}>{children}</LocaleContext>;
}
export function useLocale() {
return useContext(LocaleContext);
}

View file

@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { Form, Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { Avatar } from "./Avatar";
interface Props {
user: { username: string; displayName: string | null };
}
// Mobile drawer for the navbar. Replaces the "everything in a row"
// desktop layout with a hamburger trigger and a slide-out panel that
// holds all the same destinations. Bell + the dropdown's account
// section move inside; the bell badge still surfaces on the trigger
// itself via the parent navbar.
export function MobileNavMenu({ user }: Props) {
const { t } = useTranslation("journal");
const [open, setOpen] = useState(false);
const location = useLocation();
// Close the drawer on navigation. React Router pushes a new location
// when a Link is followed; this effect picks that up.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
// Close on Escape; lock body scroll while open.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open]);
const linkClass = (path: string) => {
const active = location.pathname === path || location.pathname.startsWith(path + "/");
return `block rounded-md px-3 py-2 text-base font-medium ${
active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50"
}`;
};
return (
<>
<button
type="button"
aria-label={t("nav.openMenu")}
aria-expanded={open}
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-6 w-6"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
{open && (
<div className="fixed inset-0 z-40 md:hidden">
{/* Backdrop */}
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="absolute inset-0 bg-black/30"
/>
{/* Panel */}
<div
role="dialog"
aria-modal="true"
className="absolute right-0 top-0 flex h-full w-72 max-w-[85%] flex-col bg-white shadow-xl"
>
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div className="flex items-center gap-3">
<Avatar displayName={user.displayName} username={user.username} size="md" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900">
{user.displayName ?? user.username}
</p>
<p className="truncate text-xs text-gray-500">@{user.username}</p>
</div>
</div>
<button
type="button"
aria-label={t("nav.closeMenu")}
onClick={() => setOpen(false)}
className="rounded-md p-2 text-gray-500 hover:bg-gray-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.75}
stroke="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-2 py-3">
<Link to="/feed" className={linkClass("/feed")}>
{t("social.feed.title")}
</Link>
<Link to="/explore" className={linkClass("/explore")}>
{t("nav.explore")}
</Link>
<Link to="/routes" className={linkClass("/routes")}>
{t("nav.routes")}
</Link>
<Link to="/activities" className={linkClass("/activities")}>
{t("nav.activities")}
</Link>
<Link to="/notifications" className={linkClass("/notifications")}>
{t("notifications.title")}
</Link>
<hr className="my-2 border-gray-200" />
<Link to={`/users/${user.username}`} className={linkClass(`/users/${user.username}`)}>
{t("nav.profile")}
</Link>
<Link to="/settings" className={linkClass("/settings")}>
{t("nav.settings")}
</Link>
<Form method="post" action="/auth/logout">
<button
type="submit"
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-gray-700 hover:bg-gray-50"
>
{t("nav.logout")}
</button>
</Form>
</nav>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { ProfileStats } from "./ProfileStats.tsx";
afterEach(cleanup);
describe("ProfileStats", () => {
it("renders nothing when there are no activities", () => {
const { container } = render(
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.firstChild).toBeNull();
});
it("renders formatted totals", () => {
const { container, getByText } = render(
<ProfileStats
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
/>,
);
// Values come from the formatters (i18n-independent).
expect(getByText("42")).toBeTruthy();
expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer
expect(getByText("↑ 5120 m")).toBeTruthy();
expect(getByText("2h 30m")).toBeTruthy();
// last-4-weeks line present when > 0
expect(container.querySelector("p")).not.toBeNull();
});
it("omits the last-4-weeks line when zero", () => {
const { container } = render(
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.querySelector("p")).toBeNull();
});
});

View file

@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { StatRow } from "./StatRow.tsx";
import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats";
// Structural shape (mirrors ActivityStats from activities.server) so this
// presentational component doesn't import from a `.server` module.
export interface ProfileStatsData {
count: number;
distance: number;
elevationGain: number;
duration: number;
last4Weeks: number;
}
/**
* Lifetime roll-up header on the profile: activity count · distance · ascent ·
* elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the
* (viewer-scoped) count is zero.
*/
export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) {
const { t } = useTranslation("journal");
if (stats.count === 0) return null;
return (
<div className={className}>
<StatRow
size="lg"
items={[
{ label: t("profileStats.activities"), value: String(stats.count) },
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
{ label: t("profileStats.ascent"), value: `${formatElevationM(stats.elevationGain)}` },
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
]}
/>
{stats.last4Weeks > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,238 @@
import { useEffect, useRef } from "react";
import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet";
import L from "leaflet";
import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css";
/** Marker shown at the position the elevation chart is pointing at. */
function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) {
if (!point) return null;
return (
<CircleMarker
center={[point.lat, point.lng]}
radius={6}
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
/>
);
}
/** Reports the route sample nearest the cursor so the chart can highlight it. */
function HoverTracker({
series,
onHoverIndex,
}: {
series: Array<[number, number]>;
onHoverIndex: (index: number | null) => void;
}) {
useMapEvents({
mousemove(e) {
const { lat, lng } = e.latlng;
let best = -1;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const [slat, slng] = series[i]!;
const delta = (slat - lat) ** 2 + (slng - lng) ** 2;
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
onHoverIndex(best >= 0 ? best : null);
},
mouseout() {
onHoverIndex(null);
},
});
return null;
}
/** Pans the map when the chart is clicked (centerOn.v bumps per click). */
function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) {
const map = useMap();
const lastV = useRef<number | null>(null);
useEffect(() => {
if (!centerOn || centerOn.v === lastV.current) return;
lastV.current = centerOn.v;
map.panTo([centerOn.lat, centerOn.lng], { animate: true });
}, [centerOn, map]);
return null;
}
function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap();
const fitted = useRef(false);
useEffect(() => {
if (fitted.current) return;
const layer = L.geoJSON(data);
const bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [20, 20] });
fitted.current = true;
}
}, [data, map]);
return null;
}
function FlyToSegment({ segments, highlightedDay, fullData }: {
segments: Array<{ coords: number[][] }>;
highlightedDay: number | null | undefined;
fullData: GeoJsonObject;
}) {
const map = useMap();
const fullBoundsRef = useRef<L.LatLngBounds | null>(null);
useEffect(() => {
// Cache full route bounds on first render
if (!fullBoundsRef.current) {
const layer = L.geoJSON(fullData);
fullBoundsRef.current = layer.getBounds();
}
if (highlightedDay != null && highlightedDay >= 1 && highlightedDay <= segments.length) {
const seg = segments[highlightedDay - 1]!;
// coords are [lon, lat] GeoJSON format
const latlngs = seg.coords.map((c) => L.latLng(c[1]!, c[0]!));
const bounds = L.latLngBounds(latlngs);
if (bounds.isValid()) {
map.flyToBounds(bounds, { padding: [40, 40], duration: 0.2 });
}
} else if (fullBoundsRef.current?.isValid()) {
map.flyToBounds(fullBoundsRef.current, { padding: [20, 20], duration: 0.2 });
}
}, [highlightedDay, segments, fullData, map]);
return null;
}
const DAY_COLORS = ["#2563eb", "#8B6D3A", "#059669", "#9333ea", "#dc2626", "#0891b2"];
function extractGeometry(data: GeoJsonObject): number[][] | null {
const coords = (data as { type: string; coordinates?: number[][] }).coordinates
?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates);
return coords && coords.length >= 2 ? coords : null;
}
function splitGeometry(data: GeoJsonObject, numDays: number): Array<{ coords: number[][] }> {
const geometry = extractGeometry(data);
if (!geometry) return [];
const totalPoints = geometry.length;
const pointsPerDay = Math.ceil(totalPoints / numDays);
const segments: Array<{ coords: number[][] }> = [];
for (let d = 0; d < numDays; d++) {
const start = d * pointsPerDay;
const end = Math.min((d + 1) * pointsPerDay + 1, totalPoints);
if (start >= totalPoints) break;
segments.push({ coords: geometry.slice(start, end) });
}
return segments;
}
interface RouteMapProps {
geojson: string;
interactive?: boolean;
className?: string;
dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null;
/** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */
activePoint?: { lat: number; lng: number } | null;
hoverSeries?: Array<[number, number]>;
onHoverIndex?: (index: number | null) => void;
centerOn?: { lat: number; lng: number; v: number } | null;
}
export function RouteMapThumbnail({
geojson,
interactive,
className,
dayBreaks,
highlightedDay,
activePoint,
hoverSeries,
onHoverIndex,
centerOn,
}: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
<MapContainer
center={[50, 10]}
zoom={6}
// `isolate` gives the Leaflet container its own stacking context so its
// internal high z-indexes (panes ~200700, zoom controls ~1000) stay
// contained and can't paint over page overlays like the mobile nav
// drawer's backdrop.
className={`${className ?? "h-36 w-full rounded"} isolate`}
zoomControl={interactive ?? false}
attributionControl={interactive ?? false}
dragging={interactive ?? false}
scrollWheelZoom={interactive ?? false}
doubleClickZoom={interactive ?? false}
touchZoom={interactive ?? false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution={interactive ? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
/>
{dayBreaks && dayBreaks.length > 0 ? (
<DayColoredRoute data={data} dayBreaks={dayBreaks} highlightedDay={highlightedDay} />
) : (
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
)}
<FitBounds data={data} />
{dayBreaks && dayBreaks.length > 0 && (
<FlyToSegment
segments={splitGeometry(data, dayBreaks.length + 1)}
highlightedDay={highlightedDay}
fullData={data}
/>
)}
<ActiveMarker point={activePoint} />
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
)}
<Recenter centerOn={centerOn} />
</MapContainer>
);
}
function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) {
const geometry = extractGeometry(data);
if (!geometry) {
return <GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />;
}
const numDays = dayBreaks.length + 1;
const rawSegments = splitGeometry(data, numDays);
const segments = rawSegments.map((seg, i) => ({
...seg,
color: DAY_COLORS[i % DAY_COLORS.length]!,
}));
const isHighlighting = highlightedDay != null;
return (
<>
{segments.map((seg, i) => {
const dayNum = i + 1;
const isActive = highlightedDay === dayNum;
const segData: GeoJsonObject = {
type: "Feature",
geometry: { type: "LineString", coordinates: seg.coords },
properties: {},
} as GeoJsonObject;
return (
<GeoJSON
key={`${i}-${highlightedDay}`}
data={segData}
style={{
color: seg.color,
weight: isHighlighting ? (isActive ? 5 : 2) : 3,
opacity: isHighlighting ? (isActive ? 1 : 0.3) : 0.8,
}}
/>
);
})}
</>
);
}

View file

@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import type { SportType } from "@trails-cool/db/schema/journal";
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
// label carries the meaning; the glyph is decorative (aria-hidden).
const SPORT_EMOJI: Record<SportType, string> = {
hike: "🥾",
walk: "🚶",
run: "🏃",
ride: "🚴",
gravel: "🚲",
mtb: "🚵",
ski: "⛷️",
other: "📍",
};
/**
* Small pill (glyph + localized label) shown next to an activity title on the
* detail page, feed cards, and the profile list. Renders nothing when the
* sport type is unset.
*/
export function SportBadge({
sportType,
className,
}: {
sportType: SportType | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!sportType) return null;
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
>
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
{t(`activities.sport.${sportType}`)}
</span>
);
}

View file

@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { StatRow } from "./StatRow.tsx";
afterEach(cleanup);
describe("StatRow", () => {
it("renders nothing for an empty item list", () => {
const { container } = render(<StatRow items={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders each item's value and label, in order", () => {
const { container } = render(
<StatRow
items={[
{ label: "Distance", value: "30.0 km" },
{ label: "Time", value: "1h 0m" },
{ label: "Avg speed", value: "30.0 km/h" },
]}
/>,
);
const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent);
const values = [...container.querySelectorAll("dd")].map((el) => el.textContent);
expect(labels).toEqual(["Distance", "Time", "Avg speed"]);
expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]);
});
});

View file

@ -0,0 +1,34 @@
import type { StatItem } from "~/lib/stats";
/**
* The one shared headline-stat presentation. Surfaces pass an ordered list of
* {value, label} items (built via `activityStatItems`); the component never
* fetches or formats. `size="lg"` is the detail-page treatment; the default
* compact size is for feed cards and the profile list.
*/
export function StatRow({
items,
size = "sm",
className,
}: {
items: StatItem[];
size?: "sm" | "lg";
className?: string;
}) {
if (items.length === 0) return null;
const valueCls =
size === "lg"
? "text-2xl font-bold text-gray-900"
: "text-sm font-semibold text-gray-900";
const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1";
return (
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
{items.map((it) => (
<div key={it.label}>
<dd className={valueCls}>{it.value}</dd>
<dt className="text-xs text-gray-500">{it.label}</dt>
</div>
))}
</dl>
);
}

View file

@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
afterEach(cleanup);
describe("SurfaceBreakdown", () => {
it("renders nothing without a breakdown", () => {
const { container } = render(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 2 surface + 1 waytype = 3 legend entries
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
});
it("sorts segments largest-first within a dimension", () => {
const { container } = render(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// first bar's first segment is the larger (asphalt 70%)
const firstBar = container.querySelector("div.flex.h-3");
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
expect(widths[0]).toBe("70%");
expect(widths[1]).toBe("30%");
});
});

View file

@ -0,0 +1,99 @@
import { useTranslation } from "react-i18next";
import {
SURFACE_COLORS,
DEFAULT_SURFACE_COLOR,
HIGHWAY_COLORS,
DEFAULT_HIGHWAY_COLOR,
} from "@trails-cool/map-core";
import { formatDistanceKm } from "~/lib/stats";
export interface SurfaceBreakdownData {
surface: Record<string, number>;
highway: Record<string, number>;
}
function Bar({
title,
data,
colorFor,
}: {
title: string;
data: Record<string, number>;
colorFor: (category: string) => string;
}) {
const { t } = useTranslation("journal");
const entries = Object.entries(data)
.filter(([, m]) => m > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((s, [, m]) => s + m, 0);
if (total <= 0) return null;
const label = (cat: string) =>
cat === "unknown"
? t("surface.other")
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
return (
<div className="mt-3 first:mt-0">
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
<div className="flex h-3 w-full overflow-hidden rounded">
{entries.map(([cat, m]) => (
<div
key={cat}
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
title={`${label(cat)} · ${formatDistanceKm(m)}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{entries.map(([cat, m]) => (
<li key={cat} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorFor(cat) }}
aria-hidden
/>
<span>{label(cat)}</span>
<span className="tabular-nums text-gray-400">
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
* when there's no breakdown data. Colours come from the shared map-core
* palettes; unknown tags collapse into "other".
*/
export function SurfaceBreakdown({
breakdown,
className,
}: {
breakdown: SurfaceBreakdownData | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!breakdown) return null;
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
if (!hasSurface && !hasHighway) return null;
return (
<div className={className}>
<Bar
title={t("surface.surface")}
data={breakdown.surface}
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
/>
<Bar
title={t("surface.waytype")}
data={breakdown.highway}
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
/>
</div>
);
}

View file

@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
afterEach(cleanup);
const weeks = (distances: number[]) =>
distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance }));
describe("WeeklyDistanceChart", () => {
it("renders nothing when every week is zero", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
expect(container.firstChild).toBeNull();
});
it("renders the chart with a bar only for non-zero weeks", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
expect(container.querySelector("svg")).not.toBeNull();
// bars only for the two non-zero weeks; empty weeks keep their slot via the track rect
expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2);
});
it("tops the y-scale at the busiest week", () => {
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
// peak gridline label = 10 km (>= 10 → integer)
expect(getByText("10 km")).toBeTruthy();
// half gridline
expect(getByText("5.0 km")).toBeTruthy();
});
it("shows a hover readout with the week's distance", () => {
// max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the
// readout for week 0, so it only appears once that week is hovered.
const { container, queryByText } = render(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
expect(queryByText("3.0 km")).toBeNull();
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
fireEvent.mouseEnter(hit);
expect(queryByText("3.0 km")).not.toBeNull();
});
});

View file

@ -0,0 +1,136 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDistanceKm } from "~/lib/stats";
export interface WeeklyDistanceBucket {
weekStart: string;
distance: number;
}
// SVG layout (user units; rendered responsive via viewBox).
const W = 480;
const H = 132;
const PAD = { top: 10, right: 6, bottom: 18, left: 36 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
const BASE_Y = PAD.top + PLOT_H;
function axisLabel(km: number): string {
if (km <= 0) return "0";
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
}
/**
* Weekly-distance bar chart for the profile (last N weeks, oldest newest).
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
* and a hover readout naming the week + its distance. Hidden when there is no
* distance in the window.
*/
export function WeeklyDistanceChart({
weeks,
className,
}: {
weeks: WeeklyDistanceBucket[];
className?: string;
}) {
const { t, i18n } = useTranslation("journal");
const [hover, setHover] = useState<number | null>(null);
const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
if (maxM <= 0) return null;
const maxKm = maxM / 1000;
const n = weeks.length;
const colW = PLOT_W / n;
const barW = colW * 0.6;
const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2;
const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H;
const fmtWeek = (iso: string) =>
new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
const gridFracs = [0, 0.5, 1];
const active = hover != null ? weeks[hover] : null;
const first = weeks[0];
const last = weeks[n - 1];
const firstLabel = first ? fmtWeek(first.weekStart) : "";
const lastLabel = last ? fmtWeek(last.weekStart) : "";
return (
<div className={className}>
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
<span>{t("profileStats.weeklyDistance")}</span>
{active && (
<span className="tabular-nums text-gray-700">
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
</span>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-28 w-full"
role="img"
aria-label={t("profileStats.weeklyDistance")}
onMouseLeave={() => setHover(null)}
>
{/* gridlines + y-axis labels (0 · half · peak) */}
{gridFracs.map((f) => {
const yy = BASE_Y - f * PLOT_H;
return (
<g key={f}>
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
{axisLabel(maxKm * f)}
</text>
</g>
);
})}
{weeks.map((w, i) => {
const isActive = hover === i;
return (
<g key={w.weekStart}>
{/* faint week-slot track so empty weeks stay visible */}
<rect
x={x(i)}
y={PAD.top}
width={barW}
height={PLOT_H}
rx="1.5"
fill={isActive ? "#dbeafe" : "#f3f4f6"}
/>
{/* distance bar */}
{w.distance > 0 && (
<rect
data-week-bar
x={x(i)}
y={yFor(w.distance)}
width={barW}
height={BASE_Y - yFor(w.distance)}
rx="1.5"
fill={isActive ? "#1d4ed8" : "#3b82f6"}
/>
)}
{/* full-height hover hit area */}
<rect
x={PAD.left + i * colW}
y={PAD.top}
width={colW}
height={PLOT_H}
fill="transparent"
onMouseEnter={() => setHover(i)}
>
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
</rect>
</g>
);
})}
</svg>
<div className="flex justify-between px-px text-[10px] text-gray-400">
<span>{firstLabel}</span>
<span>{lastLabel}</span>
</div>
</div>
);
}

View file

@ -1,31 +1,9 @@
import * as Sentry from "@sentry/react";
import { useEffect } from "react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { HydratedRouter } from "react-router/dom";
import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router";
import { initI18nClient } from "@trails-cool/i18n";
const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ??
(import.meta.env.PROD ? "production" : "development");
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
integrations: [
Sentry.reactRouterV7BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
Sentry.replayIntegration(),
],
environment: sentryEnvironment,
tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0,
replaysOnErrorSampleRate: 1.0,
enabled: import.meta.env.PROD && sentryEnvironment !== "ci",
});
initI18nClient();
startTransition(() => {
hydrateRoot(

View file

@ -6,22 +6,7 @@ import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
Sentry.init({
dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728",
release: process.env.SENTRY_RELEASE,
environment: sentryEnvironment,
tracesSampleRate: 1.0,
enabled: process.env.NODE_ENV === "production" && !process.env.CI,
beforeSend(event) {
// Drop 404s — they're expected (scanners, typos), not bugs
const serialized = event.extra?.__serialized__ as Record<string, unknown> | undefined;
if (serialized?.status === 404) return null;
return event;
},
});
import { initI18nServer, detectLanguage } from "@trails-cool/i18n";
export const streamTimeout = 5_000;
@ -32,6 +17,8 @@ export default function handleRequest(
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
initI18nServer(detectLanguage(request));
if (request.method.toUpperCase() === "HEAD") {
return new Response(null, {
status: responseStatusCode,

View file

@ -0,0 +1,38 @@
import { useEffect } from "react";
import { useRevalidator } from "react-router";
/**
* Live-updates a route/activity detail page when its async surface backfill
* completes. Subscribes to `/api/events` and, on a `surface_breakdown` event
* matching this row, re-runs the loader (so the bars appear without a reload).
*
* Enable only while it's worth it i.e. the viewer is the owner (the backfill
* emits to the owner's user stream) and the breakdown isn't present yet. Once
* the breakdown lands, the loader revalidates, `enabled` flips false, and the
* connection is torn down.
*/
export function useSurfaceBackfillUpdates(
kind: "route" | "activity",
id: string,
enabled: boolean,
): void {
const revalidator = useRevalidator();
useEffect(() => {
if (!enabled) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onEvent = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { kind?: string; id?: string };
if (parsed.kind === kind && parsed.id === id) revalidator.revalidate();
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("surface_breakdown", onEvent as EventListener);
return () => {
es.removeEventListener("surface_breakdown", onEvent as EventListener);
es.close();
};
}, [kind, id, enabled, revalidator]);
}

View file

@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
/**
* Subscribes to /api/events for live `notifications.unread` updates.
* Returns the live unread count, seeded with the loader-provided
* baseline so the badge renders correctly before the SSE handshake
* completes. Native EventSource handles reconnects with the
* server-suggested `retry:` interval.
*/
export function useUnreadNotifications(initialCount: number, signedIn: boolean): number {
const [count, setCount] = useState(initialCount);
// Keep the in-component count in sync if the loader-provided baseline
// changes (e.g., on navigation).
useEffect(() => {
setCount(initialCount);
}, [initialCount]);
useEffect(() => {
if (!signedIn) return;
if (typeof EventSource === "undefined") return;
const es = new EventSource("/api/events");
const onUnread = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as { count: number };
if (typeof parsed.count === "number") setCount(parsed.count);
} catch {
// Malformed payload — ignore.
}
};
es.addEventListener("notifications.unread", onUnread as EventListener);
return () => {
es.removeEventListener("notifications.unread", onUnread as EventListener);
es.close();
};
}, [signedIn]);
return count;
}

View file

@ -0,0 +1,26 @@
import { defineJournalJob } from "./payloads.ts";
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* One-shot backfill: generate a federation keypair for every user who
* predates federation (spec: "Existing-user backfill at deploy"). The
* server enqueues this once at startup whenever FEDERATION_ENABLED is
* on (singleton-keyed, so repeat startups don't stack runs); each run
* only touches users whose public_key IS NULL, so re-runs are no-ops.
* New users get keys at registration and never appear in this workload.
*/
export const backfillUserKeypairsJob = defineJournalJob({
name: "backfill-user-keypairs",
retryLimit: 3,
expireInSeconds: 300,
async handler() {
const ids = await listUsersWithoutKeypair();
let generated = 0;
for (const id of ids) {
if (await ensureUserKeypair(id)) generated++;
}
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
return { candidates: ids.length, generated };
},
});

View file

@ -0,0 +1,31 @@
import { defineJournalJob } from "./payloads.ts";
import { lt } from "drizzle-orm";
import { consumedJwtJti } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup for the JWT replay-protection table. Each row was
* inserted by `verifyRouteToken` to mark a token as consumed; once
* the token's `exp` claim has passed, the row is no longer useful
* (the JWT itself would fail signature verification before reaching
* the consume step). Bound the table to keep it tiny.
*
* See planner-audit #2 Phase B.
*/
export const consumedJtiSweepJob = defineJournalJob({
name: "consumed-jti-sweep",
cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const db = getDb();
const result = await db
.delete(consumedJwtJti)
.where(lt(consumedJwtJti.expiresAt, new Date()))
.returning({ jti: consumedJwtJti.jti });
const purged = result.length;
logger.info({ purged }, "consumed-jti-sweep");
return { purged };
},
});

View file

@ -0,0 +1,134 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq } from "drizzle-orm";
import { activities, users } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { getOrigin } from "../lib/config.server.ts";
import { getFederation } from "../lib/federation.server.ts";
import {
activityToCreate,
activityToDelete,
type FederatableActivity,
} from "../lib/federation-objects.server.ts";
import {
getCachedRemoteActor,
upsertRemoteActor,
type DeliveryPayload,
} from "../lib/federation-delivery.server.ts";
import { logger } from "../lib/logger.server.ts";
import { federationDeliveryTotal } from "../lib/metrics.server.ts";
/**
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
* host. In-process map is sufficient pg-boss works this queue
* sequentially in the single journal process.
*/
const lastSendPerHost = new Map<string, number>();
const MIN_INTERVAL_MS = 1000;
async function paceHost(host: string): Promise<void> {
const last = lastSendPerHost.get(host) ?? 0;
const wait = last + MIN_INTERVAL_MS - Date.now();
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastSendPerHost.set(host, Date.now());
}
/**
* Deliver one activity to one remote follower's inbox (spec 5.3/5.4).
* One job per (activity, recipient) so each delivery retries with
* exponential backoff independently (configured at enqueue time
* see enqueueActivityDeliveries). A thrown error marks the attempt
* failed and pg-boss retries; exhausting the budget is the permanent
* failure, logged by the final catch.
*/
export const deliverActivityJob = defineJournalJob({
name: "deliver-activity",
expireInSeconds: 60,
async handler(jobs) {
for (const job of jobs) {
const p = job.data;
try {
const outcome = await deliverOne(p);
federationDeliveryTotal.inc({ outcome });
} catch (err) {
federationDeliveryTotal.inc({ outcome: "failed" });
logger.warn(
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
);
throw err;
}
}
},
});
async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> {
const federation = getFederation();
const ctx = federation.createContext(new URL(getOrigin()), undefined);
// Build the activity to send. For `create`, re-read the row at
// delivery time: if it was deleted or un-publicized since enqueue,
// skip rather than leak.
let activity;
if (p.action === "create") {
const db = getDb();
const [row] = await db
.select()
.from(activities)
.where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public")))
.limit(1);
if (!row) {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
return "skipped";
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
const [owner] = await db
.select({ profileVisibility: users.profileVisibility })
.from(users)
.where(eq(users.username, p.ownerUsername))
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
return "skipped";
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
activity = activityToDelete(p.objectIri, p.ownerUsername);
}
// Resolve the recipient's inbox: cached remote_actors row first,
// actor-document fetch as fallback (which also primes the cache).
const recipientIri = new URL(p.recipientActorIri);
let inboxUrl: URL;
const cached = await getCachedRemoteActor(p.recipientActorIri);
if (cached?.inboxUrl) {
inboxUrl = new URL(cached.inboxUrl);
} else {
await paceHost(recipientIri.host);
const actor = await ctx.lookupObject(recipientIri);
const fetchedInbox =
actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null;
if (!fetchedInbox) {
throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`);
}
inboxUrl = fetchedInbox;
await upsertRemoteActor({
actorIri: p.recipientActorIri,
inboxUrl: inboxUrl.href,
domain: recipientIri.host,
});
}
await paceHost(inboxUrl.host);
await ctx.sendActivity(
// Identifier and username are the same thing in our actor model.
{ identifier: p.ownerUsername },
{ id: recipientIri, inboxId: inboxUrl },
activity,
);
logger.info(
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
"deliver-activity: delivered",
);
return "delivered";
}

View file

@ -0,0 +1,60 @@
import { defineJournalJob } from "./payloads.ts";
import {
DEMO_BACKFILL_TARGET,
DEMO_DAILY_CAP,
countSyntheticRoutesRecent,
countSyntheticRoutesTotal,
ensureDemoUser,
generateOneWalk,
isDemoBotEnabled,
refreshDemoBotGauges,
shouldWalkNow,
} from "../lib/demo-bot.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Generate job. Fires every 30 minutes via pg-boss cron. Handler steps:
*
* 1. Bail if `DEMO_BOT_ENABLED` is not "true".
* 2. On first run (no synthetic rows yet) produce a small backfill so the
* demo user's profile has content immediately.
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
*/
export const demoBotGenerateJob = defineJournalJob({
name: "demo-bot-generate",
cron: "0,30 * * * *",
retryLimit: 1,
expireInSeconds: 120,
async handler() {
if (!isDemoBotEnabled()) return { skipped: "disabled" };
const ownerId = await ensureDemoUser();
const total = await countSyntheticRoutesTotal();
if (total === 0) {
let inserted = 0;
for (let i = 0; i < DEMO_BACKFILL_TARGET; i++) {
const id = await generateOneWalk(ownerId);
if (id) inserted++;
}
logger.info({ inserted }, "demo-bot backfill complete");
await refreshDemoBotGauges();
return { mode: "backfill", inserted };
}
const recent = await countSyntheticRoutesRecent(14);
if (recent >= DEMO_DAILY_CAP) {
logger.info({ recent, cap: DEMO_DAILY_CAP }, "demo-bot cap hit, skipping");
return { skipped: "cap", recent };
}
const now = new Date();
if (!shouldWalkNow(now)) return { skipped: "gate" };
const id = await generateOneWalk(ownerId, { now });
logger.info({ inserted: id ? 1 : 0, routeId: id }, "demo-bot walk");
await refreshDemoBotGauges();
return { mode: "single", routeId: id };
},
});

View file

@ -0,0 +1,27 @@
import { defineJournalJob } from "./payloads.ts";
import {
demoRetentionDays,
isDemoBotEnabled,
pruneSynthetic,
refreshDemoBotGauges,
} from "../lib/demo-bot.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily prune. Deletes synthetic rows older than
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
*/
export const demoBotPruneJob = defineJournalJob({
name: "demo-bot-prune",
cron: "15 3 * * *",
retryLimit: 1,
expireInSeconds: 60,
async handler() {
if (!isDemoBotEnabled()) return { skipped: "disabled" };
const days = demoRetentionDays();
const counts = await pruneSynthetic(days);
logger.info({ days, ...counts }, "demo-bot prune");
await refreshDemoBotGauges();
return { days, ...counts };
},
});

View file

@ -0,0 +1,22 @@
import { defineJournalJob } from "./payloads.ts";
import { sweepProcessedActivities } from "../lib/federation-replay.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of federation_processed_activities rows older than 30
* days (spec: federation-operations "Inbound replay defense"). Replays of
* activities that old are already rejected by HTTP-signature date
* freshness, so the dedup record is no longer needed; this keeps the
* table bounded.
*/
export const federationDedupSweepJob = defineJournalJob({
name: "federation-dedup-sweep",
cron: "30 4 * * *", // daily at 04:30 UTC (offset from federation-kv-sweep at 04:15)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await sweepProcessedActivities();
logger.info({ purged }, "federation-dedup-sweep");
return { purged };
},
});

View file

@ -0,0 +1,20 @@
import { defineJournalJob } from "./payloads.ts";
import { PostgresKvStore } from "../lib/federation-kv.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily cleanup of expired federation_kv rows (Fedify replay-protection
* nonces and caches carry TTLs; reads already filter expired rows, this
* keeps the table from growing unbounded).
*/
export const federationKvSweepJob = defineJournalJob({
name: "federation-kv-sweep",
cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const purged = await new PostgresKvStore().sweepExpired();
logger.info({ purged }, "federation-kv-sweep");
return { purged };
},
});

View file

@ -0,0 +1,30 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts";
// Garmin webhook notifications enqueue here (spec: garmin-import,
// "Push-notification activity import"): the webhook answers 200
// immediately and this job does the slow part — authorized file
// download, FIT→GPX, activity creation. Backfill bursts deliver many
// notifications at once; the queue absorbs them and pg-boss retries
// transient download failures.
export const garminImportActivityJob = defineJournalJob({
name: "garmin-import-activity",
retryLimit: 3,
expireInSeconds: 300,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const data = job.data;
try {
await runGarminActivityImport(data);
} catch (err) {
logger.warn(
{ err, externalId: data.externalId },
"garmin-import-activity failed (pg-boss will retry)",
);
throw err;
}
}
},
});

View file

@ -0,0 +1,42 @@
import { defineJournalJob } from "./payloads.ts";
import { and, lt, inArray, count } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
import { logger } from "../lib/logger.server.ts";
const STALE_MS = 10 * 60 * 1000;
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
export const importBatchesSweepJob = defineJournalJob({
name: "import-batches-sweep",
cron: "* * * * *",
retryLimit: 0,
expireInSeconds: 55,
async handler() {
const db = getDb();
const cutoff = new Date(Date.now() - STALE_MS);
const staleFilter = and(
inArray(importBatches.status, STALE_STATUSES),
lt(importBatches.startedAt, cutoff),
);
// Skip the write when nothing is stale to avoid an unconditional UPDATE every minute.
const rows = await db
.select({ staleCount: count() })
.from(importBatches)
.where(staleFilter);
if ((rows[0]?.staleCount ?? 0) === 0) return;
const result = await db
.update(importBatches)
.set({
status: "failed" satisfies ImportBatchStatus,
errorMessage: "Import timed out — the server may have restarted mid-import. Click 'Run again' to retry.",
completedAt: new Date(),
})
.where(staleFilter)
.returning({ id: importBatches.id });
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
},
});

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const withFreshCredentials = vi.fn();
vi.mock("../lib/connected-services/manager.ts", () => ({
withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args),
}));
const runKomootBulkImport = vi.fn();
const markBatchFailed = vi.fn();
vi.mock("../lib/komoot-bulk-import.server.ts", () => ({
runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args),
markBatchFailed: (...args: unknown[]) => markBatchFailed(...args),
}));
import { komootBulkImportJob } from "./komoot-bulk-import.ts";
type HandlerJobs = Parameters<typeof komootBulkImportJob.handler>[0];
function jobWith(data: unknown): HandlerJobs {
return [{ id: "j1", data }] as unknown as HandlerJobs;
}
const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" };
describe("komoot-bulk-import job", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("resolves credentials through the manager — only the serviceId crosses the queue", async () => {
const creds = { mode: "public", komootUserId: "k1" };
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds));
runKomootBulkImport.mockResolvedValue(undefined);
await komootBulkImportJob.handler(jobWith(PAYLOAD));
expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function));
expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds);
expect(markBatchFailed).not.toHaveBeenCalled();
});
it("marks the batch failed and rethrows when credential resolution fails", async () => {
withFreshCredentials.mockRejectedValue(new Error("needs relink"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink");
expect(runKomootBulkImport).not.toHaveBeenCalled();
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink");
});
it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => {
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" }));
runKomootBulkImport.mockRejectedValue(new Error("komoot 500"));
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500");
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500");
});
});

View file

@ -0,0 +1,36 @@
import { defineJournalJob } from "./payloads.ts";
import { logger } from "../lib/logger.server.ts";
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
import {
markBatchFailed,
runKomootBulkImport,
type KomootCreds,
} from "../lib/komoot-bulk-import.server.ts";
export const komootBulkImportJob = defineJournalJob({
name: "komoot-bulk-import",
retryLimit: 1,
expireInSeconds: 1800,
async handler(jobs) {
const batch = Array.isArray(jobs) ? jobs : [jobs];
for (const job of batch) {
const { batchId, userId, serviceId } = job.data;
logger.info({ batchId, userId }, "komoot bulk import job started");
try {
// Credentials are resolved through the ConnectedServiceManager at
// execution time — the payload carries only the serviceId, so
// nothing credential-shaped sits in the job table and a relink
// between enqueue and execution is picked up here.
await withFreshCredentials(serviceId, (creds) =>
runKomootBulkImport(batchId, userId, creds as KomootCreds),
);
} catch (err) {
// runKomootBulkImport marks its own failures; this covers errors
// before it ran (service missing/not active/needs relink), where
// the batch would otherwise stay "pending" forever.
await markBatchFailed(batchId, err instanceof Error ? err.message : String(err));
throw err;
}
}
},
});

View file

@ -0,0 +1,112 @@
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq, sql } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { fanout } from "./notifications-fanout.ts";
import { listForUser } from "../lib/notifications.server.ts";
// Same opt-in flag as the rest of the notifications integration tests.
const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1";
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${opts.username}@example.test`,
username: opts.username,
domain: "test.local",
profileVisibility: opts.profileVisibility ?? "public",
});
return id;
}
async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) {
const db = getDb();
const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u;
await db.insert(follows).values({
id: randomUUID(),
followerId,
followedActorIri: `https://test.local/users/${followedUsername}`,
followedUserId: followedId,
acceptedAt: opts.accepted ? new Date() : null,
});
}
async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") {
const db = getDb();
const id = randomUUID();
await db.insert(activities).values({
id,
ownerId,
name,
visibility,
});
return id;
}
async function wipe() {
const db = getDb();
await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
}
describe.skipIf(!runIntegration)("notifications-fanout integration", () => {
beforeAll(async () => {
const db = getDb();
await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`);
});
afterEach(wipe);
it("inserts exactly one row per accepted follower; pending followers are skipped", async () => {
const owner = await makeUser({ username: `nf_o_${Date.now()}` });
const a1 = await makeUser({ username: `nf_a1_${Date.now()}` });
const a2 = await makeUser({ username: `nf_a2_${Date.now()}` });
const p1 = await makeUser({ username: `nf_p1_${Date.now()}` });
const p2 = await makeUser({ username: `nf_p2_${Date.now()}` });
await makeFollow(a1, owner, { accepted: true });
await makeFollow(a2, owner, { accepted: true });
await makeFollow(p1, owner, { accepted: false });
await makeFollow(p2, owner, { accepted: false });
const activityId = await makeActivity(owner, "public", "Public Walk");
await fanout(activityId);
expect((await listForUser(a1)).rows.length).toBe(1);
expect((await listForUser(a2)).rows.length).toBe(1);
expect((await listForUser(p1)).rows.length).toBe(0);
expect((await listForUser(p2)).rows.length).toBe(0);
const a1Rows = (await listForUser(a1)).rows;
expect(a1Rows[0]?.type).toBe("activity_published");
expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk");
});
it("skips fan-out for non-public activities (defense in depth)", async () => {
const owner = await makeUser({ username: `nf_np_o_${Date.now()}` });
const f = await makeUser({ username: `nf_np_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const privateAct = await makeActivity(owner, "private");
const unlistedAct = await makeActivity(owner, "unlisted");
await fanout(privateAct);
await fanout(unlistedAct);
expect((await listForUser(f)).rows.length).toBe(0);
});
it("is idempotent under retry — second fanout doesn't double-insert", async () => {
const owner = await makeUser({ username: `nf_id_o_${Date.now()}` });
const f = await makeUser({ username: `nf_id_f_${Date.now()}` });
await makeFollow(f, owner, { accepted: true });
const activityId = await makeActivity(owner, "public");
await fanout(activityId);
await fanout(activityId);
expect((await listForUser(f)).rows.length).toBe(1);
});
});

View file

@ -0,0 +1,99 @@
import { defineJournalJob } from "./payloads.ts";
import { and, eq, isNotNull } from "drizzle-orm";
import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { createNotification } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Fan out an `activity_published` notification to every accepted
* follower of the activity's owner. Idempotent at the DB level via the
* `(recipient_user_id, type, subject_id)` unique partial index a
* retry after partial failure won't double-insert.
*/
export const notificationsFanoutJob = defineJournalJob({
name: "notifications-fanout",
retryLimit: 3,
expireInSeconds: 300,
async handler(job) {
// pg-boss v12: `job` may be an array (batch) per its docs; we
// process whichever shape we get.
const batch = Array.isArray(job) ? job : [job];
for (const item of batch) {
await fanout(item.data.activityId);
}
},
});
export async function fanout(activityId: string): Promise<void> {
const db = getDb();
// Load the activity + owner info needed for the payload snapshot.
const [row] = await db
.select({
id: activities.id,
name: activities.name,
visibility: activities.visibility,
ownerId: activities.ownerId,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(users, eq(activities.ownerId, users.id))
.where(eq(activities.id, activityId));
if (!row) {
logger.warn({ activityId }, "fanout: activity not found, skipping");
return;
}
// Remote-ingested rows have no local owner and never fan out locally
// (the users innerJoin already excludes them; this narrows the type).
if (row.ownerId === null) return;
// Defense in depth — the create-side guard already filters this, but
// recheck here so the job doesn't mistakenly fan out a row that was
// later flipped to private/unlisted before the job ran.
if (row.visibility !== "public") {
logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity");
return;
}
// Find every accepted *local* follower of the owner. Remote
// followers (follower_id NULL, follower_actor_iri set) get the
// activity via federation push delivery, not via notifications.
const recipients = await db
.select({ followerId: follows.followerId })
.from(follows)
.where(
and(
eq(follows.followedUserId, row.ownerId),
isNotNull(follows.acceptedAt),
isNotNull(follows.followerId),
),
);
let inserted = 0;
for (const r of recipients) {
// Don't notify the owner about their own activity if they happen
// to follow themselves (shouldn't happen — followUser refuses
// self-follow — but defense in depth).
if (r.followerId === row.ownerId || r.followerId === null) continue;
const created = await createNotification({
type: "activity_published",
recipientUserId: r.followerId,
actorUserId: row.ownerId,
subjectId: row.id,
payload: {
activityId: row.id,
activityName: row.name,
ownerUsername: row.ownerUsername,
ownerDisplayName: row.ownerDisplayName,
},
});
if (created) inserted += 1;
}
logger.info(
{ activityId, recipients: recipients.length, inserted },
"notifications-fanout completed",
);
}

View file

@ -0,0 +1,21 @@
import { defineJournalJob } from "./payloads.ts";
import { purgeReadOlderThan } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts";
/**
* Daily retention pass. Drops notifications whose `read_at` is older
* than 90 days; unread rows are kept indefinitely so users never miss
* an event.
*/
export const notificationsPurgeJob = defineJournalJob({
name: "notifications-purge",
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
retryLimit: 1,
expireInSeconds: 60,
async handler() {
const days = 90;
const purged = await purgeReadOlderThan(days);
logger.info({ days, purged }, "notifications-purge");
return { days, purged };
},
});

View file

@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
// Importing every job module pulls in their (transitively heavy)
// server deps; mock the leaf modules with side effects so this stays a
// unit test of the registry wiring.
import { vi } from "vitest";
vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() }));
vi.mock("../lib/logger.server.ts", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
describe("job registry", () => {
it("every job's queue name is unique and a key of JobPayloads", async () => {
const modules = await Promise.all([
import("./backfill-user-keypairs.ts"),
import("./consumed-jti-sweep.ts"),
import("./deliver-activity.ts"),
import("./demo-bot-generate.ts"),
import("./demo-bot-prune.ts"),
import("./federation-kv-sweep.ts"),
import("./garmin-import-activity.ts"),
import("./import-batches-sweep.ts"),
import("./komoot-bulk-import.ts"),
import("./notifications-fanout.ts"),
import("./notifications-purge.ts"),
import("./poll-remote-actor.ts"),
import("./poll-remote-outboxes.ts"),
import("./send-welcome-email.ts"),
]);
const names = modules.flatMap((m) =>
Object.values(m as Record<string, unknown>)
.filter(
(v): v is { name: string; handler: unknown } =>
typeof v === "object" && v !== null && "handler" in v && "name" in v,
)
.map((def) => def.name),
);
expect(names).toHaveLength(14);
expect(new Set(names).size).toBe(names.length);
// pg-boss v11+ queue-name constraint, mirrored from packages/jobs
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
});
});

Some files were not shown because too many files have changed in this diff Show more