Merge pull request #198 from trails-cool/feat/multi-day-routes

Add multi-day routes with overnight waypoints
This commit is contained in:
Ullrich Schäfer 2026-04-11 01:10:09 +02:00 committed by GitHub
commit a1b231c54c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1562 additions and 161 deletions

View file

@ -19,13 +19,70 @@ function FitBounds({ data }: { data: GeoJsonObject }) {
return null;
}
function FlyToSegment({ segments, highlightedDay, fullData }: {
segments: Array<{ coords: number[][] }>;
highlightedDay: number | null | undefined;
fullData: GeoJsonObject;
}) {
const map = useMap();
const fullBoundsRef = useRef<L.LatLngBounds | null>(null);
useEffect(() => {
// Cache full route bounds on first render
if (!fullBoundsRef.current) {
const layer = L.geoJSON(fullData);
fullBoundsRef.current = layer.getBounds();
}
if (highlightedDay != null && highlightedDay >= 1 && highlightedDay <= segments.length) {
const seg = segments[highlightedDay - 1]!;
// coords are [lon, lat] GeoJSON format
const latlngs = seg.coords.map((c) => L.latLng(c[1]!, c[0]!));
const bounds = L.latLngBounds(latlngs);
if (bounds.isValid()) {
map.flyToBounds(bounds, { padding: [40, 40], duration: 0.2 });
}
} else if (fullBoundsRef.current?.isValid()) {
map.flyToBounds(fullBoundsRef.current, { padding: [20, 20], duration: 0.2 });
}
}, [highlightedDay, segments, fullData, map]);
return null;
}
const DAY_COLORS = ["#2563eb", "#8B6D3A", "#059669", "#9333ea", "#dc2626", "#0891b2"];
function extractGeometry(data: GeoJsonObject): number[][] | null {
const coords = (data as { type: string; coordinates?: number[][] }).coordinates
?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates);
return coords && coords.length >= 2 ? coords : null;
}
function splitGeometry(data: GeoJsonObject, numDays: number): Array<{ coords: number[][] }> {
const geometry = extractGeometry(data);
if (!geometry) return [];
const totalPoints = geometry.length;
const pointsPerDay = Math.ceil(totalPoints / numDays);
const segments: Array<{ coords: number[][] }> = [];
for (let d = 0; d < numDays; d++) {
const start = d * pointsPerDay;
const end = Math.min((d + 1) * pointsPerDay + 1, totalPoints);
if (start >= totalPoints) break;
segments.push({ coords: geometry.slice(start, end) });
}
return segments;
}
interface RouteMapProps {
geojson: string;
interactive?: boolean;
className?: string;
dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null;
}
export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) {
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
@ -44,8 +101,60 @@ export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapP
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution={interactive ? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' : undefined}
/>
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
{dayBreaks && dayBreaks.length > 0 ? (
<DayColoredRoute data={data} dayBreaks={dayBreaks} highlightedDay={highlightedDay} />
) : (
<GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />
)}
<FitBounds data={data} />
{dayBreaks && dayBreaks.length > 0 && (
<FlyToSegment
segments={splitGeometry(data, dayBreaks.length + 1)}
highlightedDay={highlightedDay}
fullData={data}
/>
)}
</MapContainer>
);
}
function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) {
const geometry = extractGeometry(data);
if (!geometry) {
return <GeoJSON data={data} style={{ color: "#2563eb", weight: 3, opacity: 0.8 }} />;
}
const numDays = dayBreaks.length + 1;
const rawSegments = splitGeometry(data, numDays);
const segments = rawSegments.map((seg, i) => ({
...seg,
color: DAY_COLORS[i % DAY_COLORS.length]!,
}));
const isHighlighting = highlightedDay != null;
return (
<>
{segments.map((seg, i) => {
const dayNum = i + 1;
const isActive = highlightedDay === dayNum;
const segData: GeoJsonObject = {
type: "Feature",
geometry: { type: "LineString", coordinates: seg.coords },
properties: {},
} as unknown as GeoJsonObject;
return (
<GeoJSON
key={`${i}-${highlightedDay}`}
data={segData}
style={{
color: seg.color,
weight: isHighlighting ? (isActive ? 5 : 2) : 3,
opacity: isHighlighting ? (isActive ? 1 : 0.3) : 0.8,
}}
/>
);
})}
</>
);
}

View file

@ -19,11 +19,13 @@ export async function createRoute(ownerId: string, input: RouteInput) {
let distance: number | null = null;
let elevationGain: number | null = null;
let elevationLoss: number | null = null;
let dayBreaks: number[] = [];
if (input.gpx) {
const stats = await computeRouteStats(input.gpx);
distance = stats.distance;
elevationGain = stats.elevationGain;
elevationLoss = stats.elevationLoss;
dayBreaks = stats.dayBreaks;
}
await db.insert(routes).values({
@ -36,6 +38,7 @@ export async function createRoute(ownerId: string, input: RouteInput) {
distance,
elevationGain,
elevationLoss,
dayBreaks,
});
if (input.gpx) {
@ -110,6 +113,7 @@ export async function updateRoute(
updateData.distance = stats.distance;
updateData.elevationGain = stats.elevationGain;
updateData.elevationLoss = stats.elevationLoss;
updateData.dayBreaks = stats.dayBreaks;
// Get next version number
const existingVersions = await db
@ -151,13 +155,17 @@ export async function deleteRoute(id: string, ownerId: string) {
async function computeRouteStats(gpxString: string) {
try {
const gpxData = await parseGpxAsync(gpxString);
const dayBreaks = gpxData.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
return {
distance: gpxData.distance,
elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss,
dayBreaks,
};
} catch {
return { distance: null, elevationGain: null, elevationLoss: null };
return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[] };
}
}

View file

@ -1,16 +1,77 @@
import type { Route } from "./+types/api.routes.$id.gpx";
import { getRouteWithVersions } from "~/lib/routes.server";
import { parseGpxAsync, computeDays, generateGpx } from "@trails-cool/gpx";
export async function loader({ params }: Route.LoaderArgs) {
export async function loader({ params, request }: Route.LoaderArgs) {
const route = await getRouteWithVersions(params.id);
if (!route?.gpx) {
return new Response("No GPX data", { status: 404 });
}
return new Response(route.gpx, {
headers: {
"Content-Type": "application/gpx+xml",
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
},
});
const url = new URL(request.url);
const dayParam = url.searchParams.get("day");
if (!dayParam) {
return new Response(route.gpx, {
headers: {
"Content-Type": "application/gpx+xml",
"Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
},
});
}
// Export a single day's segment
const dayNumber = parseInt(dayParam, 10);
if (isNaN(dayNumber) || dayNumber < 1) {
return new Response("Invalid day number", { status: 400 });
}
try {
const gpxData = await parseGpxAsync(route.gpx);
const days = computeDays(gpxData.waypoints, gpxData.tracks);
const day = days.find((d) => d.dayNumber === dayNumber);
if (!day) {
return new Response("Day not found", { status: 404 });
}
// Extract track points for this day by matching waypoint positions to track
const allPoints = gpxData.tracks.flat();
const startWp = gpxData.waypoints[day.startWaypointIndex]!;
const endWp = gpxData.waypoints[day.endWaypointIndex]!;
const findClosest = (wp: { lat: number; lon: number }) => {
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;
};
const startIdx = findClosest(startWp);
const endIdx = findClosest(endWp);
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
const dayName = day.startName && day.endName
? `Day ${dayNumber}: ${day.startName} - ${day.endName}`
: `Day ${dayNumber}`;
const dayGpx = generateGpx({
name: dayName,
tracks: [dayPoints],
});
const filename = `${route.name.replace(/[^a-z0-9]/gi, "_")}_day${dayNumber}.gpx`;
return new Response(dayGpx, {
headers: {
"Content-Type": "application/gpx+xml",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch {
return new Response("Failed to extract day segment", { status: 500 });
}
}

View file

@ -19,6 +19,19 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
const isOwner = user?.id === route.ownerId;
// Compute per-day stats if route has day breaks and GPX
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
if (route.dayBreaks && route.dayBreaks.length > 0 && route.gpx) {
try {
const { computeDays } = await import("@trails-cool/gpx");
const { parseGpxAsync } = await import("@trails-cool/gpx");
const gpxData = await parseGpxAsync(route.gpx);
dayStats = computeDays(gpxData.waypoints, gpxData.tracks);
} catch {
// Fall back to no day stats
}
}
return data({
route: {
id: route.id,
@ -29,10 +42,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
elevationLoss: route.elevationLoss,
routingProfile: route.routingProfile,
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
geojson: routeWithGeojson?.geojson ?? null,
createdAt: route.createdAt.toISOString(),
updatedAt: route.updatedAt.toISOString(),
},
dayStats,
versions: route.versions.map((v) => ({
version: v.version,
changeDescription: v.changeDescription,
@ -79,9 +94,10 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
}
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
const { route, versions, isOwner } = loaderData;
const { route, dayStats, versions, isOwner } = loaderData;
const { t } = useTranslation("journal");
const [editLoading, setEditLoading] = useState(false);
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
const handleEditInPlanner = useCallback(async () => {
setEditLoading(true);
@ -156,9 +172,53 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
)}
</div>
{dayStats.length > 1 && (
<div className="mt-6">
<h2 className="text-lg font-semibold text-gray-900">{t("routes.dayBreakdown")}</h2>
<div className="mt-3 divide-y divide-gray-200 rounded-md border border-gray-200">
{dayStats.map((day) => (
<div
key={day.dayNumber}
className="flex items-center gap-4 px-4 py-3 hover:bg-gray-50 transition-colors"
onMouseEnter={() => setHighlightedDay(day.dayNumber)}
onMouseLeave={() => setHighlightedDay(null)}
>
<span className="text-sm font-medium text-gray-700">
{t("routes.dayLabel", { n: day.dayNumber })}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">
{day.startName && day.endName
? `${day.startName}${day.endName}`
: day.startName || day.endName || ""}
</span>
<span className="text-sm tabular-nums text-gray-700">
{(day.distance / 1000).toFixed(1)} km
</span>
<span className="text-xs text-gray-500">
{day.ascent} m
</span>
<span className="text-xs text-gray-500">
{day.descent} m
</span>
{route.hasGpx && (
<a
href={`/api/routes/${route.id}/gpx?day=${day.dayNumber}`}
download
className="shrink-0 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-100 hover:text-gray-700"
title={t("routes.exportGpx")}
>
GPX
</a>
)}
</div>
))}
</div>
</div>
)}
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<ClientMap geojson={route.geojson} interactive className="h-full w-full" />
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
</div>
)}

View file

@ -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("planner");
const [expandedDay, setExpandedDay] = useState(1);
return (
<div className="flex flex-col">
{days.map((day) => {
const isExpanded = expandedDay === day.dayNumber;
return (
<div key={day.dayNumber}>
<button
onClick={() => setExpandedDay(isExpanded ? -1 : day.dayNumber)}
className="flex w-full items-center gap-2 border-b border-gray-100 px-4 py-2 text-left hover:bg-gray-50"
>
<span className="text-xs font-semibold text-gray-500">
{t("multiDay.dayLabel", { n: day.dayNumber })}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-gray-600">
{day.startName && day.endName
? `${day.startName}${day.endName}`
: day.startName || day.endName || ""}
</span>
<span className="shrink-0 text-xs tabular-nums text-gray-500">
{(day.distance / 1000).toFixed(1)} km
</span>
<span className="shrink-0 text-xs text-gray-400">
{isExpanded ? "▾" : "▸"}
</span>
</button>
{isExpanded && (
<>
<div className="flex gap-3 border-b border-gray-100 bg-gray-50 px-4 py-1.5 text-[10px] text-gray-500">
<span> {day.ascent} m</span>
<span> {day.descent} m</span>
</div>
{children(day, { start: day.startWaypointIndex, end: day.endWaypointIndex })}
</>
)}
</div>
);
})}
</div>
);
}

View file

@ -1,4 +1,6 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
import { elevationColor, type ColorMode } from "~/components/ColoredRoute";
@ -55,9 +57,11 @@ const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
interface ElevationChartProps {
yjs: YjsState;
onHover?: (position: [number, number] | null) => void;
days?: DayStage[];
}
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
export function ElevationChart({ yjs, onHover, days }: ElevationChartProps) {
const { t } = useTranslation();
const [points, setPoints] = useState<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const [colorMode, setColorMode] = useState<ColorMode>("plain");
@ -170,6 +174,31 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
ctx.fillText("0 km", PADDING.left, h - 4);
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
// Day dividers
if (days && days.length > 1) {
for (let d = 0; d < days.length - 1; d++) {
// Find the point closest to the day boundary distance
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
const bx = toX(boundaryDist);
// Dashed vertical line
ctx.beginPath();
ctx.setLineDash([4, 3]);
ctx.moveTo(bx, PADDING.top);
ctx.lineTo(bx, PADDING.top + chartH);
ctx.strokeStyle = "#9A9484";
ctx.lineWidth = 1;
ctx.stroke();
ctx.setLineDash([]);
// Day label at top
ctx.fillStyle = "#9A9484";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
}
}
// Hover crosshair + info
if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
const p = points[highlightIdx]!;
@ -203,7 +232,7 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
ctx.fillText(label, labelX, PADDING.top + 10);
}
},
[points, colorMode],
[points, colorMode, days, t],
);
useEffect(() => {

View file

@ -23,6 +23,7 @@ function getWaypoints(yjs: YjsState) {
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
isDayBreak: yMap.get("overnight") === true ? true : undefined,
}));
}

View file

@ -3,25 +3,46 @@ import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEve
import L from "leaflet";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { DayStage } from "@trails-cool/gpx";
import type { YjsState } from "~/lib/use-yjs";
import { baseLayers } from "@trails-cool/map";
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { isOvernight } from "~/lib/overnight";
import { setOvernight } from "~/lib/overnight";
import { NoGoAreaLayer } from "./NoGoAreaLayer";
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
import { RouteInteraction } from "./RouteInteraction";
import "leaflet/dist/leaflet.css";
function waypointIcon(index: number): L.DivIcon {
function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
const bg = overnight ? "#8B6D3A" : "#2563eb";
const scale = highlighted ? "scale(1.17)" : "scale(1)";
return L.divIcon({
className: "",
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:#2563eb;color:white;
background:${bg};color:white;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-12px,-12px);
">${index + 1}</div>`,
transform:translate(-12px,-12px) ${scale};
transition:transform 0.2s ease;
">${overnight ? "" : index + 1}</div>`,
iconSize: [0, 0],
});
}
function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="position:absolute;left:50%;transform:translate(-50%,-40px);">
<div style="
background:white;color:#1f2937;
padding:1px 6px;border-radius:8px;
font-size:10px;font-weight:600;white-space:nowrap;
box-shadow:0 1px 3px rgba(0,0,0,0.15);
">Day ${dayNumber} · ${distanceKm} km</div>
</div>`,
iconSize: [0, 0],
});
}
@ -30,6 +51,7 @@ interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
@ -37,6 +59,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
@ -45,6 +68,8 @@ interface PlannerMapProps {
onRouteRequest?: (waypoints: WaypointData[]) => void;
onImportError?: (message: string) => void;
highlightPosition?: [number, number] | null;
highlightedWaypoint?: number | null;
days?: DayStage[];
}
function MapExposer() {
@ -206,7 +231,7 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
);
}
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) {
export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [draggingOver, setDraggingOver] = useState(false);
@ -390,6 +415,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
if (wp.isDayBreak) yMap.set("overnight", true);
yjs.waypoints.push([yMap]);
}
@ -442,7 +469,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
key={i}
position={[wp.lat, wp.lon]}
draggable
icon={waypointIcon(i)}
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
eventHandlers={{
mouseover: () => {
routeInteractionSuspendedRef.current = true;
@ -465,12 +492,31 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
deleteWaypoint(i);
// Middle waypoints: toggle overnight. First/last: delete.
if (i > 0 && i < waypoints.length - 1) {
setOvernight(yjs, i, !wp.overnight);
} else {
deleteWaypoint(i);
}
},
}}
/>
))}
{/* Day boundary labels on map */}
{days && days.length > 1 && days.map((day) => {
const wp = waypoints[day.endWaypointIndex];
if (!wp || day.dayNumber === days.length) return null;
return (
<Marker
key={`day-${day.dayNumber}`}
position={[wp.lat, wp.lon]}
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
interactive={false}
/>
);
})}
{routeCoordinates && routeCoordinates.length >= 2 && (
<>
<ColoredRoute

View file

@ -45,6 +45,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
isDayBreak: yMap.get("overnight") === true ? true : undefined,
}));
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas });

View file

@ -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";
@ -141,7 +142,7 @@ function ColorModeToggle({ yjs }: { yjs: YjsState }) {
);
}
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"]; days: ReturnType<typeof useDays>; onWaypointHover: (index: number | null) => void }) {
const { t } = useTranslation("planner");
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
@ -164,7 +165,7 @@ function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnTyp
<div className="flex-1 overflow-hidden">
{tab === "waypoints" ? (
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
<WaypointSidebar yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={onWaypointHover} />
</Suspense>
) : (
<NotesPanel yjs={yjs} />
@ -179,7 +180,7 @@ interface SessionViewProps {
callbackUrl?: string;
callbackToken?: string;
returnUrl?: string;
initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>;
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>;
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>;
}
@ -190,7 +191,9 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs);
const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
useUndoShortcuts(yjs?.undoManager ?? null);
const days = useDays(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const [highlightedWaypoint, setHighlightedWaypoint] = useState<number | null>(null);
const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast);
@ -283,14 +286,14 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} onImportError={(msg) => addToast(msg, "error")} />
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} highlightedWaypoint={highlightedWaypoint} onImportError={(msg) => addToast(msg, "error")} days={days} />
</Suspense>
</div>
<Suspense fallback={null}>
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
<ElevationChart yjs={yjs} onHover={handleElevationHover} days={days} />
</Suspense>
</main>
<SidebarTabs yjs={yjs} routeStats={routeStats} />
<SidebarTabs yjs={yjs} routeStats={routeStats} days={days} onWaypointHover={setHighlightedWaypoint} />
</div>
<YjsDebugPanel yjs={yjs} />
{toasts.length > 0 && (

View file

@ -1,11 +1,16 @@
import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs";
import type { DayStage } from "@trails-cool/gpx";
import { setOvernight, isOvernight } from "~/lib/overnight";
import { DayBreakdown } from "./DayBreakdown";
interface WaypointData {
lat: number;
lon: number;
name?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
@ -13,6 +18,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
overnight: isOvernight(yMap),
}));
}
@ -23,9 +29,12 @@ interface WaypointSidebarProps {
elevationGain?: number;
elevationLoss?: number;
};
days: DayStage[];
onWaypointHover?: (index: number | null) => void;
}
export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: WaypointSidebarProps) {
const { t } = useTranslation("planner");
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
useEffect(() => {
@ -47,92 +56,149 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
if (from === to || from < 0 || to < 0) return;
const item = yjs.waypoints.get(from);
if (!item) return;
const data = { lat: item.get("lat") as number, lon: item.get("lon") as number, name: item.get("name") as string | undefined };
const data = {
lat: item.get("lat") as number,
lon: item.get("lon") as number,
name: item.get("name") as string | undefined,
overnight: isOvernight(item),
};
yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1);
const yMap = new Y.Map();
yMap.set("lat", data.lat);
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
if (data.overnight) yMap.set("overnight", true);
yjs.waypoints.insert(to, [yMap]);
}, "local");
},
[yjs.waypoints, yjs.doc],
);
const toggleOvernight = useCallback(
(index: number) => {
const wp = waypoints[index];
if (!wp) return;
setOvernight(yjs, index, !wp.overnight);
},
[yjs, waypoints],
);
const hasMultipleDays = days.length > 1;
const renderWaypointRow = (wp: WaypointData, i: number) => (
<li
key={i}
className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50"
onMouseEnter={() => onWaypointHover?.(i)}
onMouseLeave={() => onWaypointHover?.(null)}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
{wp.overnight && (
<span className="shrink-0 rounded px-1 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-50 border border-amber-200">
{t("multiDay.overnight")}
</span>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{/* Overnight toggle — not on first or last waypoint */}
{i > 0 && i < waypoints.length - 1 && (
<button
onClick={() => toggleOvernight(i)}
className={`rounded p-1 ${wp.overnight ? "text-amber-600 hover:bg-amber-100" : "text-gray-400 hover:bg-gray-200 hover:text-gray-600"}`}
title={wp.overnight ? t("multiDay.removeOvernight") : t("multiDay.markOvernight")}
>
</button>
)}
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
);
return (
<div className="flex h-full flex-col">
{/* Header with route summary */}
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">
Waypoints ({waypoints.length})
{t("sidebar.waypoints")} ({waypoints.length})
</h2>
{routeStats && routeStats.distance !== undefined && (
<p className="mt-0.5 text-xs text-gray-500">
{(routeStats.distance / 1000).toFixed(1)} km
{routeStats.elevationGain !== undefined && ` · ↑${routeStats.elevationGain} m`}
{hasMultipleDays && ` · ${t("multiDay.dayCount", { count: days.length })}`}
</p>
)}
</div>
{/* Waypoint list */}
<div className="flex-1 overflow-y-auto">
{waypoints.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-gray-500">
Click on the map to add waypoints
</p>
) : hasMultipleDays ? (
<DayBreakdown days={days}>
{(_day, { start, end }) => (
<ul className="divide-y divide-gray-100">
{waypoints.slice(start, end + 1).map((wp, offset) => renderWaypointRow(wp, start + offset))}
</ul>
)}
</DayBreakdown>
) : (
<ul className="divide-y divide-gray-100">
{waypoints.map((wp, i) => (
<li key={i} className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
))}
{waypoints.map((wp, i) => renderWaypointRow(wp, i))}
</ul>
)}
</div>
{routeStats && routeStats.distance !== undefined && (
{/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */}
{!hasMultipleDays && routeStats && routeStats.distance !== undefined && (
<div className="border-t border-gray-200 px-4 py-3">
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<p className="font-medium text-gray-900">
{(routeStats.distance / 1000).toFixed(1)} km
</p>
<p className="text-gray-500">Distance</p>
<p className="text-gray-500">{t("multiDay.distance")}</p>
</div>
{routeStats.elevationGain !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationGain} m
</p>
<p className="text-gray-500">Ascent</p>
<p className="text-gray-500">{t("multiDay.ascent")}</p>
</div>
)}
{routeStats.elevationLoss !== undefined && (
@ -140,7 +206,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
<p className="font-medium text-gray-900">
{routeStats.elevationLoss} m
</p>
<p className="text-gray-500">Descent</p>
<p className="text-gray-500">{t("multiDay.descent")}</p>
</div>
)}
</div>

View file

@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import * as Y from "yjs";
import { setOvernight, isOvernight } from "./overnight.ts";
import type { YjsState } from "./use-yjs.ts";
function createTestYjs(): YjsState {
const doc = new Y.Doc();
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
return { doc, waypoints } as unknown as YjsState;
}
function addWaypoint(yjs: YjsState, lat: number, lon: number): void {
const yMap = new Y.Map();
yMap.set("lat", lat);
yMap.set("lon", lon);
yjs.waypoints.push([yMap]);
}
function createDocMap(): Y.Map<unknown> {
const doc = new Y.Doc();
const arr = doc.getArray<Y.Map<unknown>>("test");
const yMap = new Y.Map<unknown>();
arr.push([yMap]);
return yMap;
}
describe("isOvernight", () => {
it("returns false for a waypoint without overnight flag", () => {
const yMap = createDocMap();
yMap.set("lat", 52.52);
yMap.set("lon", 13.405);
expect(isOvernight(yMap)).toBe(false);
});
it("returns true for a waypoint with overnight flag", () => {
const yMap = createDocMap();
yMap.set("lat", 52.52);
yMap.set("lon", 13.405);
yMap.set("overnight", true);
expect(isOvernight(yMap)).toBe(true);
});
it("returns false for non-boolean overnight value", () => {
const yMap = createDocMap();
yMap.set("overnight", "yes");
expect(isOvernight(yMap)).toBe(false);
});
});
describe("setOvernight", () => {
it("sets overnight flag on a waypoint", () => {
const yjs = createTestYjs();
addWaypoint(yjs, 52.52, 13.405);
setOvernight(yjs, 0, true);
const yMap = yjs.waypoints.get(0)!;
expect(yMap.get("overnight")).toBe(true);
});
it("clears overnight flag from a waypoint", () => {
const yjs = createTestYjs();
addWaypoint(yjs, 52.52, 13.405);
setOvernight(yjs, 0, true);
expect(yjs.waypoints.get(0)!.get("overnight")).toBe(true);
setOvernight(yjs, 0, false);
expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined();
});
it("does nothing for out-of-bounds index", () => {
const yjs = createTestYjs();
addWaypoint(yjs, 52.52, 13.405);
// Should not throw
setOvernight(yjs, 5, true);
expect(yjs.waypoints.get(0)!.get("overnight")).toBeUndefined();
});
});

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

@ -42,7 +42,7 @@ export interface YjsState {
export function useYjs(
sessionId: string,
initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>,
initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>,
initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>,
): YjsState | null {
const [state, setState] = useState<YjsState | null>(null);
@ -106,6 +106,7 @@ export function useYjs(
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
if (wp.isDayBreak) yMap.set("overnight", true);
waypoints.push([yMap]);
}
if (initialNoGoAreas?.length && noGoAreas.length === 0) {

View file

@ -19,7 +19,7 @@ export async function action({ request }: Route.ActionArgs) {
return withDb(async () => {
const session = await createSession({ callbackUrl, callbackToken });
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
let initialWaypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> | undefined;
let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined;
if (gpx) {
try {

View file

@ -33,7 +33,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) {
const [searchParams] = useSearchParams();
const returnUrl = searchParams.get("returnUrl") ?? undefined;
const waypointsParam = searchParams.get("waypoints");
let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
let initialWaypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> | undefined;
if (waypointsParam) {
try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ }
}

View file

@ -0,0 +1,97 @@
import type { Page } from "@playwright/test";
/**
* Canned enriched route for 3 nearby Berlin waypoints:
* [52.520, 13.405] [52.516, 13.377] [52.510, 13.390]
*
* ~3.2km total, 6 track points with elevation.
*/
const MOCK_ROUTE_3WP = {
geojson: {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38],
[13.384, 52.517, 40], [13.377, 52.516, 42],
[13.379, 52.514, 40], [13.382, 52.513, 38],
[13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34],
],
},
properties: {},
}],
},
coordinates: [
[13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38],
[13.384, 52.517, 40], [13.377, 52.516, 42],
[13.379, 52.514, 40], [13.382, 52.513, 38],
[13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34],
],
segmentBoundaries: [0, 4],
totalLength: 3200,
totalAscend: 8,
surfaces: [],
};
/**
* Canned enriched route for 2 nearby Berlin waypoints:
* [52.520, 13.405] [52.515, 13.351]
*
* ~3.8km total, 6 track points with elevation.
*/
const MOCK_ROUTE_2WP = {
geojson: {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40],
[13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35],
],
},
properties: {},
}],
},
coordinates: [
[13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40],
[13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35],
],
segmentBoundaries: [0],
totalLength: 3800,
totalAscend: 6,
surfaces: [],
};
/**
* Mock the BRouter route API to return instant, deterministic responses.
* Selects the appropriate canned response based on waypoint count.
*/
export async function mockBRouter(page: Page) {
await page.route("**/api/route", async (route) => {
const body = route.request().postDataJSON();
const wpCount = body?.waypoints?.length ?? 0;
const mock = wpCount >= 3 ? MOCK_ROUTE_3WP : MOCK_ROUTE_2WP;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mock),
});
});
}
/**
* Convert a lat/lon to pixel coordinates on the Leaflet map.
* Requires MapExposer to have set window.__leafletMap.
*/
export async function latLngToPixel(page: Page, lat: number, lon: number): Promise<{ x: number; y: number }> {
return page.evaluate(([la, ln]) => {
const map = (window as any).__leafletMap;
if (!map) throw new Error("__leafletMap not available");
const point = map.latLngToContainerPoint([la, ln]);
return { x: point.x, y: point.y };
}, [lat, lon]);
}

View file

@ -32,6 +32,30 @@ test.describe("Integration: Journal ↔ Planner handoff", () => {
expect(session.initialWaypoints).toHaveLength(2);
expect(session.initialWaypoints[0].name).toBe("Berlin");
});
test("GPX import with overnight waypoints preserves isDayBreak", async ({ request }) => {
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="51.84" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
<wpt lat="50.98" lon="11.028"><name>Erfurt</name></wpt>
<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.84" lon="12.243"><ele>80</ele></trkpt>
<trkpt lat="50.98" lon="11.028"><ele>195</ele></trkpt>
</trkseg></trk>
</gpx>`;
const sessionResp = await request.post(`${PLANNER}/api/sessions`, {
data: { gpx },
});
expect(sessionResp.ok()).toBeTruthy();
const session = await sessionResp.json();
expect(session.initialWaypoints).toHaveLength(3);
expect(session.initialWaypoints[1].name).toBe("Dessau");
// isDayBreak should be preserved through GPX parsing
expect(session.initialWaypoints[1].isDayBreak).toBe(true);
});
});
test.describe("Integration: BRouter routing", () => {

View file

@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock";
test.describe("Planner", () => {
test("loads the home page", async ({ page }) => {
@ -86,6 +87,8 @@ test.describe("Planner", () => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
await mockBRouter(page);
await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([
{ lat: 52.520, lon: 13.405 },
{ lat: 52.515, lon: 13.351 },
@ -94,31 +97,23 @@ test.describe("Planner", () => {
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 });
await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 });
// Zoom in and click on the route midpoint to split it
// RouteInteraction uses map-level mousemove, but click on the ghost marker
// inserts the waypoint. We'll use Leaflet events to simulate.
const sidebar = page.locator("aside");
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
// Zoom in to the route midpoint using known coordinates
await page.evaluate(() => {
const map = (window as any).__leafletMap;
if (!map) return;
map.setView([52.5175, 13.378], 14, { animate: false });
map.setView([52.518, 13.383], 15, { animate: false });
});
await page.waitForTimeout(1000);
await page.waitForTimeout(500);
// Click on the route via the visible polyline's bounding box
const routePath = page.locator(".leaflet-overlay-pane path").first();
await expect(routePath).toBeAttached({ timeout: 5000 });
const box = await routePath.boundingBox();
if (!box) throw new Error("Route polyline not visible");
// Click on a known route coordinate to insert a waypoint
const pixel = await latLngToPixel(page, 52.518, 13.383);
await page.mouse.click(pixel.x, pixel.y);
// Click the center of the route path bounding box
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
// The click on the map near the route should trigger RouteInteraction
// which shows the ghost and inserts a waypoint. If the click was within
// snap tolerance, a waypoint is inserted. Otherwise, a new waypoint is
// appended (map click). Either way we get 3 waypoints.
// Should now have 3 waypoints (original 2 + inserted)
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 });
});
@ -155,6 +150,8 @@ test.describe("Planner", () => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
await mockBRouter(page);
const waypoints = [
{ lat: 52.520, lon: 13.405 },
{ lat: 52.515, lon: 13.351 },
@ -163,8 +160,9 @@ test.describe("Planner", () => {
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
// Wait for route to compute and fit
await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 });
const sidebar = page.locator("aside");
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
// The map should have zoomed in to the route bounds (zoom > default 6)
const zoom = await page.evaluate(() => {
@ -235,4 +233,80 @@ test.describe("Planner", () => {
await expect(page).toHaveURL(/\/session\//, { timeout: 10000 });
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
});
test("toggle overnight on waypoint shows day breakdown in sidebar", async ({ page, request }) => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
await mockBRouter(page);
await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([
{ lat: 52.520, lon: 13.405 },
{ lat: 52.516, lon: 13.377 },
{ lat: 52.510, lon: 13.390 },
]))}`);
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 5000 });
const sidebar = page.locator("aside");
await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 });
// Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon)
const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") });
const secondRow = waypointRows.nth(1);
await secondRow.hover();
const moonButton = secondRow.getByTitle(/overnight/i);
await moonButton.click();
// Day breakdown should appear in the sidebar
await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 });
await expect(sidebar.getByText("Day 2")).toBeVisible({ timeout: 5000 });
});
test("export GPX with day breaks includes overnight metadata", async ({ page, request }) => {
const sessionResp = await request.post("/api/sessions", { data: {} });
const { url } = await sessionResp.json();
// Import a GPX with overnight waypoints
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<wpt lat="52.520" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="51.840" lon="12.243"><name>Dessau</name><type>overnight</type></wpt>
<wpt lat="50.980" lon="11.028"><name>Erfurt</name></wpt>
<trk><trkseg>
<trkpt lat="52.520" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.840" lon="12.243"><ele>80</ele></trkpt>
<trkpt lat="50.980" lon="11.028"><ele>195</ele></trkpt>
</trkseg></trk>
</gpx>`;
await page.goto(url);
await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 });
// Drop the GPX file onto the map
const dataTransfer = await page.evaluateHandle((content) => {
const dt = new DataTransfer();
const file = new File([content], "multi-day.gpx", { type: "application/gpx+xml" });
dt.items.add(file);
return dt;
}, gpx);
const map = page.locator(".leaflet-container");
await map.dispatchEvent("dragenter", { dataTransfer });
await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 });
page.on("dialog", (dialog) => dialog.accept());
await map.dispatchEvent("drop", { dataTransfer });
// Wait for waypoints to load
await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 });
// The overnight waypoint should show day breakdown in the sidebar
const sidebar = page.locator("aside");
await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 });
});
});

View file

@ -280,6 +280,36 @@ function computeDays(
The Planner's `useDays()` hook maps its Yjs waypoints + EnrichedRoute into
this same shape before calling `computeDays`.
### D13: Sidebar waypoint hover highlights map marker
Hovering a waypoint row in the `WaypointSidebar` passes the waypoint index
up to `SessionView` via an `onWaypointHover` callback. `PlannerMap` receives
a `highlightedWaypoint` index and renders the corresponding marker with a
CSS `scale(1.17)` transform (0.2s ease transition). This reuses the existing
`waypointIcon` function with a `highlighted` parameter — no extra DOM
elements or Leaflet layers needed.
### D14: Journal route detail — day segment hover interaction
Hovering a day row in the route detail day breakdown triggers two effects:
1. **Map segment highlighting**: The hovered day's polyline thickens (weight 5,
opacity 1) while other days dim (weight 2, opacity 0.3). Implemented via
`highlightedDay` state passed to `DayColoredRoute`.
2. **Fly-to-segment**: A `FlyToSegment` component calls `map.flyToBounds()`
on the hovered segment's bounds (200ms animation). On mouse leave it flies
back to the full route bounds. The full route bounds are cached on first
render to avoid recomputation.
### D15: Per-day GPX export endpoint
The existing `/api/routes/:id/gpx` endpoint gains an optional `?day=N` query
parameter. When present, it parses the stored GPX, runs `computeDays()` to
find the track point range for that day, and returns a GPX containing only
that day's track segment. The filename includes the day number
(`route_day1.gpx`).
## Risks / Trade-offs
- **Segment boundary alignment**: The day computation relies on

View file

@ -26,6 +26,12 @@ and deriving per-day stats from the segment data we already have.
"Day 1 . 120 km".
- **GPX export**: Day-break waypoints exported with a `<type>overnight</type>`
element so the structure survives round-trips.
- **Waypoint highlighting**: Hovering a waypoint in the sidebar highlights
the corresponding marker on the map with a smooth scale animation.
- **Journal day interaction**: Hovering a day in the Journal route detail
highlights that segment on the map and flies the map to fit it.
- **Per-day GPX export**: Each day in the Journal route detail has a GPX
download button that exports just that day's track segment.
All state lives in Yjs. No database changes are needed -- the Planner remains
stateless. The visual design is already specified in the `visual-redesign`
@ -46,7 +52,9 @@ the data model, computation logic, and integration wiring.
- `gpx-export`: Day-break metadata in exported GPX waypoints
- `gpx-import`: Parse `<type>overnight</type>` waypoints to restore day breaks
- `route-management`: Populate `dayBreaks` column on save, expose per-day stats
- `journal-route-detail`: Day breakdown display with per-day stats and map segments
- `journal-route-detail`: Day breakdown display with per-day stats, map segment
highlighting, fly-to-segment on hover, per-day GPX export
- `planner-sidebar`: Waypoint hover highlights corresponding map marker
## Non-Goals
@ -78,6 +86,9 @@ the data model, computation logic, and integration wiring.
- **Journal route storage**: `updateRoute` populates `dayBreaks` column from
parsed waypoint indices when saving GPX
- **Journal route detail**: Day breakdown section with per-day stats (distance,
ascent, descent) when `dayBreaks` is non-empty
ascent, descent) when `dayBreaks` is non-empty. Hover highlights segment on
map and flies to fit. Per-day GPX download via `?day=N` query param.
- **Sidebar hover**: Hovering waypoint rows highlights the marker on the map
with CSS `scale(1.17)` transition (0.2s ease)
- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de)
in both planner and journal namespaces

View file

@ -0,0 +1,64 @@
## 1. Data Model & Computation
- [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
- [x] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
- [x] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
- [x] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
- [x] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
## 3. Map Integration
- [x] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
- [x] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
- [x] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
## 4. Elevation Chart
- [x] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
- [x] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
## 5. GPX Roundtrip
- [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
- [x] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
## 6. Journal Integration
- [x] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column
- [x] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`)
- [x] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty
- [x] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist
## 7. i18n
- [x] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
- [x] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
## 8. Planner Waypoint Hover
- [x] 8.0a Add `onWaypointHover` callback to `WaypointSidebar`, emit waypoint index on row hover
- [x] 8.0b Pass `highlightedWaypoint` index to `PlannerMap`, render highlighted marker with CSS `scale(1.17)` and 0.2s ease transition
## 9. Journal Day Interaction
- [x] 9.1 Add `highlightedDay` state to route detail page, pass to `RouteMapThumbnail`
- [x] 9.2 Highlight hovered day segment on map: thicken active (weight 5), dim others (opacity 0.3)
- [x] 9.3 Fly map to hovered day segment bounds (200ms), fly back to full route on mouse leave
- [x] 9.4 Add per-day GPX download button on each day row in route detail breakdown
- [x] 9.5 Extend `/api/routes/:id/gpx` with `?day=N` query param to export single day's track segment
## 10. Testing
- [x] 10.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
- [x] 10.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
- [x] 10.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved
- [x] 10.4 Unit tests for `dayBreaks` extraction in route update logic
- [x] 10.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
- [x] 10.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
- [x] 10.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page

View file

@ -1,51 +0,0 @@
## 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[]`
## 2. Sidebar Day Breakdown
- [ ] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
- [ ] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
- [ ] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
- [ ] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
## 3. Map Integration
- [ ] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
- [ ] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
- [ ] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
## 4. Elevation Chart
- [ ] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
- [ ] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
## 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
- [ ] 5.3 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
## 6. Journal Integration
- [ ] 6.1 Update `updateRoute` in `apps/journal/app/lib/routes.server.ts` to extract `dayBreaks` indices from parsed GPX waypoints and write to `dayBreaks` column
- [ ] 6.2 Expose `dayBreaks` and per-day stats in route detail loader (`routes.$id.tsx`)
- [ ] 6.3 Add day breakdown section to route detail page: per-day distance, ascent, descent, start/end names — shown only when dayBreaks is non-empty
- [ ] 6.4 Color route map segments per day (alternating colors) on the route detail map when dayBreaks exist
## 7. i18n
- [ ] 7.1 Add Planner translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Übernachtung markieren"), per-day stats, route summary
- [ ] 7.2 Add Journal translation keys for en + de: day breakdown header, per-day stats labels
## 8. Testing
- [ ] 8.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
- [ ] 8.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
- [ ] 8.3 Unit tests for GPX roundtrip: generate with `isDayBreak`, parse back, verify `isDayBreak` preserved
- [ ] 8.4 Unit tests for `dayBreaks` extraction in route update logic
- [ ] 8.5 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
- [ ] 8.6 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
- [ ] 8.7 E2E test: save multi-day route to Journal, verify day breakdown displays on route detail page

View file

@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Overnight waypoint markers
Any waypoint SHALL be toggleable as an overnight stop, creating day boundaries in the route.
#### Scenario: Toggle overnight
- **WHEN** a user toggles the overnight flag on a waypoint
- **THEN** the waypoint's `overnight: true` flag is set in the Yjs document
- **AND** the route is visually divided into days at that point
#### Scenario: Implicit day boundaries
- **WHEN** overnight stops are set
- **THEN** the first waypoint is the implicit start of Day 1 and the last waypoint is the implicit end of the final day
### Requirement: Per-day statistics
The Planner SHALL compute and display distance, ascent, and estimated duration for each day.
#### Scenario: Day stats computed
- **WHEN** a route has overnight waypoints
- **THEN** per-day distance, total ascent, and estimated duration are derived from segment boundaries and coordinates
### Requirement: Day-aware sidebar
The sidebar SHALL group waypoints by day with collapsible sections and per-day stats.
#### Scenario: Day breakdown
- **WHEN** a route has multiple days
- **THEN** waypoints are grouped under "Day 1", "Day 2", etc. with collapsible sections
- **AND** each section header shows day distance and ascent
### Requirement: Elevation chart day dividers
The elevation chart SHALL show day boundaries as dashed vertical lines.
#### Scenario: Day dividers on chart
- **WHEN** a route has multiple days
- **THEN** dashed vertical lines with "Day N" labels appear at each overnight waypoint position
### Requirement: Map day labels
The map SHALL display day summary labels at day boundary waypoints.
#### Scenario: Day labels on map
- **WHEN** a route has multiple days
- **THEN** white pill markers at day boundaries show "Day N . X km"
### Requirement: Multi-day GPX export
Day structure SHALL be preserved in GPX exports via waypoint type elements.
#### Scenario: Export multi-day route
- **WHEN** a user exports a plan with overnight waypoints
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
- **AND** reimporting the GPX restores the day structure

View file

@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import { computeDays } from "./compute-days.ts";
import type { Waypoint } from "@trails-cool/types";
import type { TrackPoint } from "./types.ts";
function makeTrack(coords: [number, number, number][]): TrackPoint[] {
return coords.map(([lat, lon, ele]) => ({ lat, lon, ele }));
}
describe("computeDays", () => {
it("returns empty array for no waypoints", () => {
expect(computeDays([], [[{ lat: 0, lon: 0 }]])).toEqual([]);
});
it("returns empty array for no tracks", () => {
expect(computeDays([{ lat: 0, lon: 0 }], [])).toEqual([]);
});
it("returns single day when no overnight waypoints", () => {
const waypoints: Waypoint[] = [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 51.34, lon: 12.375, name: "Leipzig" },
];
const track = makeTrack([
[52.52, 13.405, 34],
[52.0, 13.0, 50],
[51.34, 12.375, 113],
]);
const days = computeDays(waypoints, [track]);
expect(days).toHaveLength(1);
expect(days[0]!.dayNumber).toBe(1);
expect(days[0]!.startName).toBe("Berlin");
expect(days[0]!.endName).toBe("Leipzig");
expect(days[0]!.distance).toBeGreaterThan(0);
});
it("splits into two days at overnight waypoint", () => {
const waypoints: Waypoint[] = [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
];
const track = makeTrack([
[52.52, 13.405, 34],
[52.2, 12.8, 60],
[51.84, 12.243, 80],
[51.4, 11.6, 200],
[50.98, 11.028, 195],
]);
const days = computeDays(waypoints, [track]);
expect(days).toHaveLength(2);
expect(days[0]!.dayNumber).toBe(1);
expect(days[0]!.startName).toBe("Berlin");
expect(days[0]!.endName).toBe("Dessau");
expect(days[1]!.dayNumber).toBe(2);
expect(days[1]!.startName).toBe("Dessau");
expect(days[1]!.endName).toBe("Erfurt");
// Total distance should roughly equal sum of days
const totalDist = days[0]!.distance + days[1]!.distance;
expect(totalDist).toBeGreaterThan(0);
});
it("splits into three days with two overnight stops", () => {
const waypoints: Waypoint[] = [
{ lat: 0, lon: 0, name: "A" },
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
{ lat: 2, lon: 0, name: "C", isDayBreak: true },
{ lat: 3, lon: 0, name: "D" },
];
const track = makeTrack([
[0, 0, 0], [0.5, 0, 10], [1, 0, 20],
[1.5, 0, 30], [2, 0, 40],
[2.5, 0, 50], [3, 0, 60],
]);
const days = computeDays(waypoints, [track]);
expect(days).toHaveLength(3);
expect(days[0]!.startName).toBe("A");
expect(days[0]!.endName).toBe("B");
expect(days[1]!.startName).toBe("B");
expect(days[1]!.endName).toBe("C");
expect(days[2]!.startName).toBe("C");
expect(days[2]!.endName).toBe("D");
});
it("computes ascent and descent per day", () => {
const waypoints: Waypoint[] = [
{ lat: 0, lon: 0 },
{ lat: 1, lon: 0, isDayBreak: true },
{ lat: 2, lon: 0 },
];
const track = makeTrack([
[0, 0, 100],
[0.5, 0, 200], // +100 ascent
[1, 0, 150], // -50 descent
[1.5, 0, 50], // -100 descent
[2, 0, 250], // +200 ascent
]);
const days = computeDays(waypoints, [track]);
expect(days).toHaveLength(2);
expect(days[0]!.ascent).toBe(100);
expect(days[0]!.descent).toBe(50);
expect(days[1]!.ascent).toBe(200);
expect(days[1]!.descent).toBe(100);
});
it("handles overnight on first waypoint gracefully", () => {
const waypoints: Waypoint[] = [
{ lat: 0, lon: 0, isDayBreak: true },
{ lat: 1, lon: 0 },
];
const track = makeTrack([[0, 0, 0], [1, 0, 100]]);
const days = computeDays(waypoints, [track]);
// First waypoint is implicit start of Day 1, isDayBreak on it creates
// a zero-length day followed by the real day
expect(days.length).toBeGreaterThanOrEqual(1);
});
it("handles overnight on last waypoint gracefully", () => {
const waypoints: Waypoint[] = [
{ lat: 0, lon: 0 },
{ lat: 1, lon: 0, isDayBreak: true },
];
const track = makeTrack([[0, 0, 0], [1, 0, 100]]);
const days = computeDays(waypoints, [track]);
// Last waypoint is implicit end, isDayBreak on it = single day
expect(days.length).toBeGreaterThanOrEqual(1);
});
it("handles single waypoint", () => {
const waypoints: Waypoint[] = [{ lat: 0, lon: 0 }];
const track = makeTrack([[0, 0, 0]]);
const days = computeDays(waypoints, [track]);
// Single waypoint can't form a meaningful day with distance
expect(days).toHaveLength(0);
});
});

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

@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { generateGpx } from "./generate.ts";
import { parseGpxAsync } from "./parse.ts";
/**
* Tests the dayBreaks extraction pattern used by the Journal's updateRoute:
* parse GPX find waypoints with isDayBreak extract indices
*/
describe("dayBreaks extraction from GPX", () => {
it("extracts empty dayBreaks when no overnight waypoints", async () => {
const gpx = generateGpx({
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
],
tracks: [[
{ lat: 52.52, lon: 13.405, ele: 34 },
{ lat: 50.98, lon: 11.028, ele: 195 },
]],
});
const parsed = await parseGpxAsync(gpx);
const dayBreaks = parsed.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
expect(dayBreaks).toEqual([]);
});
it("extracts single dayBreak index", async () => {
const gpx = generateGpx({
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
],
tracks: [[
{ lat: 52.52, lon: 13.405, ele: 34 },
{ lat: 51.84, lon: 12.243, ele: 80 },
{ lat: 50.98, lon: 11.028, ele: 195 },
]],
});
const parsed = await parseGpxAsync(gpx);
const dayBreaks = parsed.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
expect(dayBreaks).toEqual([1]);
});
it("extracts multiple dayBreak indices", async () => {
const gpx = generateGpx({
waypoints: [
{ lat: 0, lon: 0, name: "A" },
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
{ lat: 2, lon: 0, name: "C" },
{ lat: 3, lon: 0, name: "D", isDayBreak: true },
{ lat: 4, lon: 0, name: "E" },
],
tracks: [[
{ lat: 0, lon: 0 }, { lat: 1, lon: 0 }, { lat: 2, lon: 0 },
{ lat: 3, lon: 0 }, { lat: 4, lon: 0 },
]],
});
const parsed = await parseGpxAsync(gpx);
const dayBreaks = parsed.waypoints
.map((w, i) => (w.isDayBreak ? i : -1))
.filter((i) => i >= 0);
expect(dayBreaks).toEqual([1, 3]);
});
});

View file

@ -48,4 +48,59 @@ describe("generateGpx", () => {
expect(parsed.waypoints[0]!.name).toBe("Start");
expect(parsed.tracks[0]).toHaveLength(2);
});
it("emits <type>overnight</type> for isDayBreak waypoints", () => {
const gpx = generateGpx({
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
],
});
expect(gpx).toContain("<type>overnight</type>");
// Only Dessau should have the type element
expect(gpx.match(/<type>overnight<\/type>/g)).toHaveLength(1);
});
it("round-trips isDayBreak through generate and parse", async () => {
const gpx = generateGpx({
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 51.84, lon: 12.243, name: "Dessau", isDayBreak: true },
{ lat: 50.98, lon: 11.028, name: "Erfurt" },
],
tracks: [[
{ lat: 52.52, lon: 13.405, ele: 34 },
{ lat: 51.84, lon: 12.243, ele: 80 },
{ lat: 50.98, lon: 11.028, ele: 195 },
]],
});
const parsed = await parseGpxAsync(gpx);
expect(parsed.waypoints).toHaveLength(3);
expect(parsed.waypoints[0]!.isDayBreak).toBeUndefined();
expect(parsed.waypoints[1]!.isDayBreak).toBe(true);
expect(parsed.waypoints[2]!.isDayBreak).toBeUndefined();
});
it("splits tracks by day with splitByDay option", () => {
const gpx = generateGpx({
waypoints: [
{ lat: 0, lon: 0, name: "A" },
{ lat: 1, lon: 0, name: "B", isDayBreak: true },
{ lat: 2, lon: 0, name: "C" },
],
tracks: [[
{ lat: 0, lon: 0, ele: 0 },
{ lat: 0.5, lon: 0, ele: 50 },
{ lat: 1, lon: 0, ele: 100 },
{ lat: 1.5, lon: 0, ele: 150 },
{ lat: 2, lon: 0, ele: 200 },
]],
splitByDay: true,
});
// Should have two <trk> elements
expect(gpx.match(/<trk>/g)).toHaveLength(2);
expect(gpx).toContain("<name>Day 1: A - B</name>");
expect(gpx).toContain("<name>Day 2: B - C</name>");
});
});

View file

@ -13,6 +13,8 @@ export function generateGpx(options: {
waypoints?: Waypoint[];
tracks?: TrackPoint[][];
noGoAreas?: NoGoArea[];
/** When true, splits tracks at overnight waypoints into separate <trk> elements per day */
splitByDay?: boolean;
}): string {
const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0;
const lines: string[] = [
@ -33,24 +35,52 @@ 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>");
}
}
if (options.tracks) {
for (const track of options.tracks) {
lines.push(" <trk>", " <trkseg>");
for (const pt of track) {
lines.push(` <trkpt lat="${pt.lat}" lon="${pt.lon}">`);
if (pt.ele !== undefined) {
lines.push(` <ele>${pt.ele}</ele>`);
const allPoints = options.tracks.flat();
if (options.splitByDay && options.waypoints) {
// Split track into per-day segments at overnight waypoints
const dayTracks = splitTrackByDays(allPoints, options.waypoints);
for (const dayTrack of dayTracks) {
lines.push(" <trk>");
if (dayTrack.name) {
lines.push(` <name>${escapeXml(dayTrack.name)}</name>`);
}
if (pt.time) {
lines.push(` <time>${escapeXml(pt.time)}</time>`);
lines.push(" <trkseg>");
for (const pt of dayTrack.points) {
lines.push(` <trkpt lat="${pt.lat}" lon="${pt.lon}">`);
if (pt.ele !== undefined) {
lines.push(` <ele>${pt.ele}</ele>`);
}
if (pt.time) {
lines.push(` <time>${escapeXml(pt.time)}</time>`);
}
lines.push(" </trkpt>");
}
lines.push(" </trkpt>");
lines.push(" </trkseg>", " </trk>");
}
} else {
for (const track of options.tracks) {
lines.push(" <trk>", " <trkseg>");
for (const pt of track) {
lines.push(` <trkpt lat="${pt.lat}" lon="${pt.lon}">`);
if (pt.ele !== undefined) {
lines.push(` <ele>${pt.ele}</ele>`);
}
if (pt.time) {
lines.push(` <time>${escapeXml(pt.time)}</time>`);
}
lines.push(" </trkpt>");
}
lines.push(" </trkseg>", " </trk>");
}
lines.push(" </trkseg>", " </trk>");
}
}
@ -70,6 +100,54 @@ export function generateGpx(options: {
return lines.join("\n");
}
function splitTrackByDays(
points: TrackPoint[],
waypoints: Waypoint[],
): Array<{ name: string; points: TrackPoint[] }> {
if (points.length === 0 || waypoints.length === 0) return [];
// Find closest track point index for each waypoint
const wpTrackIndices = waypoints.map((wp) => {
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < points.length; i++) {
const dx = points[i]!.lat - wp.lat;
const dy = points[i]!.lon - wp.lon;
const d = dx * dx + dy * dy;
if (d < bestDist) {
bestDist = d;
bestIdx = i;
}
}
return bestIdx;
});
// Build day boundaries from overnight waypoints
const breakWpIndices = waypoints
.map((wp, i) => (wp.isDayBreak ? i : -1))
.filter((i) => i >= 0);
if (breakWpIndices.length === 0) {
return [{ name: "Day 1", points }];
}
const boundaries = [0, ...breakWpIndices.map((i) => wpTrackIndices[i]!), points.length];
const dayTracks: Array<{ name: string; points: TrackPoint[] }> = [];
for (let d = 0; d < boundaries.length - 1; d++) {
const start = boundaries[d]!;
const end = boundaries[d + 1]!;
const startWp = d === 0 ? waypoints[0] : waypoints[breakWpIndices[d - 1]!];
const endWp = d < breakWpIndices.length ? waypoints[breakWpIndices[d]!] : waypoints[waypoints.length - 1];
const startName = startWp?.name ?? "";
const endName = endWp?.name ?? "";
const name = startName && endName ? `Day ${d + 1}: ${startName} - ${endName}` : `Day ${d + 1}`;
dayTracks.push({ name, points: points.slice(start, end) });
}
return dayTracks;
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")

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

View file

@ -5,7 +5,7 @@ import type { GpxData } from "./types.ts";
* Uses explicit <wpt> elements if present, otherwise simplifies the
* track using Douglas-Peucker to find significant turning points.
*/
export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string }> {
export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> {
if (gpxData.waypoints.length > 0) return gpxData.waypoints;
if (gpxData.tracks.length === 0) return [];

View file

@ -58,6 +58,17 @@ export default {
waypoints: "Wegpunkte",
notes: "Notizen",
},
multiDay: {
dayLabel: "Tag {{n}}",
dayCount: "{{count}} Tage",
dayCount_one: "1 Tag",
overnight: "Übernachtung",
markOvernight: "Als Übernachtung markieren",
removeOvernight: "Übernachtung entfernen",
distance: "Distanz",
ascent: "Anstieg",
descent: "Abstieg",
},
noGoAreas: {
draw: "Sperrgebiet zeichnen",
cancel: "Sperrgebiet abbrechen",
@ -124,6 +135,8 @@ export default {
delete: "Route löschen",
distance: "Strecke",
elevationGain: "Höhenmeter",
dayBreakdown: "Tagesübersicht",
dayLabel: "Tag {{n}}",
noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!",
noMapPreview: "Keine Kartenvorschau",
saveChanges: "Änderungen speichern",

View file

@ -58,6 +58,17 @@ export default {
waypoints: "Waypoints",
notes: "Notes",
},
multiDay: {
dayLabel: "Day {{n}}",
dayCount: "{{count}} days",
dayCount_one: "1 day",
overnight: "Overnight",
markOvernight: "Mark as overnight stop",
removeOvernight: "Remove overnight stop",
distance: "Distance",
ascent: "Ascent",
descent: "Descent",
},
noGoAreas: {
draw: "Draw no-go area",
cancel: "Cancel no-go area",
@ -124,6 +135,8 @@ export default {
delete: "Delete Route",
distance: "Distance",
elevationGain: "Elevation Gain",
dayBreakdown: "Day Breakdown",
dayLabel: "Day {{n}}",
noRoutesYet: "No routes yet. Create your first route!",
noMapPreview: "No map preview",
saveChanges: "Save Changes",