From efdb3c19737f8efeb07e9c047d47f43a77115c36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 10 Apr 2026 23:51:31 +0200 Subject: [PATCH 01/32] Add multi-day data model, computeDays, and GPX roundtrip - overnight.ts: Yjs helpers to set/clear/check overnight flag on waypoints - compute-days.ts: Pure function splitting routes into DayStage[] at day breaks - use-days.ts: Reactive hook mapping Yjs state into computeDays() input - GPX generate: emit overnight for isDayBreak waypoints - GPX parse: recognize overnight and set isDayBreak on waypoints Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/overnight.ts | 24 ++++ apps/planner/app/lib/use-days.ts | 68 ++++++++++++ openspec/changes/multi-day-routes/tasks.md | 10 +- packages/gpx/src/compute-days.ts | 121 +++++++++++++++++++++ packages/gpx/src/generate.ts | 3 + packages/gpx/src/index.ts | 2 + packages/gpx/src/parse.ts | 4 +- 7 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 apps/planner/app/lib/overnight.ts create mode 100644 apps/planner/app/lib/use-days.ts create mode 100644 packages/gpx/src/compute-days.ts diff --git a/apps/planner/app/lib/overnight.ts b/apps/planner/app/lib/overnight.ts new file mode 100644 index 0000000..eecfabc --- /dev/null +++ b/apps/planner/app/lib/overnight.ts @@ -0,0 +1,24 @@ +import * as Y from "yjs"; +import type { YjsState } from "./use-yjs.ts"; + +/** + * Set or clear the overnight flag on a waypoint. + */ +export function setOvernight(yjs: YjsState, index: number, value: boolean): void { + const waypointMap = yjs.waypoints.get(index); + if (!waypointMap) return; + yjs.doc.transact(() => { + if (value) { + waypointMap.set("overnight", true); + } else { + waypointMap.delete("overnight"); + } + }, "local"); +} + +/** + * Check if a waypoint Y.Map has the overnight flag set. + */ +export function isOvernight(yMap: Y.Map): boolean { + return yMap.get("overnight") === true; +} diff --git a/apps/planner/app/lib/use-days.ts b/apps/planner/app/lib/use-days.ts new file mode 100644 index 0000000..f56326a --- /dev/null +++ b/apps/planner/app/lib/use-days.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import * as Y from "yjs"; +import { computeDays, type DayStage } from "@trails-cool/gpx"; +import type { Waypoint } from "@trails-cool/types"; +import type { TrackPoint } from "@trails-cool/gpx"; +import type { YjsState } from "./use-yjs.ts"; +import { isOvernight } from "./overnight.ts"; + +/** + * Reactive hook that computes day stages from Yjs waypoints and route data. + * Returns an empty array for single-day routes (no overnight waypoints). + */ +export function useDays(yjs: YjsState | null): DayStage[] { + const [days, setDays] = useState([]); + + useEffect(() => { + if (!yjs) return; + + const recompute = () => { + // Extract waypoints with isDayBreak from Yjs + const waypoints: Waypoint[] = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ + lat: yMap.get("lat") as number, + lon: yMap.get("lon") as number, + name: yMap.get("name") as string | undefined, + isDayBreak: isOvernight(yMap) || undefined, + })); + + // Check if any waypoint has isDayBreak + const hasBreaks = waypoints.some((w) => w.isDayBreak); + if (!hasBreaks) { + setDays([]); + return; + } + + // Extract track points from route coordinates stored in Yjs + const coordsStr = yjs.routeData.get("coordinates") as string | undefined; + if (!coordsStr) { + setDays([]); + return; + } + + try { + // coordinates are stored as [[lon, lat, ele], ...] (GeoJSON format) + const coords: number[][] = JSON.parse(coordsStr); + const trackPoints: TrackPoint[] = coords.map((c) => ({ + lat: c[1]!, + lon: c[0]!, + ele: c[2], + })); + + setDays(computeDays(waypoints, [trackPoints])); + } catch { + setDays([]); + } + }; + + yjs.waypoints.observeDeep(recompute); + yjs.routeData.observe(recompute); + recompute(); + + return () => { + yjs.waypoints.unobserveDeep(recompute); + yjs.routeData.unobserve(recompute); + }; + }, [yjs]); + + return days; +} diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md index 717fbcd..120db05 100644 --- a/openspec/changes/multi-day-routes/tasks.md +++ b/openspec/changes/multi-day-routes/tasks.md @@ -1,8 +1,8 @@ ## 1. Data Model & Computation -- [ ] 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` -- [ ] 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. -- [ ] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: maps Yjs waypoints + EnrichedRoute into `computeDays()` input, returns reactive `DayStage[]` +- [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 @@ -24,8 +24,8 @@ ## 5. GPX Roundtrip -- [ ] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `overnight` for waypoints with `isDayBreak: true` -- [ ] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `overnight` and set `isDayBreak: true` on parsed waypoints +- [x] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `overnight` for waypoints with `isDayBreak: true` +- [x] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `overnight` and set `isDayBreak: true` on parsed waypoints - [ ] 5.3 Add multi-track export option: split track into one `` per day, each named "Day N: Start - End" ## 6. Journal Integration diff --git a/packages/gpx/src/compute-days.ts b/packages/gpx/src/compute-days.ts new file mode 100644 index 0000000..f5211cc --- /dev/null +++ b/packages/gpx/src/compute-days.ts @@ -0,0 +1,121 @@ +import type { Waypoint } from "@trails-cool/types"; +import type { TrackPoint } from "./types.ts"; + +export interface DayStage { + dayNumber: number; + startWaypointIndex: number; + endWaypointIndex: number; + startName?: string; + endName?: string; + /** Distance in meters */ + distance: number; + /** Ascent in meters */ + ascent: number; + /** Descent in meters */ + descent: number; +} + +/** + * Split a route into day stages based on waypoints with isDayBreak. + * + * Day boundaries are defined by waypoints where isDayBreak is true. + * The first waypoint is the implicit start of Day 1, the last waypoint + * is the implicit end of the final day, and each isDayBreak waypoint + * marks the end of one day and the start of the next. + * + * If no waypoints have isDayBreak, returns a single day covering the + * entire route. + */ +export function computeDays( + waypoints: Waypoint[], + tracks: TrackPoint[][], +): DayStage[] { + if (waypoints.length === 0 || tracks.length === 0) return []; + + // Flatten all tracks into a single point array + const allPoints: TrackPoint[] = tracks.flat(); + if (allPoints.length === 0) return []; + + // Find waypoint indices that are day breaks + const breakIndices: number[] = []; + for (let i = 0; i < waypoints.length; i++) { + if (waypoints[i]!.isDayBreak) { + breakIndices.push(i); + } + } + + // Build day boundaries: [0, break1, break2, ..., lastWaypointIndex] + const boundaries = [0, ...breakIndices]; + if (boundaries[boundaries.length - 1] !== waypoints.length - 1) { + boundaries.push(waypoints.length - 1); + } + + // For each waypoint, find the closest point in the track + const waypointTrackIndices = waypoints.map((wp) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < allPoints.length; i++) { + const dx = allPoints[i]!.lat - wp.lat; + const dy = allPoints[i]!.lon - wp.lon; + const d = dx * dx + dy * dy; + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + return bestIdx; + }); + + // Precompute cumulative distances and elevation changes + const cumDist: number[] = [0]; + const cumAscent: number[] = [0]; + const cumDescent: number[] = [0]; + + for (let i = 1; i < allPoints.length; i++) { + const prev = allPoints[i - 1]!; + const curr = allPoints[i]!; + cumDist.push(cumDist[i - 1]! + haversine(prev.lat, prev.lon, curr.lat, curr.lon)); + + if (prev.ele !== undefined && curr.ele !== undefined) { + const diff = curr.ele - prev.ele; + cumAscent.push(cumAscent[i - 1]! + (diff > 0 ? diff : 0)); + cumDescent.push(cumDescent[i - 1]! + (diff < 0 ? -diff : 0)); + } else { + cumAscent.push(cumAscent[i - 1]!); + cumDescent.push(cumDescent[i - 1]!); + } + } + + // Build day stages + const days: DayStage[] = []; + for (let d = 0; d < boundaries.length - 1; d++) { + const startWpIdx = boundaries[d]!; + const endWpIdx = boundaries[d + 1]!; + const startTrackIdx = waypointTrackIndices[startWpIdx]!; + const endTrackIdx = waypointTrackIndices[endWpIdx]!; + + days.push({ + dayNumber: d + 1, + startWaypointIndex: startWpIdx, + endWaypointIndex: endWpIdx, + startName: waypoints[startWpIdx]!.name, + endName: waypoints[endWpIdx]!.name, + distance: Math.round(cumDist[endTrackIdx]! - cumDist[startTrackIdx]!), + ascent: Math.round(cumAscent[endTrackIdx]! - cumAscent[startTrackIdx]!), + descent: Math.round(cumDescent[endTrackIdx]! - cumDescent[startTrackIdx]!), + }); + } + + return days; +} + +function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index 1be51f2..f52de43 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -33,6 +33,9 @@ export function generateGpx(options: { if (wpt.name) { lines.push(` ${escapeXml(wpt.name)}`); } + if (wpt.isDayBreak) { + lines.push(" overnight"); + } lines.push(" "); } } diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index 6830ada..f958032 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,4 +1,6 @@ export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.ts"; export { extractWaypoints } from "./waypoints.ts"; +export { computeDays } from "./compute-days.ts"; +export type { DayStage } from "./compute-days.ts"; export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index e43d249..114aad9 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -58,7 +58,9 @@ function parseWaypoints(doc: Document): Waypoint[] { const lat = parseFloat(wpt.getAttribute("lat") ?? "0"); const lon = parseFloat(wpt.getAttribute("lon") ?? "0"); const name = wpt.querySelector("name")?.textContent ?? undefined; - return { lat, lon, name }; + const type = wpt.querySelector("type")?.textContent ?? undefined; + const isDayBreak = type === "overnight" ? true : undefined; + return { lat, lon, name, isDayBreak }; }); } From 94016fd4c4c938d19f097715c36c02aa803bc971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 10 Apr 2026 23:53:43 +0200 Subject: [PATCH 02/32] Add sidebar day breakdown and overnight toggle UI - DayBreakdown: Collapsible day sections with per-day stats - WaypointSidebar: Overnight toggle button (moon icon), day-grouped view when any waypoint is marked overnight, route summary in header - SessionView: Wire useDays() hook into sidebar via SidebarTabs - i18n: Add multiDay keys for en + de (day labels, overnight, stats) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/DayBreakdown.tsx | 54 +++++++ apps/planner/app/components/SessionView.tsx | 4 +- .../app/components/WaypointSidebar.tsx | 152 ++++++++++++------ openspec/changes/multi-day-routes/tasks.md | 10 +- packages/i18n/src/locales/de.ts | 11 ++ packages/i18n/src/locales/en.ts | 11 ++ 6 files changed, 190 insertions(+), 52 deletions(-) create mode 100644 apps/planner/app/components/DayBreakdown.tsx diff --git a/apps/planner/app/components/DayBreakdown.tsx b/apps/planner/app/components/DayBreakdown.tsx new file mode 100644 index 0000000..9632531 --- /dev/null +++ b/apps/planner/app/components/DayBreakdown.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { DayStage } from "@trails-cool/gpx"; + +interface DayBreakdownProps { + days: DayStage[]; + children: (dayStage: DayStage, waypointIndices: { start: number; end: number }) => React.ReactNode; +} + +export function DayBreakdown({ days, children }: DayBreakdownProps) { + const { t } = useTranslation(); + const [expandedDay, setExpandedDay] = useState(1); + + return ( +
+ {days.map((day) => { + const isExpanded = expandedDay === day.dayNumber; + return ( +
+ + + {isExpanded && ( + <> +
+ ↑ {day.ascent} m + ↓ {day.descent} m +
+ {children(day, { start: day.startWaypointIndex, end: day.endWaypointIndex })} + + )} +
+ ); + })} +
+ ); +} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 6cea400..614934f 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -5,6 +5,7 @@ import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting, type RouteError } from "~/lib/use-routing"; +import { useDays } from "~/lib/use-days"; import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; @@ -144,6 +145,7 @@ function ColorModeToggle({ yjs }: { yjs: YjsState }) { function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType["routeStats"] }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); + const days = useDays(yjs); return (