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>
This commit is contained in:
Ullrich Schäfer 2026-04-11 00:54:05 +02:00
parent d325127a06
commit adc5b44739
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 50 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View file

@ -0,0 +1,329 @@
## Context
The Planner stores waypoints as a Yjs `Y.Array<Y.Map<unknown>>` where each
Y.Map holds `lat`, `lon`, and optionally `name`. Routes are computed
segment-by-segment between consecutive waypoints via BRouter, producing an
`EnrichedRoute` with `coordinates`, `segmentBoundaries`, `totalLength`,
`totalAscend`, and `totalTime`. The visual-redesign change already defines the
UI treatment for multi-day routes (sidebar day breakdown, elevation chart
dividers, map day labels) but explicitly defers the data model and logic.
This design covers the data model, computation, and integration decisions.
## Decisions
### D1: Day-break waypoints via `overnight` flag
A waypoint becomes a day boundary by setting `overnight: true` on its Y.Map.
The first waypoint of the route is the implicit start of Day 1, the last
waypoint is the implicit end of the final day, and every waypoint with
`overnight: true` marks the end of one day and start of the next.
```
Waypoints: [Berlin, Zossen, Dessau(overnight), Halle, Erfurt]
|------- Day 1 -------||------- Day 2 ------|
```
This is the simplest possible model: a single boolean on existing data. No new
Yjs types, no separate array, no ordering concerns. It composes naturally with
waypoint reordering, insertion, and deletion -- if an overnight waypoint is
removed, the two days merge automatically.
### D2: Day computation as a pure utility
A `computeDays()` function takes the waypoints array and the `EnrichedRoute`
and returns an array of day objects:
```typescript
interface DayStage {
dayNumber: number;
startWaypointIndex: number;
endWaypointIndex: number;
startName: string;
endName: string;
distance: number; // meters
ascent: number; // meters
descent: number; // meters
estimatedTime: number; // seconds
coordStartIndex: number;
coordEndIndex: number;
}
function computeDays(
waypoints: Array<{ lat: number; lon: number; name?: string; overnight?: boolean }>,
route: EnrichedRoute,
): DayStage[];
```
The function walks `segmentBoundaries` to map waypoint indices to coordinate
ranges, then accumulates distance and elevation per day by iterating
coordinates within each range. If no waypoints have `overnight: true`, it
returns a single day covering the entire route.
This is a pure function with no Yjs dependency -- it takes plain data and
returns plain data. This makes it easy to test and reuse.
### D3: Yjs state -- minimal addition
The only change to Yjs state is adding an `overnight` key to waypoint Y.Maps:
```typescript
// Setting overnight on a waypoint
const waypointMap = yjs.waypoints.get(index);
waypointMap.set("overnight", true);
// Clearing overnight
waypointMap.delete("overnight");
```
No new Y.Array or Y.Map types are introduced. The `overnight` key is optional
-- existing waypoints without it are treated as regular (non-overnight)
waypoints. This is fully backwards-compatible: sessions created before this
feature work identically, and clients that don't understand `overnight` simply
ignore it.
The existing crash-recovery logic (periodic localStorage save of Y.Doc state)
preserves overnight flags automatically since they are part of the Y.Doc.
### D4: Sidebar day breakdown
The `WaypointSidebar` component gains a day-grouped view when any waypoint has
`overnight: true`. The layout follows visual-redesign D4:
```
ACTIVE ROUTE
Berlin -> Erfurt 343 km ^868m 2 days
DAY 1 - Berlin -> Dessau [v]
^340m 120 km ~4h 30m
1. Berlin Alexanderplatz
2. Zossen
3. Juterbog
4. Dessau [OVERNIGHT]
> DAY 2 - Dessau -> Erfurt 223 km [>]
(collapsed days show summary only)
```
**Behavior:**
- Day 1 is expanded by default; other days are collapsed
- Clicking a day header toggles expand/collapse
- Per-day stats (distance, ascent, estimated time) shown in day header
- Overnight waypoints display an amber badge
- Waypoints within each day are numbered sequentially (1, 2, 3... restarting
per day would be confusing -- use global numbering)
- When no waypoints have `overnight: true`, the sidebar shows the flat list
as it does today (no "Day 1" wrapper for single-day routes)
### D5: Elevation chart day dividers
The `ElevationChart` canvas drawing is extended to render day boundaries as
vertical dashed lines. For each day boundary (overnight waypoint), the
corresponding distance along the route is computed from `segmentBoundaries`
and `coordinates`, and a dashed vertical line is drawn at that x-position.
```
Day 1 Day 2 Day 3
___/\__/\___ : __/\_____ : __/\___
/ \ : / \ : / \
/_______________\:/___________\:/________\
0 km 120 km 120 km 250 km 250 km 343 km
```
Each divider has a "Day N" label at the top of the chart area. The dashed line
uses a muted color (`--text-lo` / `#9A9484`) to avoid competing with the
elevation profile. This follows visual-redesign D6.
### D6: Map day labels
White pill-shaped labels are placed on the route at each day boundary, showing
"Day 1 . 120 km". These use Leaflet DivIcon markers positioned at the
coordinate of the overnight waypoint.
Styling follows visual-redesign D5:
- White background with subtle shadow (`--shadow-sm`)
- Text in `--text-hi` with distance in `--font-mono`
- Positioned slightly offset from the route line to avoid overlap with the
route itself
Day labels are only shown when there are 2+ days. They update reactively when
overnight flags change (same Yjs observe pattern as existing markers).
### D7: GPX export with day-break metadata
Day-break waypoints are exported with a `<type>overnight</type>` element inside
the `<wpt>` tag. This is valid GPX 1.1 (the `<type>` element is a standard
child of `<wpt>`).
```xml
<wpt lat="51.8365" lon="12.2428">
<name>Dessau</name>
<type>overnight</type>
</wpt>
```
Additionally, the track can optionally be split into multiple `<trk>` elements
(one per day), each with a `<name>` like "Day 1: Berlin - Dessau". This gives
GPS devices and other tools a natural per-day breakdown. The single-track export
remains the default; multi-track is an option in the export dialog.
On GPX import (future), the parser should recognize `<type>overnight</type>`
waypoints and restore the overnight flags. This is not in scope for this change
but the format is designed to support it.
### D8: Overnight toggle UX
Two interaction paths to toggle a waypoint as overnight:
1. **Sidebar**: Each waypoint row in the sidebar gains an overnight toggle
button (crescent moon icon). Clicking it sets/clears `overnight` on the
waypoint's Y.Map. The button uses amber styling (`--stop`, `--stop-bg`)
when active.
2. **Map context menu**: Right-clicking (long-press on mobile) a waypoint
marker on the map shows a context menu with "Mark as overnight stop" /
"Remove overnight stop". This reuses the same Y.Map mutation.
Visual feedback follows visual-redesign tokens:
- Overnight waypoint markers on the map use amber-brown (`--stop`: `#8B6D3A`)
instead of the default olive (`--accent`: `#4A6B40`)
- Sidebar overnight waypoints have a subtle amber background (`--stop-bg`)
- The "OVERNIGHT" badge uses `--stop` text on `--stop-bg` background with
`--stop-border` border
### D9: GPX parse recognizes overnight waypoints
The `parseWaypoints` function in `packages/gpx/src/parse.ts` is extended to
check for `<type>overnight</type>` inside `<wpt>` elements. When found, the
returned `Waypoint` has `isDayBreak: true`.
```typescript
function parseWaypoints(doc: Document): Waypoint[] {
const wpts = doc.querySelectorAll("wpt");
return Array.from(wpts).map((wpt) => {
const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
const name = wpt.querySelector("name")?.textContent ?? undefined;
const type = wpt.querySelector("type")?.textContent ?? undefined;
const isDayBreak = type === "overnight" ? true : undefined;
return { lat, lon, name, isDayBreak };
});
}
```
This makes GPX round-tripping work: Planner exports overnight waypoints with
`<type>overnight</type>` (D7), and any consumer — the Journal's `updateRoute`,
the Planner's GPX import, or a future mobile app — gets `isDayBreak` back.
### D10: Journal stores dayBreaks on route save
The `dayBreaks` jsonb column on `journal.routes` already exists. When
`updateRoute` receives GPX and calls `parseGpxAsync`, it extracts the indices
of waypoints where `isDayBreak === true` and writes them to `dayBreaks`:
```typescript
const parsed = await parseGpxAsync(gpx);
const dayBreaks = parsed.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
```
This is computed on write, not on read — the Journal doesn't need to re-parse
GPX to know day structure. If the GPX has no overnight waypoints, `dayBreaks`
is an empty array (single-day route).
### D11: Journal route detail day breakdown
When a route has non-empty `dayBreaks`, the route detail page shows a day
breakdown section below the stats grid. Per-day stats (distance, ascent,
descent) are computed from the stored GPX by splitting at day-break waypoints
— reusing the same `computeDays` logic from D2, extracted into
`@trails-cool/gpx` so both Planner and Journal can use it.
```
DAY 1: Berlin → Dessau 120 km ↑340m ↓180m
DAY 2: Dessau → Erfurt 223 km ↑528m ↓490m
```
The map also colors each day segment differently (alternating two colors from
the design tokens) so users can visually see where each day starts and ends.
For single-day routes (no `dayBreaks`), nothing changes — the page renders
exactly as it does today.
### D12: computeDays as a shared package function
The `computeDays` pure function (D2) is placed in `@trails-cool/gpx` rather
than in the Planner app, since both the Planner (from Yjs + EnrichedRoute) and
the Journal (from parsed GPX) need day computation. The function signature
works with the `GpxData` tracks and waypoints returned by `parseGpx`:
```typescript
interface DayStage {
dayNumber: number;
startWaypointIndex: number;
endWaypointIndex: number;
startName?: string;
endName?: string;
distance: number; // meters
ascent: number; // meters
descent: number; // meters
}
function computeDays(
waypoints: Waypoint[],
tracks: TrackPoint[][],
): DayStage[];
```
The Planner's `useDays()` hook maps its Yjs waypoints + EnrichedRoute into
this same shape before calling `computeDays`.
### D13: Sidebar waypoint hover highlights map marker
Hovering a waypoint row in the `WaypointSidebar` passes the waypoint index
up to `SessionView` via an `onWaypointHover` callback. `PlannerMap` receives
a `highlightedWaypoint` index and renders the corresponding marker with a
CSS `scale(1.17)` transform (0.2s ease transition). This reuses the existing
`waypointIcon` function with a `highlighted` parameter — no extra DOM
elements or Leaflet layers needed.
### D14: Journal route detail — day segment hover interaction
Hovering a day row in the route detail day breakdown triggers two effects:
1. **Map segment highlighting**: The hovered day's polyline thickens (weight 5,
opacity 1) while other days dim (weight 2, opacity 0.3). Implemented via
`highlightedDay` state passed to `DayColoredRoute`.
2. **Fly-to-segment**: A `FlyToSegment` component calls `map.flyToBounds()`
on the hovered segment's bounds (200ms animation). On mouse leave it flies
back to the full route bounds. The full route bounds are cached on first
render to avoid recomputation.
### D15: Per-day GPX export endpoint
The existing `/api/routes/:id/gpx` endpoint gains an optional `?day=N` query
parameter. When present, it parses the stored GPX, runs `computeDays()` to
find the track point range for that day, and returns a GPX containing only
that day's track segment. The filename includes the day number
(`route_day1.gpx`).
## Risks / Trade-offs
- **Segment boundary alignment**: The day computation relies on
`segmentBoundaries` from `EnrichedRoute` to map waypoint indices to
coordinate ranges. If the segment merge logic changes, day computation
breaks. Mitigate with thorough unit tests on `computeDays`.
- **Large routes**: A route with 50+ waypoints and many overnight stops could
make the sidebar unwieldy. Collapsible sections mitigate this. We can add
virtual scrolling later if needed.
- **Yjs backwards compatibility**: Adding `overnight` to Y.Maps is safe, but
older clients that don't understand it will silently ignore overnight flags.
In a collaborative session, one user could see day breakdown while another
does not. This is acceptable for now since all clients will be updated
together.
- **GPX round-trip**: The `<type>overnight</type>` convention is not a standard
GPX extension namespace. Other tools will ignore it, which is fine. The data
is not lost, just not interpreted.

View file

@ -0,0 +1,94 @@
## Why
Long routes -- multi-day bike tours, thru-hikes, extended backpacking trips --
are flat lists of waypoints with no structure. Users planning a 5-day ride from
Berlin to Prague have no way to mark where each day ends, see per-day distance
and climbing, or reason about daily effort. They resort to external spreadsheets
or mental arithmetic to divide the route into manageable stages.
The Planner already computes total distance and elevation across the full route.
Adding day structure is a matter of marking overnight stops on existing waypoints
and deriving per-day stats from the segment data we already have.
## What Changes
- **Day-break waypoints**: Any waypoint can be toggled as an overnight stop.
This adds an `overnight: true` flag to the waypoint's Y.Map in the existing
Yjs waypoints array. First and last waypoints are implicit day boundaries.
- **Per-day stats**: Distance, total ascent, and estimated duration computed per
day by splitting the route at overnight waypoints. Derived from the existing
`segmentBoundaries` and `coordinates` in the enriched route data.
- **Sidebar day breakdown**: Waypoints grouped by day with collapsible sections,
per-day stats, and overnight toggle. Day 1 expanded by default.
- **Elevation chart day dividers**: Dashed vertical lines at day boundaries with
"Day N" labels.
- **Map day labels**: White pill markers on the route at day boundaries showing
"Day 1 . 120 km".
- **GPX export**: Day-break waypoints exported with a `<type>overnight</type>`
element so the structure survives round-trips.
- **Waypoint highlighting**: Hovering a waypoint in the sidebar highlights
the corresponding marker on the map with a smooth scale animation.
- **Journal day interaction**: Hovering a day in the Journal route detail
highlights that segment on the map and flies the map to fit it.
- **Per-day GPX export**: Each day in the Journal route detail has a GPX
download button that exports just that day's track segment.
All state lives in Yjs. No database changes are needed -- the Planner remains
stateless. The visual design is already specified in the `visual-redesign`
change (D4 sidebar, D5 map markers, D6 elevation chart); this spec covers
the data model, computation logic, and integration wiring.
## Capabilities
### New Capabilities
- `multi-day-routes`: Overnight waypoint markers, per-day stats computation,
day-aware sidebar/chart/map display, multi-day GPX export
### Modified Capabilities
- `planner-session`: Waypoints gain an `overnight` property in Yjs state
- `map-display`: Day boundary labels on route, overnight marker styling
- `gpx-export`: Day-break metadata in exported GPX waypoints
- `gpx-import`: Parse `<type>overnight</type>` waypoints to restore day breaks
- `route-management`: Populate `dayBreaks` column on save, expose per-day stats
- `journal-route-detail`: Day breakdown display with per-day stats, map segment
highlighting, fly-to-segment on hover, per-day GPX export
- `planner-sidebar`: Waypoint hover highlights corresponding map marker
## Non-Goals
- **Automatic day splitting**: No algorithm to suggest where to stop. Users
decide manually. This avoids opinionated defaults and keeps the logic simple.
- **Accommodation search**: No POI lookup for campsites or hotels. Out of scope.
- **Per-day routing profiles**: All days use the same BRouter profile. Supporting
different profiles per day would require rearchitecting the routing pipeline.
- **Per-day activity tracking**: Linking individual activities to specific days
of a multi-day route (e.g. "Day 2 activity"). Activities remain linked to the
full route. Per-day activity chaining is a future concern.
## Impact
- **Yjs state**: `overnight` boolean added to waypoint Y.Map entries (additive,
backwards-compatible -- existing sessions without it behave as single-day)
- **Shared types**: `Waypoint.isDayBreak` already exists in `@trails-cool/types`
but is unused. This change activates it.
- **New utility**: `compute-days.ts` -- pure function that splits route data at
overnight waypoints and returns per-day stats
- **Sidebar**: `WaypointSidebar.tsx` gains day-grouped view with collapsible
sections and overnight toggle buttons
- **ElevationChart**: Canvas drawing extended with vertical day dividers
- **Map**: New day-label layer and overnight marker variant
- **GPX generate**: `generateGpx` extended to emit `<type>overnight</type>` on
day-break waypoints
- **GPX parse**: `parseGpx` extended to recognize `<type>overnight</type>` and
set `isDayBreak: true` on parsed waypoints
- **Journal route storage**: `updateRoute` populates `dayBreaks` column from
parsed waypoint indices when saving GPX
- **Journal route detail**: Day breakdown section with per-day stats (distance,
ascent, descent) when `dayBreaks` is non-empty. Hover highlights segment on
map and flies to fit. Per-day GPX download via `?day=N` query param.
- **Sidebar hover**: Hovering waypoint rows highlights the marker on the map
with CSS `scale(1.17)` transition (0.2s ease)
- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de)
in both planner and journal namespaces

View file

@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Overnight waypoint markers
Any waypoint SHALL be toggleable as an overnight stop, creating day boundaries in the route.
#### Scenario: Toggle overnight
- **WHEN** a user toggles the overnight flag on a waypoint
- **THEN** the waypoint's `overnight: true` flag is set in the Yjs document
- **AND** the route is visually divided into days at that point
#### Scenario: Implicit day boundaries
- **WHEN** overnight stops are set
- **THEN** the first waypoint is the implicit start of Day 1 and the last waypoint is the implicit end of the final day
### Requirement: Per-day statistics
The Planner SHALL compute and display distance, ascent, and estimated duration for each day.
#### Scenario: Day stats computed
- **WHEN** a route has overnight waypoints
- **THEN** per-day distance, total ascent, and estimated duration are derived from segment boundaries and coordinates
### Requirement: Day-aware sidebar
The sidebar SHALL group waypoints by day with collapsible sections and per-day stats.
#### Scenario: Day breakdown
- **WHEN** a route has multiple days
- **THEN** waypoints are grouped under "Day 1", "Day 2", etc. with collapsible sections
- **AND** each section header shows day distance and ascent
### Requirement: Elevation chart day dividers
The elevation chart SHALL show day boundaries as dashed vertical lines.
#### Scenario: Day dividers on chart
- **WHEN** a route has multiple days
- **THEN** dashed vertical lines with "Day N" labels appear at each overnight waypoint position
### Requirement: Map day labels
The map SHALL display day summary labels at day boundary waypoints.
#### Scenario: Day labels on map
- **WHEN** a route has multiple days
- **THEN** white pill markers at day boundaries show "Day N . X km"
### Requirement: Multi-day GPX export
Day structure SHALL be preserved in GPX exports via waypoint type elements.
#### Scenario: Export multi-day route
- **WHEN** a user exports a plan with overnight waypoints
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
- **AND** reimporting the GPX restores the day structure

View file

@ -0,0 +1,64 @@
## 1. Data Model & Computation
- [x] 1.1 Add `overnight` property support to Yjs waypoint Y.Maps: helper functions `setOvernight(yjs, index, value)` and `isOvernight(yMap)` in a new `apps/planner/app/lib/overnight.ts`
- [x] 1.2 Create `computeDays()` pure function in `packages/gpx/src/compute-days.ts`: takes `Waypoint[]` + `TrackPoint[][]`, returns `DayStage[]` (day number, waypoint range, distance, ascent, descent). Export from package index.
- [x] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: maps Yjs waypoints + EnrichedRoute into `computeDays()` input, returns reactive `DayStage[]`
## 2. Sidebar Day Breakdown
- [x] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
- [x] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
- [x] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
- [x] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
## 3. Map Integration
- [x] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
- [x] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
- [x] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
## 4. Elevation Chart
- [x] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
- [x] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
## 5. GPX Roundtrip
- [x] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `<type>overnight</type>` for waypoints with `isDayBreak: true`
- [x] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `<type>overnight</type>` and set `isDayBreak: true` on parsed waypoints
- [x] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
## 6. Journal Integration
- [x] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column
- [x] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`)
- [x] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty
- [x] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist
## 7. i18n
- [x] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
- [x] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
## 8. Planner Waypoint Hover
- [x] 8.0a Add `onWaypointHover` callback to `WaypointSidebar`, emit waypoint index on row hover
- [x] 8.0b Pass `highlightedWaypoint` index to `PlannerMap`, render highlighted marker with CSS `scale(1.17)` and 0.2s ease transition
## 9. Journal Day Interaction
- [x] 9.1 Add `highlightedDay` state to route detail page, pass to `RouteMapThumbnail`
- [x] 9.2 Highlight hovered day segment on map: thicken active (weight 5), dim others (opacity 0.3)
- [x] 9.3 Fly map to hovered day segment bounds (200ms), fly back to full route on mouse leave
- [x] 9.4 Add per-day GPX download button on each day row in route detail breakdown
- [x] 9.5 Extend `/api/routes/:id/gpx` with `?day=N` query param to export single day's track segment
## 10. Testing
- [x] 10.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
- [x] 10.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
- [x] 10.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved
- [x] 10.4 Unit tests for `dayBreaks` extraction in route update logic
- [x] 10.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
- [x] 10.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
- [x] 10.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page