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 <type>overnight</type> for isDayBreak waypoints
- GPX parse: recognize <type>overnight</type> and set isDayBreak on waypoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-10 23:51:31 +02:00
parent c9d6f65616
commit efdb3c1973
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 226 additions and 6 deletions

View file

@ -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<unknown>): boolean {
return yMap.get("overnight") === true;
}

View file

@ -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<DayStage[]>([]);
useEffect(() => {
if (!yjs) return;
const recompute = () => {
// Extract waypoints with isDayBreak from Yjs
const waypoints: Waypoint[] = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
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;
}

View file

@ -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 `<type>overnight</type>` for waypoints with `isDayBreak: true`
- [ ] 5.2 Extend `parseGpx` in `@trails-cool/gpx` to recognize `<type>overnight</type>` and set `isDayBreak: true` on parsed waypoints
- [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
- [ ] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
## 6. Journal Integration

View file

@ -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));
}

View file

@ -33,6 +33,9 @@ export function generateGpx(options: {
if (wpt.name) {
lines.push(` <name>${escapeXml(wpt.name)}</name>`);
}
if (wpt.isDayBreak) {
lines.push(" <type>overnight</type>");
}
lines.push(" </wpt>");
}
}

View file

@ -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";

View file

@ -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 };
});
}