trails/openspec/changes/multi-day-routes/design.md
Ullrich Schäfer 0a330e4466
Break up route-features into focused specs, add new changes
Archive the monolithic route-features spec and replace with 9 focused
OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping),
undo-redo, local-dev-stack, route-sharing, route-discovery,
activity-photos, osm-overlays, plus the existing changelog and
komoot-import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:53:41 +02:00

8.1 KiB

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:

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:

// 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>).

<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

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.