- 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>
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>
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>
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>
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>
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>
- `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>
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>
- 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>
- 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>
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>
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>
- 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>
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>
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>
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>
- 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>
- 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>
- 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>
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>
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>
- 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>
- Enrich BRouter response with per-point 3D coordinates and segment boundary
tracking (EnrichedRoute interface)
- ColoredRoute component: plain, elevation gradient (green→yellow→red), and
surface color modes with invisible wide polyline for click targeting
- Click-to-split: click on route polyline inserts waypoint at nearest point,
mapped to correct segment via boundary indices
- MidpointHandles: draggable CircleMarkers at route segment midpoints for
reshaping, hidden below zoom 12, opaque on hover
- Color mode toggle (select) synced via Yjs routeData
- i18n keys for color mode labels (en + de)
- Unit tests for segment boundary tracking (13 tests)
- E2E tests for enriched route response and color mode toggle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Transactional emails:
- Add nodemailer SMTP email module with dev-mode console logging
- Magic link template and welcome template with HTML + plain text
- Wire sendMagicLink into login flow, sendWelcome into registration
- Update privacy page and deploy docs for SMTP configuration
Planner features:
- No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs,
passed to BRouter as nogos parameter, route recomputes on change
- Session notes: collaborative Y.Text textarea in sidebar tab
- Crash recovery: periodic localStorage save of Yjs state, restore on reconnect
- Rate limit session creation (10/IP/hour) in /new route
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement the planner-multiplayer-awareness OpenSpec change:
- Add setUserName to use-yjs.ts that persists to localStorage and syncs via awareness
- Create ParticipantList component showing all session participants with color dots,
host badge, and inline name editing for the local user
- Replace plain text cursor labels with SVG pointer arrows and styled name tags
(colored background, drop shadow, rounded corners, z-index below map controls)
- Track awareness add/remove events and display auto-dismissing toasts (3s)
when participants join or leave the session
- Add i18n keys for participant UI in English and German
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Journal gets a nav bar in root.tsx with conditional links: Routes and
Activities for authenticated users, Sign In and Register for guests.
Active route is highlighted via useLocation(). User menu shows username
linking to profile and a logout button.
Planner SessionView header title becomes a home link back to /.
Adds i18n keys (en + de) for all nav labels. Updates E2E tests to use
scoped nav selectors now that links appear in both nav bar and page body.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Set Sentry user context (id, username) in Journal root for all routes
- Tag Planner errors with session_id
- Use reactRouterV7BrowserTracingIntegration for route-aware traces
- Hidden source maps (no sourceMappingURL in bundles, .map deleted after
upload to Sentry)
- Privacy manifest at /privacy documenting all data collection
- robots.txt blocking all bots on both apps
- Suppress i18next promotional console log
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Initialize i18n via initI18n() in both root.tsx files and replace all
hardcoded user-facing strings with useTranslation() calls. Adds missing
translation keys for planner (profiles, connection states, save flow)
and journal (auth pages, route management, passkey prompts).
Both English and German translations are complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Planner: hide title on small screens, wrap header items, hide waypoint
sidebar below md breakpoint (map takes full width).
Journal: stack route stats and action buttons on mobile, responsive
header layout on route detail.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues fixed:
1. GPX parser used browser DOMParser which doesn't exist in Node/Vite SSR.
Added async parseGpxAsync() using linkedom for server-side parsing.
2. Server-side session initialization stored waypoints in a Yjs doc
instance separate from the Vite WebSocket plugin's doc store.
Moved waypoint initialization to client-side: API returns parsed
waypoints, client adds them to Yjs after sync.
3. GPX was encoded in URL params causing HTTP 431. Now the Journal
creates a Planner session via API (POST body), and only passes
compact waypoint coordinates in URL params.
Verified: Journal route → Edit in Planner → 4 waypoints loaded,
route computed (79.3km), elevation profile, Save to Journal ready.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
JWT-based handoff between Journal and Planner:
Journal side:
- JWT token generation scoped to route_id with 7-day expiry (jose)
- "Edit in Planner" button on route detail page — generates JWT,
redirects to Planner /new with callback URL, token, and GPX
- Callback endpoint (POST /api/routes/:id/callback) validates JWT
and creates new route version from received GPX
Planner side:
- /new route accepts callback, token, returnUrl, gpx params
- Creates session with callback metadata, initializes with GPX
- "Save to Journal" button POSTs GPX with Bearer token to callback
- "Return to Journal" link shown after successful save
All 6 Group 9 tasks complete.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Waypoint drag:
- Use observeDeep instead of observe to detect Y.Map property changes
Host election:
- Extracted to host-election.ts with 8 unit tests
- Simple rule: lowest real client ID is always host, deterministic
- Always sets role (host or participant), no missing roles
- Filters server docs (empty awareness state) from election
BRouter routing:
- Route segment-by-segment like bikerouter.de (one request per
waypoint pair), guarantees route passes through every waypoint
- Merge GeoJSON segments with accumulated stats
- 8 unit tests for segment merging and waypoint splitting
Awareness cleanup:
- Track client IDs per WebSocket connection
- Immediately remove awareness on WebSocket close
Debug panel:
- Visibility + expanded state persisted in localStorage
- Hotkey (Shift+Cmd+Option+T) toggles visibility
- Labels server docs as (server)
- All logic self-contained in YjsDebugPanel.tsx
User identity:
- Color and name persisted in localStorage across reloads
Tests: 32 total (16 existing + 8 host election + 8 routing)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Blue sliding progress bar appears at the top of the map while
BRouter is computing a route. Overlays on top of Leaflet map
with z-index 1000.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Waypoint icons:
- Replace broken Leaflet default icons with numbered blue circles
- Numbers match sidebar order (1, 2, 3...)
Elevation chart:
- Mouse hover shows crosshair, elevation, and distance at cursor
- Red dot appears on the map at the corresponding route position
- Dot disappears when mouse leaves the chart
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SSR fix:
- ClientOnly wrapper prevents Leaflet/Yjs from running during SSR
- SessionView lazy-loaded to avoid browser API usage on server
Automerge fix:
- Set MERGE_READY_STATE to 'clean' only (was including 'unstable')
- Prevents merging before all CI checks complete
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>