trails/openspec/changes/multi-day-routes/design.md
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

299 lines
11 KiB
Markdown

## 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`.
## 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.