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

11 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

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.

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:

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:

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.