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>
This commit is contained in:
Ullrich Schäfer 2026-05-24 11:07:03 +02:00
parent a57637868b
commit 47eb2615ec
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
10 changed files with 355 additions and 152 deletions

View file

@ -18,7 +18,7 @@ The Account settings page (`/settings/account`) SHALL include an "Email" section
#### Scenario: Verification link applies the change
- **WHEN** the user follows the verification link (`/auth/verify?email-change=1&token=...`)
- **THEN** the server validates the token (matching purpose, not expired, not used), updates `users.email`, marks the token used, signs the user back in if necessary, and redirects to `/settings/account`
- **THEN** the server validates the token (matching purpose, not expired, not used), updates `users.email`, marks the token used, and redirects to `/settings/account`. The existing session remains valid; no re-authentication is performed.
#### Scenario: Expired verification link
- **WHEN** the verification link is more than 15 minutes old or has already been used

View file

@ -53,7 +53,7 @@ The Planner SHALL load POIs only within the current map viewport, refreshing whe
- **THEN** POIs are fetched for the new viewport after a 500ms debounce
#### Scenario: Zoom threshold
- **WHEN** the map zoom level is below 12
- **WHEN** the map zoom level is below 10 (`MIN_ZOOM` constant in `use-pois.ts`)
- **THEN** POI queries are not sent and a message indicates the user should zoom in to see POIs
#### Scenario: Cached results
@ -82,6 +82,8 @@ The Planner SHALL auto-enable relevant POI categories when the routing profile c
- **WHEN** the routing profile is changed to a hiking variant
- **THEN** "Shelter" and "Viewpoints" POI categories are automatically enabled
#### Scenario: User override persists
#### Scenario: User override persists (not yet implemented)
- **WHEN** a user manually disables an auto-enabled POI category
- **THEN** it remains disabled until the next profile change
Note: the current implementation (`use-profile-defaults.ts`) always merges the profile defaults on every profile change without checking for prior manual overrides. The override-persistence behaviour is not yet implemented.

View file

@ -56,8 +56,9 @@ Each tile overlay SHALL display proper attribution when enabled.
- **WHEN** the overlay is toggled off
- **THEN** its attribution text is removed
### Requirement: Profile-aware overlay suggestions
The Planner SHALL auto-enable relevant tile overlays when the routing profile changes.
### Requirement: Profile-aware overlay suggestions (not yet implemented)
`profileOverlayDefaults` is exported from `@trails-cool/map-core` but is not currently wired in the Planner app. The scenarios below describe the intended behaviour once wired.
#### Scenario: Switch to cycling profile
- **WHEN** the routing profile is changed to a cycling variant

View file

@ -1,13 +1,238 @@
## Purpose
Round-trip GPX exchange between Planner and Journal, including JWT-scoped callbacks for saving routes and GPX reimport in the Planner.
Round-trip GPX exchange between Planner and Journal so a Journal user can open a saved route in the Planner, edit it collaboratively, and save it back as a new version — without the Planner ever holding user credentials.
## Actors
- **Journal** — authenticated web app; owns routes, issues tokens, hosts the callback endpoint.
- **Planner** — stateless collaborative editor; receives initial route data; posts the result back.
- **User's browser** — navigates between the two apps via a redirect URL.
---
## Flow overview
```
Journal (POST /api/routes/:id/edit-in-planner)
→ Planner (POST /api/sessions) [server-to-server]
← { sessionId, url, initialWaypoints, initialNoGoAreas, initialNotes }
→ Browser redirect to /session/:id?waypoints=…&notes=…&returnUrl=…
→ User edits route
→ SaveToJournalButton (POST callbackUrl, Bearer token, { gpx }) [browser-to-Journal]
← { success: true }
→ "Return to Journal" link shown
```
---
## Requirements
### Requirement: Export Plan reimport
The Planner SHALL support reimporting an exported plan GPX via the file upload UI, completing the round-trip without needing the journal.
### Requirement: Journal initiates the handoff
#### Scenario: Reimport exported plan
- **WHEN** a user exports a plan and later imports it via the planner's GPX upload
- **THEN** waypoints, no-go areas, and track data are restored from the GPX
- **AND** BRouter re-routes between the imported waypoints with the imported no-go areas active
When a Journal user triggers "Edit in Planner", the Journal action (`POST /api/routes/:id/edit-in-planner`) MUST:
1. Authenticate the requesting user via the Journal session. Return 401 if unauthenticated.
2. Load the route and verify the requesting user is the route owner. Return 403 otherwise.
3. Issue a JWT callback token scoped to that route (see Token section).
4. Construct `callbackUrl = <journalOrigin>/api/routes/:id/callback`.
5. POST to `<PLANNER_URL>/api/sessions` with JSON body:
```json
{
"callbackUrl": "<journalOrigin>/api/routes/:id/callback",
"callbackToken": "<jwt>",
"gpx": "<gpx string or omitted if route has no GPX>"
}
```
6. Parse the Planner session response to obtain `{ sessionId, url, initialWaypoints, initialNoGoAreas, initialNotes }`.
7. Build a redirect URL: `<PLANNER_URL><session.url>?returnUrl=<journalRouteUrl>[&waypoints=…][&noGoAreas=…][&notes=…]`. Only include the optional params when the Planner returned non-empty values for them.
8. Return `{ url: "<redirect url>" }` to the browser.
The Journal MUST return 502 if the Planner session creation request fails.
---
### Requirement: Planner creates a session
`POST /api/sessions` MUST:
1. Accept `callbackUrl`, `callbackToken`, and `gpx` from the JSON body. All three are optional; the Planner can also be used without a Journal connection.
2. Create a new session row (UUID) in the `planner.sessions` table, storing `callbackUrl` and `callbackToken` verbatim.
3. If `gpx` is provided, parse it with `@trails-cool/gpx`:
- Extract waypoints → `initialWaypoints` (omitted if empty).
- Extract no-go areas → `initialNoGoAreas` (omitted if empty).
- Extract `gpxData.description``initialNotes` (omitted if absent).
- If GPX parsing throws, silently ignore and continue with an empty session.
4. Return HTTP 201:
```json
{
"sessionId": "<uuid>",
"url": "/session/<uuid>",
"initialWaypoints": […],
"initialNoGoAreas": […],
"initialNotes": "<string>"
}
```
Omit `initialWaypoints`, `initialNoGoAreas`, and `initialNotes` when absent.
---
### Requirement: Planner session page initialises route data
`GET /session/:id` (server loader) MUST:
1. Look up the session by ID. Return 404 if not found or already closed.
2. Expose `callbackUrl` and `callbackToken` to the client component (from the session row).
The client component MUST read the following URL search params from the redirect URL the Journal built:
| Param | Type | Purpose |
|---|---|---|
| `waypoints` | JSON array | Initial waypoint list |
| `noGoAreas` | JSON array | Initial no-go polygon list |
| `notes` | string | Initial route notes |
| `returnUrl` | string | "Return to Journal" link target |
On first Yjs sync (`synced` event), if the Yjs document is empty the client populates:
- `doc.getArray("waypoints")` from `initialWaypoints`
- `doc.getArray("noGoAreas")` from `initialNoGoAreas`
- `doc.getText("notes")` from `initialNotes`
Population is gated on `waypoints.length === 0` to avoid duplicating data on reconnect.
The `callbackUrl`, `callbackToken`, and `returnUrl` are passed as props to `SessionView``SaveToJournalButton`. They are **not** stored in the Yjs document.
---
### Requirement: Planner saves the route back to the Journal
`SaveToJournalButton` MUST:
1. Only render when both `callbackUrl` and `callbackToken` are present.
2. On activation, read the current Yjs state:
- Waypoints from `doc.getArray("waypoints")`.
- Computed track geometry (GeoJSON) from `doc.getMap("routeData").get("geojson")` — this is the BRouter output stored by the routing client.
- No-go areas from `doc.getArray("noGoAreas")`.
- Notes text from `doc.getText("notes").toString()`.
3. Generate a GPX string via `generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas })`.
- Notes round-trip as the GPX `<metadata><desc>` element.
- No-go areas are embedded as GPX extensions on the track.
4. POST to `callbackUrl`:
```
POST <callbackUrl>
Authorization: Bearer <callbackToken>
Content-Type: application/json
{ "gpx": "<gpx string>" }
```
5. On success, show a "saved" confirmation and a "Return to Journal" link targeting `returnUrl`.
6. On failure, surface the error message from the response body.
---
### Requirement: Journal callback endpoint writes a new version
`POST /api/routes/:id/callback` MUST:
1. Respond to CORS preflight (`OPTIONS`) with:
- `Access-Control-Allow-Origin: <PLANNER_URL>`
- `Access-Control-Allow-Methods: POST, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Authorization`
2. Require a `Bearer <token>` in the `Authorization` header. Return 401 if absent.
3. Verify the JWT (HS256, issuer = Journal origin). Return 401 on verification failure.
4. Confirm the token's `route_id` claim matches `:id`. Return 403 if not.
5. Confirm the token's `permissions` array includes `"write"`. Return 403 if not.
6. Confirm the route exists. Return 404 if not.
7. Parse `{ gpx }` from the JSON request body. Return 400 if absent.
8. Call `updateRoute(routeId, ownerId, { gpx })`, which creates a new route version. Return 400 if GPX validation fails (`GpxValidationError`).
9. Return `{ success: true, routeId }` on success.
All responses include CORS headers so the browser can read them.
---
### Requirement: JWT callback token
Tokens are HS256 JWTs signed with `JWT_SECRET` (env var; defaults to `"dev-jwt-secret-change-in-production"` in development).
| Claim | Value |
|---|---|
| `iss` | Journal origin URL |
| `iat` | Issue time |
| `exp` | Issue time + 7 days |
| `route_id` | Route ID string |
| `permissions` | `["read", "write"]` |
The Planner stores the token opaquely in the `planner.sessions` table and presents it verbatim in the `Authorization: Bearer` header on callback. The Planner never inspects the token contents.
---
## Notes flow (round-trip)
```
Journal route.description
→ GPX <metadata><desc> (via Journal's stored GPX)
→ Planner POST /api/sessions body.gpx
→ parse: gpxData.description → initialNotes
→ URL param ?notes=<string>
→ Yjs doc.getText("notes")
→ generateGpx({ description: notes })
→ GPX <metadata><desc>
→ Journal callback body.gpx
→ updateRoute stores new GPX version
```
Notes are plain text. No Markdown processing occurs in the handoff layer.
---
## Session lifecycle
Sessions are stored in `planner.sessions` (PostgreSQL). A session is considered active while `closed = false`. The session expiry job removes rows older than 7 days (configurable). Closing a session (`closed = true`) also removes its in-memory Yjs document. There is no automatic session close triggered by saving back to the Journal; the session remains open and the user can continue editing and save again.
---
## Scenarios
### Scenario: Full round-trip with notes and no-go areas
- **GIVEN** a Journal route with GPX containing waypoints, a computed track, no-go area extensions, and a `<metadata><desc>` with notes text
- **WHEN** the owner clicks "Edit in Planner"
- **THEN** the Journal POSTs to `/api/sessions` with the full GPX
- **AND** the Planner parses waypoints, no-go areas, and notes from the GPX
- **AND** the browser is redirected to `/session/:id?waypoints=…&noGoAreas=…&notes=…&returnUrl=…`
- **AND** on first sync the Yjs document is populated with all three data types
- **WHEN** the user edits the route and clicks "Save to Journal"
- **THEN** the Planner generates GPX embedding the current waypoints, computed track, no-go areas, and notes in `<metadata><desc>`
- **AND** POSTs it to `callbackUrl` with the JWT in `Authorization: Bearer`
- **AND** the Journal verifies the token, writes a new route version, and returns `{ success: true }`
- **AND** the "Return to Journal" link is shown
### Scenario: Route without GPX
- **GIVEN** a Journal route with no stored GPX
- **WHEN** the owner clicks "Edit in Planner"
- **THEN** the Journal omits the `gpx` field from the `/api/sessions` POST body
- **AND** the Planner creates an empty session and returns no `initialWaypoints`, `initialNoGoAreas`, or `initialNotes`
- **AND** the browser is redirected to `/session/:id?returnUrl=…` with no planning data params
- **AND** the Yjs document starts empty
### Scenario: Invalid GPX in session creation
- **GIVEN** the Journal sends a malformed GPX string
- **WHEN** the Planner's `POST /api/sessions` handler attempts to parse it
- **THEN** the parse error is silently swallowed
- **AND** the session is created with no initial planning data
- **AND** HTTP 201 is returned as normal
### Scenario: Expired or tampered token on callback
- **GIVEN** the JWT is expired or the signature is invalid
- **WHEN** the Planner POSTs to the callback endpoint
- **THEN** `verifyRouteToken` throws
- **AND** the Journal returns HTTP 401 with `{ error: "<message>" }`
### Scenario: Reconnect does not duplicate data
- **GIVEN** a session that was already populated and has an active Yjs document
- **WHEN** the Yjs WebSocket reconnects and fires `synced` again
- **THEN** the initialization guard (`waypoints.length === 0 && !initializedWaypoints.current`) prevents re-inserting the initial data

View file

@ -12,8 +12,15 @@ The Planner SHALL limit session creation to 10 per IP per hour.
- **THEN** the server responds with 429 Too Many Requests
### Requirement: BRouter call rate limit
The Planner SHALL limit route computations to 60 per session per hour.
The Planner SHALL limit route computations to 300 per session per hour (the `DEFAULT_MAX_REQUESTS` value in `apps/planner/app/lib/rate-limit.ts`).
#### Scenario: Routing rate limit exceeded
- **WHEN** a session exceeds 60 BRouter calls in one hour
- **WHEN** a session exceeds 300 BRouter calls in one hour
- **THEN** the server responds with 429 and the client shows a "slow down" message
### Requirement: Overpass API rate limit
The Planner SHALL limit Overpass API calls to 120 per IP per minute to protect the upstream service.
#### Scenario: Overpass rate limit exceeded
- **WHEN** a single IP exceeds 120 Overpass requests in one minute
- **THEN** the `/api/overpass` proxy responds with 429 Too Many Requests

View file

@ -1,82 +1,7 @@
## Purpose
## Merged into `route-coloring`
Road type visualization for route planning. Extracts OSM highway classification from BRouter tiledesc data and provides a color mode that shows road type on both the map polyline and elevation chart.
All requirements previously in this spec have been incorporated into `route-coloring`, which describes all color modes (plain, elevation, grade, surface, highway/road-type, speed limit, smoothness, track type, cycleway, bike route) in a unified way.
## Requirements
See `openspec/specs/route-coloring/spec.md`.
### Requirement: Highway tag extraction from BRouter
The routing pipeline SHALL extract `highway=*` tags from BRouter tiledesc messages and include them in the enriched route data as a per-point `highways` array.
#### Scenario: Highway tags present in BRouter response
- **WHEN** BRouter returns a route with tiledesc messages containing `highway=*` in the WayTags column
- **THEN** the `EnrichedRoute` SHALL include a `highways` string array with one entry per coordinate point
#### Scenario: Highway tags missing from BRouter response
- **WHEN** BRouter returns a route without `highway=*` tags in WayTags
- **THEN** each entry in the `highways` array SHALL be `"unknown"`
#### Scenario: Highway data stored in Yjs
- **WHEN** a route is computed and enriched route data is received
- **THEN** the highway array SHALL be stored in Yjs `routeData` as a JSON-serialized string under the key `"highways"`
### Requirement: Road type color palette
The Planner SHALL define a color mapping for OSM highway classifications, grouped by road category.
#### Scenario: Major roads colored with warm tones
- **WHEN** a route segment has highway type `motorway`, `trunk`, or `primary`
- **THEN** the segment SHALL be colored in red/orange tones
#### Scenario: Urban roads colored with neutral tones
- **WHEN** a route segment has highway type `secondary`, `tertiary`, `residential`, or `unclassified`
- **THEN** the segment SHALL be colored in gray/blue tones
#### Scenario: Paths and cycling infrastructure colored with green tones
- **WHEN** a route segment has highway type `cycleway`, `path`, `footway`, `track`, or `bridleway`
- **THEN** the segment SHALL be colored in green tones
#### Scenario: Unknown highway type
- **WHEN** a route segment has an unrecognized or missing highway value
- **THEN** the segment SHALL be colored with a neutral default color
### Requirement: Road type map polyline coloring
The Planner map SHALL color the route polyline by highway classification when road type mode is active.
#### Scenario: Road type coloring on map
- **WHEN** the color mode is set to "highway"
- **THEN** the route polyline on the map SHALL be colored segment-by-segment using the road type color palette
#### Scenario: Fallback when highway data unavailable
- **WHEN** the color mode is set to "highway" but no highway data is available
- **THEN** the route SHALL fall back to plain color mode
### Requirement: Road type elevation chart coloring
The elevation chart SHALL color segments by highway classification when road type mode is active.
#### Scenario: Road type chart rendering
- **WHEN** the color mode is set to "highway"
- **THEN** the elevation chart line and fill segments SHALL be colored using the road type color palette, matching the map polyline
### Requirement: Road type legend
The elevation chart SHALL display a legend for road type mode showing the highway types present in the route.
#### Scenario: Road type legend display
- **WHEN** the color mode is "highway" and highway data is available
- **THEN** a legend SHALL show colored swatches with highway type labels for the types present in the current route, up to 6 entries with a "+N" overflow indicator
### Requirement: Road type hover information
The elevation chart hover label SHALL include the highway type when in road type mode.
#### Scenario: Road type hover label
- **WHEN** hovering the elevation chart in "highway" mode
- **THEN** the label SHALL show elevation, distance, and highway type name (e.g., "340m · 12.3km · cycleway")
### Requirement: Road type i18n
All user-facing strings for the road type color mode SHALL be translated in English and German.
#### Scenario: English labels
- **WHEN** the app language is English
- **THEN** the color mode dropdown SHALL show "Road Type" and the chart title SHALL show "Road Type Profile"
#### Scenario: German labels
- **WHEN** the app language is German
- **THEN** the color mode dropdown SHALL show "Straßentyp" and the chart title SHALL show "Straßentypenprofil"
This file is retained as a redirect so existing cross-references don't 404.

View file

@ -1,32 +1,41 @@
## Purpose
Ghost marker drag interaction for reshaping routes by inserting new waypoints mid-segment.
Segment midpoint handles on the route polyline that allow users to reshape the route by inserting new waypoints, then dragging them to a desired position.
## Implementation note
The shipped interaction uses **permanent segment midpoint handles** — one semi-transparent circle rendered at the geometric center of each route segment — rather than a proximity-based hover ghost marker. Handles are visible at zoom ≥ 12. Clicking a handle inserts a new waypoint at the midpoint position; the user then drags that waypoint (standard Leaflet marker drag) to reshape the route.
## Requirements
### Requirement: Ghost marker on route hover
The Planner SHALL display a transient ghost marker when the cursor is near the route polyline, allowing users to reshape the route by dragging.
### Requirement: Segment midpoint handles
The Planner SHALL render a midpoint handle at the geometric center of each route segment.
#### Scenario: Ghost marker appears on hover
- **WHEN** the cursor moves within 15 pixels of the route polyline
- **THEN** a ghost marker (small blue circle) appears at the nearest route coordinate point
#### Scenario: Handles visible at sufficient zoom
- **WHEN** the map zoom level is 12 or above and the route has two or more waypoints
- **THEN** a semi-transparent circle is rendered at the midpoint of each segment between consecutive waypoints
#### Scenario: Ghost marker disappears on leave
- **WHEN** the cursor moves more than 15 pixels away from the route polyline
- **THEN** the ghost marker disappears
#### Scenario: Handles hidden at low zoom
- **WHEN** the map zoom level is below 12
- **THEN** midpoint handles are not rendered (the map is too far out for precise editing)
#### Scenario: Drag ghost marker to reshape
- **WHEN** a user drags the ghost marker to a new position
- **THEN** a new waypoint is inserted between the two adjacent waypoints of the hovered segment, and the route recomputes through the new point
#### Scenario: Handles update on waypoint change
- **WHEN** a waypoint is added, moved, or removed
- **THEN** midpoint handles reposition to reflect the updated segment geometry
#### Scenario: Trailer lines during drag
- **WHEN** a user is dragging the ghost marker
- **THEN** dashed guide lines are shown connecting the ghost marker to the adjacent waypoints
### Requirement: Insert waypoint by clicking a midpoint handle
Clicking a midpoint handle SHALL insert a new waypoint at that handle's position, splitting the segment in two.
#### Scenario: No text selection during drag
- **WHEN** a user drags the ghost marker
- **THEN** text selection is disabled on the page (via Leaflet's built-in L.Draggable)
#### Scenario: Click to insert
- **WHEN** a user clicks a midpoint handle
- **THEN** a new waypoint is inserted at the geometric midpoint of that segment
- **AND** the route recomputes through the new waypoint, splitting the segment into two
#### Scenario: Reshape syncs to other participants
- **WHEN** a user reshapes the route by dragging the ghost marker
- **THEN** all other participants see the new waypoint and recomputed route via Yjs sync
#### Scenario: Inserted waypoint is immediately draggable
- **WHEN** a new waypoint is inserted via a midpoint handle
- **THEN** the user can immediately drag the new waypoint to adjust the route shape
- **AND** the route recomputes continuously as the waypoint is dragged (standard waypoint drag behaviour)
#### Scenario: Insert syncs to other participants
- **WHEN** a user inserts a waypoint via a midpoint handle
- **THEN** all other participants see the new waypoint appear in the waypoint list and on the map via Yjs sync

View file

@ -1,24 +1,27 @@
## Purpose
Waypoint insertion by clicking the ghost marker on the route polyline, splitting a segment into two.
Splitting a route segment into two by inserting a waypoint at the segment midpoint via the midpoint handle. This is the same mechanism as route-drag-reshape — the distinction is user intent: reshape moves the new waypoint to reroute, while split leaves it in place as a named stop or day-break marker.
See `route-drag-reshape` for the midpoint handle interaction model.
## Requirements
### Requirement: Insert waypoint by clicking on route
The Planner SHALL allow users to insert a new waypoint by clicking the ghost marker that appears when hovering near the route.
### Requirement: Insert waypoint at segment midpoint
The Planner SHALL allow users to split a segment by clicking its midpoint handle, inserting a new waypoint at the geometric midpoint of the segment.
#### Scenario: Click ghost marker to split
- **WHEN** a ghost marker is visible on the route and the user clicks it
- **THEN** a new waypoint is inserted at the ghost marker position between the appropriate adjacent waypoints, and the route recomputes
#### Scenario: Waypoint snaps to route
- **WHEN** the ghost marker appears near the route
- **THEN** it is positioned at the closest coordinate point on the existing route geometry, not at the raw cursor position
#### Scenario: No duplicate waypoint from map click
- **WHEN** a user clicks the ghost marker to insert a waypoint
- **THEN** the map click handler is suppressed so only one waypoint is inserted (not an additional one appended at the end)
#### Scenario: Click midpoint handle to split
- **WHEN** a user clicks a midpoint handle on the route
- **THEN** a new waypoint is inserted at the geometric midpoint of that segment between the appropriate adjacent waypoints
- **AND** the route recomputes through the new waypoint
#### Scenario: Split syncs to other participants
- **WHEN** a user inserts a waypoint by clicking the ghost marker
- **WHEN** a user splits a segment
- **THEN** all other participants see the new waypoint appear in the waypoint list and on the map via Yjs sync
#### Scenario: Split waypoint can be named
- **WHEN** a waypoint is inserted by splitting
- **THEN** the user can click the waypoint to open its editor and assign a name, type (e.g. overnight), or note
## Note on ghost marker position
The inserted waypoint is placed at the **geometric midpoint** of the segment (midpoint index = `Math.floor((startIdx + endIdx) / 2)` of the BRouter coordinate array), not at the cursor position or the closest point on the polyline to the cursor. This is a deliberate simplification of the midpoint-handle model.

View file

@ -73,7 +73,7 @@ The BRouter service SHALL be reachable only from the flagship host over a privat
#### Scenario: Token storage
- **WHEN** `BROUTER_AUTH_TOKEN` is added or rotated
- **THEN** the token is written only to `infrastructure/secrets.infra.env` (SOPS-encrypted) and to the GitHub Actions secret store, and is never committed in cleartext to the repository
- **THEN** the token is written only to `infrastructure/secrets.app.env` (SOPS-encrypted, shared app secrets) and to the GitHub Actions secret store, and is never committed in cleartext to the repository
#### Scenario: Token rotation
- **WHEN** the token is rotated

View file

@ -1,6 +1,22 @@
## Purpose
Shared TypeScript packages (@trails-cool/types, gpx, map, ui, i18n) used by both Planner and Journal apps.
Shared TypeScript packages used by both Planner and Journal apps. All packages live under `packages/` and are published as `@trails-cool/<name>` within the monorepo via pnpm workspaces.
## Package overview
| Package | Name | Consumers |
|---|---|---|
| `types` | `@trails-cool/types` | Planner, Journal |
| `gpx` | `@trails-cool/gpx` | Planner, Journal |
| `fit` | `@trails-cool/fit` | Journal |
| `map` | `@trails-cool/map` | Planner |
| `map-core` | `@trails-cool/map-core` | Planner, Journal (server-side, jobs) |
| `ui` | `@trails-cool/ui` | Planner, Journal |
| `i18n` | `@trails-cool/i18n` | Planner, Journal |
| `api` | `@trails-cool/api` | Planner, Journal |
| `db` | `@trails-cool/db` | Journal |
| `jobs` | `@trails-cool/jobs` | Journal |
| `sentry-config` | `@trails-cool/sentry-config` | Planner, Journal |
## Requirements
@ -16,7 +32,7 @@ The `@trails-cool/types` package SHALL export TypeScript interfaces for Route, A
- **THEN** it has access to Route, Activity, RouteVersion, and RouteMetadata interfaces
### Requirement: GPX parsing package
The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoints, tracks, elevation) and generate GPX XML from structured data.
The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoints, tracks, elevation) and generate GPX XML from structured data. It is the sole owner of GPX encoding and decoding — apps do not bundle their own GPX logic.
#### Scenario: Parse GPX to waypoints
- **WHEN** the gpx package parses a valid GPX file
@ -24,30 +40,12 @@ The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoin
#### Scenario: Generate GPX from waypoints
- **WHEN** the gpx package is given an array of waypoints and a track
- **THEN** it generates a valid GPX XML string
- **THEN** it generates a valid GPX XML string including the custom `<extensions>` namespace for trails.cool metadata (see `docs/gpx-extensions.md`)
#### Scenario: Extract elevation data
- **WHEN** the gpx package parses a GPX file with elevation data
- **THEN** it returns elevation gain, loss, and a profile array of distance/elevation pairs
### Requirement: Map rendering package
The `@trails-cool/map` package SHALL provide core React components (MapView, RouteLayer) for rendering Leaflet maps with configurable base layers and route overlays. Interactive features (route drag-reshape, ghost markers, no-go area drawing, elevation chart, cursor tracking, colored routes) are implemented directly in the Planner app since they are Planner-specific.
#### Scenario: Render map component
- **WHEN** the map package's MapView component is rendered with a center and zoom
- **THEN** a Leaflet map is displayed with the default OSM tile layer
#### Scenario: Display route on map
- **WHEN** the map package's RouteLayer component receives GeoJSON
- **THEN** it renders a polyline on the map
### Requirement: UI component package
The `@trails-cool/ui` package SHALL provide shared React components (buttons, layout, form elements) styled with Tailwind CSS.
#### Scenario: Use Button component
- **WHEN** an app renders the Button component from `@trails-cool/ui`
- **THEN** a styled button is displayed consistent with the trails.cool design
### Requirement: FIT encoding package
The `@trails-cool/fit` package SHALL provide a `gpxToFitCourse` function that converts a GPX string to a FIT Course binary (`Uint8Array`) suitable for upload to Wahoo and other head units. It is the sole owner of FIT file generation; apps do not bundle their own FIT encoder. See `wahoo-route-push` spec for the full round-trip contract.
@ -55,8 +53,29 @@ The `@trails-cool/fit` package SHALL provide a `gpxToFitCourse` function that co
- **WHEN** the Journal app imports `@trails-cool/fit`
- **THEN** it has access to `gpxToFitCourse({ gpx, name })` returning a `Uint8Array`
### Requirement: Map components package
The `@trails-cool/map` package SHALL provide React/Leaflet components (`MapView`, `RouteLayer`) for rendering interactive maps. It re-exports `@trails-cool/map-core` so consumers only need one import for both components and constants. Interactive Planner-specific features (midpoint handles, no-go drawing, elevation chart) are implemented in the Planner app, not in this package.
#### Scenario: Render map component
- **WHEN** the map package's MapView component is rendered with a center and zoom
- **THEN** a Leaflet map is displayed with the configured base tile layer
#### Scenario: Display route on map
- **WHEN** the map package's RouteLayer component receives GeoJSON
- **THEN** it renders a polyline on the map
### Requirement: Map constants package
The `@trails-cool/map-core` package SHALL provide framework-free map constants and utilities: tile/overlay layer configs, color palettes (surface, highway, smoothness, grade, etc.), POI category definitions, z-index constants, and snap distance. It has zero React/Leaflet dependencies so it can be imported in server-side code, jobs, and tests without pulling in browser globals.
#### Scenario: Import colors in Journal server code
- **WHEN** server-side Journal code imports `@trails-cool/map-core`
- **THEN** it has access to color maps and tile configs without importing React or Leaflet
### Requirement: UI component package
The `@trails-cool/ui` package SHALL provide shared React components (buttons, layout primitives, form elements) styled with Tailwind CSS, used by both apps.
### Requirement: i18n package
The `@trails-cool/i18n` package SHALL provide react-i18next configuration and translation strings starting with English and German.
The `@trails-cool/i18n` package SHALL provide react-i18next configuration and translation strings for English (primary) and German.
#### Scenario: Display German translation
- **WHEN** a user's browser locale is set to German
@ -65,3 +84,15 @@ The `@trails-cool/i18n` package SHALL provide react-i18next configuration and tr
#### Scenario: Fallback to English
- **WHEN** a user's browser locale is not supported
- **THEN** UI strings fall back to English
### Requirement: API contracts package
The `@trails-cool/api` package SHALL define shared API contracts: endpoint URL constants, request/response types, pagination shapes, error codes, and API version. Both apps import from this package; neither defines its own duplicate API types.
### Requirement: Database package
The `@trails-cool/db` package SHALL provide the Drizzle ORM schema (all tables across `planner.*` and `journal.*` schemas), the database client factory, and migration helpers. The Journal app is the sole runtime consumer; the Planner references only `planner.*` tables. All schema changes flow through this package.
### Requirement: Background jobs package
The `@trails-cool/jobs` package SHALL provide the pg-boss client factory (`createBoss`), the worker registration helper, and the `JobDefinition` type that each job exports. Apps construct boss instances from this package rather than importing pg-boss directly.
### Requirement: Sentry configuration package
The `@trails-cool/sentry-config` package SHALL provide shared Sentry initialisation helpers used by both apps. It sets user context, attaches session IDs as tags, and configures source map upload. Apps call the shared helper from their Sentry entry points rather than configuring Sentry inline.