diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx index 3c48b7f..3ea476d 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -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(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 ? '© OpenStreetMap' : undefined} /> - + {dayBreaks && dayBreaks.length > 0 ? ( + + ) : ( + + )} + {dayBreaks && dayBreaks.length > 0 && ( + + )} ); } + +function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) { + const geometry = extractGeometry(data); + if (!geometry) { + return ; + } + + 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 ( + + ); + })} + + ); +} diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 49e13dd..1bc0880 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -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[] }; } } diff --git a/apps/journal/app/routes/api.routes.$id.gpx.ts b/apps/journal/app/routes/api.routes.$id.gpx.ts index f3b721d..d430ece 100644 --- a/apps/journal/app/routes/api.routes.$id.gpx.ts +++ b/apps/journal/app/routes/api.routes.$id.gpx.ts @@ -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 }); + } } diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 662ba9b..5623e31 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -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(null); const handleEditInPlanner = useCallback(async () => { setEditLoading(true); @@ -156,9 +172,53 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )} + {dayStats.length > 1 && ( +
+

{t("routes.dayBreakdown")}

+
+ {dayStats.map((day) => ( +
setHighlightedDay(day.dayNumber)} + onMouseLeave={() => setHighlightedDay(null)} + > + + {t("routes.dayLabel", { n: day.dayNumber })} + + + {day.startName && day.endName + ? `${day.startName} → ${day.endName}` + : day.startName || day.endName || ""} + + + {(day.distance / 1000).toFixed(1)} km + + + ↑{day.ascent} m + + + ↓{day.descent} m + + {route.hasGpx && ( + + GPX + + )} +
+ ))} +
+
+ )} + {route.geojson && (
- + 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
)} diff --git a/apps/planner/app/components/DayBreakdown.tsx b/apps/planner/app/components/DayBreakdown.tsx new file mode 100644 index 0000000..58cfa9c --- /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("planner"); + 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/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 166f178..999d958 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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([]); const [hoverIdx, setHoverIdx] = useState(null); const [colorMode, setColorMode] = useState("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(() => { diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index f143c09..94e757c 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -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, })); } diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index f1986d6..26d38e4 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -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: `
${index + 1}
`, + transform:translate(-12px,-12px) ${scale}; + transition:transform 0.2s ease; + ">${overnight ? "☾" : index + 1}`, + iconSize: [0, 0], + }); +} + +function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { + return L.divIcon({ + className: "", + html: `
+
Day ${dayNumber} · ${distanceKm} km
+
`, iconSize: [0, 0], }); } @@ -30,6 +51,7 @@ interface WaypointData { lat: number; lon: number; name?: string; + overnight: boolean; } function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] { @@ -37,6 +59,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): 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([]); 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 ( + + ); + })} + {routeCoordinates && routeCoordinates.length >= 2 && ( <> ["routeStats"] }) { +function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; 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
{tab === "waypoints" ? ( - + ) : ( @@ -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(null); const { toasts, addToast } = useToasts(); useAwarenessToasts(yjs, t, addToast); @@ -283,14 +286,14 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
} > - addToast(msg, "error")} /> + addToast(msg, "error")} days={days} /> - + - + {toasts.length > 0 && ( diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index bc98ac9..3c2d645 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -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>): WaypointData[] { @@ -13,6 +18,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): 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([]); 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) => ( +
  • onWaypointHover?.(i)} + onMouseLeave={() => onWaypointHover?.(null)} + > + + {i + 1} + +
    +

    + {wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`} +

    +
    + {wp.overnight && ( + + {t("multiDay.overnight")} + + )} +
    + {/* Overnight toggle — not on first or last waypoint */} + {i > 0 && i < waypoints.length - 1 && ( + + )} + {i > 0 && ( + + )} + {i < waypoints.length - 1 && ( + + )} + +
    +
  • + ); + return (
    + {/* Header with route summary */}

    - Waypoints ({waypoints.length}) + {t("sidebar.waypoints")} ({waypoints.length})

    + {routeStats && routeStats.distance !== undefined && ( +

    + {(routeStats.distance / 1000).toFixed(1)} km + {routeStats.elevationGain !== undefined && ` · ↑${routeStats.elevationGain} m`} + {hasMultipleDays && ` · ${t("multiDay.dayCount", { count: days.length })}`} +

    + )}
    + {/* Waypoint list */}
    {waypoints.length === 0 ? (

    Click on the map to add waypoints

    + ) : hasMultipleDays ? ( + + {(_day, { start, end }) => ( +
      + {waypoints.slice(start, end + 1).map((wp, offset) => renderWaypointRow(wp, start + offset))} +
    + )} +
    ) : (
      - {waypoints.map((wp, i) => ( -
    • - - {i + 1} - -
      -

      - {wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`} -

      -
      -
      - {i > 0 && ( - - )} - {i < waypoints.length - 1 && ( - - )} - -
      -
    • - ))} + {waypoints.map((wp, i) => renderWaypointRow(wp, i))}
    )}
    - {routeStats && routeStats.distance !== undefined && ( + {/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */} + {!hasMultipleDays && routeStats && routeStats.distance !== undefined && (

    {(routeStats.distance / 1000).toFixed(1)} km

    -

    Distance

    +

    {t("multiDay.distance")}

    {routeStats.elevationGain !== undefined && (

    ↑ {routeStats.elevationGain} m

    -

    Ascent

    +

    {t("multiDay.ascent")}

    )} {routeStats.elevationLoss !== undefined && ( @@ -140,7 +206,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {

    ↓ {routeStats.elevationLoss} m

    -

    Descent

    +

    {t("multiDay.descent")}

    )}
    diff --git a/apps/planner/app/lib/overnight.test.ts b/apps/planner/app/lib/overnight.test.ts new file mode 100644 index 0000000..43a4528 --- /dev/null +++ b/apps/planner/app/lib/overnight.test.ts @@ -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>("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 { + const doc = new Y.Doc(); + const arr = doc.getArray>("test"); + const yMap = new Y.Map(); + 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(); + }); +}); 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/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 95135dd..d573a34 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -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(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) { diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index fd7df07..af238d2 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -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 { diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index 33b8311..a0f93ad 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -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 */ } } diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts new file mode 100644 index 0000000..27bb9c8 --- /dev/null +++ b/e2e/fixtures/brouter-mock.ts @@ -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]); +} diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index f28286b..a7d0fc6 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -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 = ` + + Berlin + Dessauovernight + Erfurt + + 34 + 80 + 195 + +`; + + 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", () => { diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index fe6ec4d..fa10ca3 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -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 = ` + + Berlin + Dessauovernight + Erfurt + + 34 + 80 + 195 + +`; + + 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 }); + }); }); diff --git a/openspec/changes/multi-day-routes/.openspec.yaml b/openspec/changes/archive/2026-04-11-multi-day-routes/.openspec.yaml similarity index 100% rename from openspec/changes/multi-day-routes/.openspec.yaml rename to openspec/changes/archive/2026-04-11-multi-day-routes/.openspec.yaml diff --git a/openspec/changes/multi-day-routes/design.md b/openspec/changes/archive/2026-04-11-multi-day-routes/design.md similarity index 88% rename from openspec/changes/multi-day-routes/design.md rename to openspec/changes/archive/2026-04-11-multi-day-routes/design.md index 48aad3b..30c8f98 100644 --- a/openspec/changes/multi-day-routes/design.md +++ b/openspec/changes/archive/2026-04-11-multi-day-routes/design.md @@ -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 diff --git a/openspec/changes/multi-day-routes/proposal.md b/openspec/changes/archive/2026-04-11-multi-day-routes/proposal.md similarity index 83% rename from openspec/changes/multi-day-routes/proposal.md rename to openspec/changes/archive/2026-04-11-multi-day-routes/proposal.md index e09b6b3..6f36536 100644 --- a/openspec/changes/multi-day-routes/proposal.md +++ b/openspec/changes/archive/2026-04-11-multi-day-routes/proposal.md @@ -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 `overnight` 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 `overnight` 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 diff --git a/openspec/changes/multi-day-routes/specs/multi-day-routes/spec.md b/openspec/changes/archive/2026-04-11-multi-day-routes/specs/multi-day-routes/spec.md similarity index 100% rename from openspec/changes/multi-day-routes/specs/multi-day-routes/spec.md rename to openspec/changes/archive/2026-04-11-multi-day-routes/specs/multi-day-routes/spec.md diff --git a/openspec/changes/archive/2026-04-11-multi-day-routes/tasks.md b/openspec/changes/archive/2026-04-11-multi-day-routes/tasks.md new file mode 100644 index 0000000..0bcc587 --- /dev/null +++ b/openspec/changes/archive/2026-04-11-multi-day-routes/tasks.md @@ -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 `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 +- [x] 5.3 Add multi-track export option: split track into one `` 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 diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md deleted file mode 100644 index 717fbcd..0000000 --- a/openspec/changes/multi-day-routes/tasks.md +++ /dev/null @@ -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 `overnight` for waypoints with `isDayBreak: true` -- [ ] 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 - -- [ ] 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 diff --git a/openspec/specs/multi-day-routes/spec.md b/openspec/specs/multi-day-routes/spec.md new file mode 100644 index 0000000..45c50a4 --- /dev/null +++ b/openspec/specs/multi-day-routes/spec.md @@ -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 `overnight` element in the GPX +- **AND** reimporting the GPX restores the day structure diff --git a/packages/gpx/src/compute-days.test.ts b/packages/gpx/src/compute-days.test.ts new file mode 100644 index 0000000..0702daf --- /dev/null +++ b/packages/gpx/src/compute-days.test.ts @@ -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); + }); +}); 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/daybreaks-extraction.test.ts b/packages/gpx/src/daybreaks-extraction.test.ts new file mode 100644 index 0000000..b43e3f0 --- /dev/null +++ b/packages/gpx/src/daybreaks-extraction.test.ts @@ -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]); + }); +}); diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts index 0eefc9f..f92fab1 100644 --- a/packages/gpx/src/generate.test.ts +++ b/packages/gpx/src/generate.test.ts @@ -48,4 +48,59 @@ describe("generateGpx", () => { expect(parsed.waypoints[0]!.name).toBe("Start"); expect(parsed.tracks[0]).toHaveLength(2); }); + + it("emits overnight 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("overnight"); + // Only Dessau should have the type element + expect(gpx.match(/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 elements + expect(gpx.match(//g)).toHaveLength(2); + expect(gpx).toContain("Day 1: A - B"); + expect(gpx).toContain("Day 2: B - C"); + }); }); diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index 1be51f2..d108bc1 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -13,6 +13,8 @@ export function generateGpx(options: { waypoints?: Waypoint[]; tracks?: TrackPoint[][]; noGoAreas?: NoGoArea[]; + /** When true, splits tracks at overnight waypoints into separate 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(` ${escapeXml(wpt.name)}`); } + if (wpt.isDayBreak) { + lines.push(" overnight"); + } lines.push(" "); } } if (options.tracks) { - for (const track of options.tracks) { - lines.push(" ", " "); - for (const pt of track) { - lines.push(` `); - if (pt.ele !== undefined) { - lines.push(` ${pt.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(" "); + if (dayTrack.name) { + lines.push(` ${escapeXml(dayTrack.name)}`); } - if (pt.time) { - lines.push(` `); + lines.push(" "); + for (const pt of dayTrack.points) { + lines.push(` `); + if (pt.ele !== undefined) { + lines.push(` ${pt.ele}`); + } + if (pt.time) { + lines.push(` `); + } + lines.push(" "); } - lines.push(" "); + lines.push(" ", " "); + } + } else { + for (const track of options.tracks) { + lines.push(" ", " "); + for (const pt of track) { + lines.push(` `); + if (pt.ele !== undefined) { + lines.push(` ${pt.ele}`); + } + if (pt.time) { + lines.push(` `); + } + lines.push(" "); + } + lines.push(" ", " "); } - lines.push(" ", " "); } } @@ -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, "&") 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 }; }); } diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index dddfffb..2283b88 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -5,7 +5,7 @@ import type { GpxData } from "./types.ts"; * Uses explicit 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 []; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 51f0a30..1a0527e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 31490a8..da27412 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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",