From 16568ffa4598bd7bb90d49b07285558dbe9193a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:37:43 +0100 Subject: [PATCH 001/684] Fix no-go button click creating a waypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button was rendered as a React component, not a Leaflet control, so clicks propagated to the map. Use L.DomEvent.disableClickPropagation on the container — same approach Leaflet's built-in controls use. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/PlannerMap.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index d8d3499..0ec1aef 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -170,8 +170,15 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { } function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) { + const ref = useRef(null); + + // Prevent clicks from reaching the Leaflet map (same as built-in controls) + useEffect(() => { + if (ref.current) L.DomEvent.disableClickPropagation(ref.current); + }, []); + return ( -
+
Date: Fri, 3 Apr 2026 10:46:12 +0100 Subject: [PATCH 002/684] Export only computed track, not editing waypoints Editing waypoints represent where the user clicked, not the actual route. Exporting them causes reimported routes to look different because BRouter re-routes without no-go areas. Now both GPX export and Save to Journal only include the computed track. On reimport, Douglas-Peucker extracts waypoints from the track shape, preserving the route including no-go detours. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 13 ++++--------- apps/planner/app/components/SaveToJournalButton.tsx | 11 +++-------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index b4dff4e..769a982 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -9,14 +9,9 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { const { t } = useTranslation("planner"); const handleExport = useCallback(() => { - // Get waypoints from Yjs - const waypoints = 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, - })); - - // Get route track from GeoJSON + // Export only the computed track, not editing waypoints. + // The track already reflects no-go area avoidance and BRouter's routing. + // On reimport, waypoints are extracted from the track shape. let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -37,7 +32,7 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { } } - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); // Download const blob = new Blob([gpx], { type: "application/gpx+xml" }); diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index af0389d..641707d 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -23,13 +23,8 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl setError(null); try { - // Build GPX from current Yjs state - const waypoints = 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, - })); - + // Build GPX from computed track (not editing waypoints). + // The track reflects the actual routed path including no-go avoidance. let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -42,7 +37,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } catch { /* invalid geojson */ } } - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); // POST to Journal callback const response = await fetch(callbackUrl, { From 4b711abaec40047e686176cb7aba132d5dcc0d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:52:39 +0100 Subject: [PATCH 003/684] Add split export: Export Route vs Export Plan Split button with default "Export GPX" (track only) and dropdown: - Export Route: clean track for use in any app - Export Plan: track + waypoints + no-go areas in GPX extensions (trails:planning namespace) for reimporting into the planner Also: - Add no-go area serialization to generateGpx (GPX extensions) - Parse no-go areas from GPX extensions on import - Initialize no-go areas in Yjs session from imported GPX - Save to Journal now exports track only (consistent with Export Route) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 142 ++++++++++++------ .../app/components/SaveToJournalButton.tsx | 10 +- apps/planner/app/lib/sessions.ts | 16 ++ apps/planner/app/routes/api.sessions.ts | 5 +- apps/planner/app/routes/new.tsx | 5 +- packages/gpx/src/generate.ts | 22 ++- packages/gpx/src/index.ts | 2 +- packages/gpx/src/parse.ts | 21 ++- packages/gpx/src/types.ts | 5 + packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 11 files changed, 172 insertions(+), 60 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index b4dff4e..840e0b9 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -1,60 +1,110 @@ -import { useCallback } from "react"; +import { useCallback, useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { generateGpx } from "@trails-cool/gpx"; -import type { TrackPoint } from "@trails-cool/gpx"; +import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; + +function getTracks(yjs: YjsState): TrackPoint[][] { + const geojsonStr = yjs.routeData.get("geojson") as string | undefined; + if (!geojsonStr) return []; + try { + const geojson = JSON.parse(geojsonStr); + const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; + if (coords.length > 0) { + return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; + } + } catch { /* invalid geojson */ } + return []; +} + +function getWaypoints(yjs: YjsState) { + return 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, + })); +} + +function getNoGoAreas(yjs: YjsState): NoGoArea[] { + return yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ + points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], + })).filter((a) => a.points.length >= 3); +} + +function download(gpx: string, filename: string) { + const blob = new Blob([gpx], { type: "application/gpx+xml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} export function ExportButton({ yjs }: { yjs: YjsState }) { const { t } = useTranslation("planner"); + const [open, setOpen] = useState(false); + const ref = useRef(null); - const handleExport = useCallback(() => { - // Get waypoints from Yjs - const waypoints = 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, - })); + // Close dropdown on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("click", handler); + return () => document.removeEventListener("click", handler); + }, [open]); - // Get route track from GeoJSON - let tracks: TrackPoint[][] = []; - const geojsonStr = yjs.routeData.get("geojson") as string | undefined; - if (geojsonStr) { - try { - const geojson = JSON.parse(geojsonStr); - const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; - if (coords.length > 0) { - tracks = [ - coords.map((c) => ({ - lat: c[1]!, - lon: c[0]!, - ele: c[2], - })), - ]; - } - } catch { - // Invalid GeoJSON - } - } + const handleExportRoute = useCallback(() => { + const tracks = getTracks(yjs); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); + download(gpx, "route.gpx"); + setOpen(false); + }, [yjs]); - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); - - // Download - const blob = new Blob([gpx], { type: "application/gpx+xml" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "route.gpx"; - a.click(); - URL.revokeObjectURL(url); - }, [yjs.waypoints, yjs.routeData]); + const handleExportPlan = useCallback(() => { + const tracks = getTracks(yjs); + const waypoints = getWaypoints(yjs); + const noGoAreas = getNoGoAreas(yjs); + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); + download(gpx, "route-plan.gpx"); + setOpen(false); + }, [yjs]); return ( - +
+
+ + +
+ {open && ( +
+ + +
+ )} +
); } diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index af0389d..ae9b4d3 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -23,13 +23,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl setError(null); try { - // Build GPX from current Yjs state - const waypoints = 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, - })); - + // Build GPX from computed track (not editing waypoints). let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -42,7 +36,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } catch { /* invalid geojson */ } } - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks }); + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); // POST to Journal callback const response = await fetch(callbackUrl, { diff --git a/apps/planner/app/lib/sessions.ts b/apps/planner/app/lib/sessions.ts index 728f380..9b4bb33 100644 --- a/apps/planner/app/lib/sessions.ts +++ b/apps/planner/app/lib/sessions.ts @@ -90,6 +90,22 @@ export function initializeSessionWithWaypoints( }); } +export function initializeSessionWithNoGoAreas( + sessionId: string, + noGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }>, +): void { + const doc = getOrCreateDoc(sessionId); + const yNoGoAreas = doc.getArray("noGoAreas"); + + doc.transact(() => { + for (const area of noGoAreas) { + const yMap = new Y.Map(); + yMap.set("points", area.points); + yNoGoAreas.push([yMap]); + } + }); +} + export async function listSessions(): Promise { return getDb() .select() diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 3364385..f458bfc 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.sessions"; -import { createSession, listSessions } from "~/lib/sessions"; +import { createSession, listSessions, initializeSessionWithNoGoAreas } from "~/lib/sessions"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { withDb } from "@trails-cool/db"; @@ -25,6 +25,9 @@ export async function action({ request }: Route.ActionArgs) { const gpxData = await parseGpxAsync(gpx); const wps = extractWaypoints(gpxData); if (wps.length > 0) initialWaypoints = wps; + if (gpxData.noGoAreas.length > 0) { + initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas); + } } catch { // Continue with empty session if GPX is invalid } diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index 4150c29..121e0f6 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -1,6 +1,6 @@ import { redirect, data } from "react-router"; import type { Route } from "./+types/new"; -import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions"; +import { createSession, initializeSessionWithWaypoints, initializeSessionWithNoGoAreas } from "~/lib/sessions"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { checkRateLimit } from "~/lib/rate-limit"; @@ -37,6 +37,9 @@ export async function loader({ request }: Route.LoaderArgs) { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData)); + if (gpxData.noGoAreas.length > 0) { + initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas); + } } catch { // Continue with empty session if GPX is invalid } diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index d0f0268..1be51f2 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -4,15 +4,23 @@ import type { TrackPoint } from "./types.ts"; /** * Generate a GPX XML string from waypoints and track points. */ +export interface NoGoArea { + points: Array<{ lat: number; lon: number }>; +} + export function generateGpx(options: { name?: string; waypoints?: Waypoint[]; tracks?: TrackPoint[][]; + noGoAreas?: NoGoArea[]; }): string { + const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0; const lines: string[] = [ '', '', + ' xmlns="http://www.topografix.com/GPX/1/1"' + + (hasExtensions ? '\n xmlns:trails="https://trails.cool/gpx/1"' : "") + + ">", ]; if (options.name) { @@ -46,6 +54,18 @@ export function generateGpx(options: { } } + if (options.noGoAreas && options.noGoAreas.length > 0) { + lines.push(" ", " "); + for (const area of options.noGoAreas) { + lines.push(" "); + for (const pt of area.points) { + lines.push(` `); + } + lines.push(" "); + } + lines.push(" ", " "); + } + lines.push(""); return lines.join("\n"); } diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index f8887f2..6830ada 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,4 +1,4 @@ export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.ts"; export { extractWaypoints } from "./waypoints.ts"; -export type { GpxData, TrackPoint, ElevationProfile } from "./types.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 1ff1b79..18c577e 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -1,5 +1,5 @@ import type { Waypoint } from "@trails-cool/types"; -import type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; +import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts"; /** * Parse a GPX XML string into structured data. @@ -46,9 +46,10 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { const name = doc.querySelector("metadata > name")?.textContent ?? undefined; const waypoints = parseWaypoints(doc); const tracks = parseTracks(doc); + const noGoAreas = parseNoGoAreas(doc); const { totalDistance, ...elevation } = computeElevation(tracks); - return { name, waypoints, tracks, distance: totalDistance, elevation }; + return { name, waypoints, tracks, noGoAreas, distance: totalDistance, elevation }; } function parseWaypoints(doc: Document): Waypoint[] { @@ -85,6 +86,22 @@ function parseTracks(doc: Document): TrackPoint[][] { return tracks; } +function parseNoGoAreas(doc: Document): NoGoArea[] { + const areas: NoGoArea[] = []; + const nogos = doc.querySelectorAll("nogo"); + for (const nogo of Array.from(nogos)) { + const points: Array<{ lat: number; lon: number }> = []; + const pts = nogo.querySelectorAll("point"); + for (const pt of Array.from(pts)) { + const lat = parseFloat(pt.getAttribute("lat") ?? "0"); + const lon = parseFloat(pt.getAttribute("lon") ?? "0"); + points.push({ lat, lon }); + } + if (points.length >= 3) areas.push({ points }); + } + return areas; +} + function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } { let gain = 0; let loss = 0; diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts index bb7d0a2..9d8a30f 100644 --- a/packages/gpx/src/types.ts +++ b/packages/gpx/src/types.ts @@ -14,10 +14,15 @@ export interface ElevationProfile { elevation: number; } +export interface NoGoArea { + points: Array<{ lat: number; lon: number }>; +} + export interface GpxData { name?: string; waypoints: Waypoint[]; tracks: TrackPoint[][]; + noGoAreas: NoGoArea[]; /** Total distance in meters (haversine, works with or without elevation data) */ distance: number; elevation: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 72080a3..14fb3b1 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -18,6 +18,8 @@ export default { newSession: "Neue Sitzung", saveRoute: "Route speichern", exportGpx: "GPX exportieren", + exportRoute: "Route exportieren", + exportPlan: "Plan exportieren", profile: "Profil", connecting: "Verbinde...", loadingMap: "Karte wird geladen...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 51af6f7..482a07e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -18,6 +18,8 @@ export default { newSession: "New Session", saveRoute: "Save Route", exportGpx: "Export GPX", + exportRoute: "Export Route", + exportPlan: "Export Plan", profile: "Profile", connecting: "Connecting...", loadingMap: "Loading map...", From 721ec9e5e536f4ab22e03ce67b6bc78b4751acb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:53:18 +0100 Subject: [PATCH 004/684] Fix export chevron: wider padding + stop click propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chevron was too narrow (px-1.5 → px-2.5) and clicks were caught by the outside-click handler before toggling the dropdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 840e0b9..3e44a70 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -83,8 +83,8 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { {t("exportGpx")} From 87251ea366febb7e4dddb9acd001bede46557c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 10:56:22 +0100 Subject: [PATCH 005/684] Fix dropdown race: use mousedown for outside-click handler The document click handler registered on open could catch the same click event that opened the dropdown, immediately closing it. Using mousedown avoids the race since it fires before click. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 3e44a70..af784d8 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -47,14 +47,14 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { const [open, setOpen] = useState(false); const ref = useRef(null); - // Close dropdown on outside click + // Close dropdown on outside mousedown useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; - document.addEventListener("click", handler); - return () => document.removeEventListener("click", handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, [open]); const handleExportRoute = useCallback(() => { From e12479293d26667bfabe14f30e0b0673c92c48de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 11:06:58 +0100 Subject: [PATCH 006/684] Add descriptions to export dropdown, fix z-index and alignment - Add description text below each export option explaining the difference - Fix z-index (z-[1001]) so dropdown renders above the map - Align dropdown left instead of right to avoid overflow - Widen dropdown (w-56) to fit descriptions Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/ExportButton.tsx | 14 ++++++++------ packages/i18n/src/locales/de.ts | 2 ++ packages/i18n/src/locales/en.ts | 2 ++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index af784d8..f143c09 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -74,7 +74,7 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { }, [yjs]); return ( -
+
{open && ( -
+
)} diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 14fb3b1..80917ca 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -19,7 +19,9 @@ export default { saveRoute: "Route speichern", exportGpx: "GPX exportieren", exportRoute: "Route exportieren", + exportRouteDesc: "GPX-Track für jede App", exportPlan: "Plan exportieren", + exportPlanDesc: "Mit Wegpunkten und Sperrzonen", profile: "Profil", connecting: "Verbinde...", loadingMap: "Karte wird geladen...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 482a07e..534bbb2 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -19,7 +19,9 @@ export default { saveRoute: "Save Route", exportGpx: "Export GPX", exportRoute: "Export Route", + exportRouteDesc: "Clean GPX track for any app", exportPlan: "Export Plan", + exportPlanDesc: "Includes waypoints and no-go areas", profile: "Profile", connecting: "Connecting...", loadingMap: "Loading map...", From 6ccf7af7b16704888c55fa2365d5e0c59f5636fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:26:54 +0100 Subject: [PATCH 007/684] Fix no-go area parsing for namespaced XML elements querySelectorAll('nogo') doesn't match . Now matches both unprefixed and prefixed element names. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/parse.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 18c577e..e43d249 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -88,10 +88,11 @@ function parseTracks(doc: Document): TrackPoint[][] { function parseNoGoAreas(doc: Document): NoGoArea[] { const areas: NoGoArea[] = []; - const nogos = doc.querySelectorAll("nogo"); + // Match both unprefixed and prefixed (trails:nogo) elements + const nogos = doc.querySelectorAll("nogo, trails\\:nogo"); for (const nogo of Array.from(nogos)) { const points: Array<{ lat: number; lon: number }> = []; - const pts = nogo.querySelectorAll("point"); + const pts = nogo.querySelectorAll("point, trails\\:point"); for (const pt of Array.from(pts)) { const lat = parseFloat(pt.getAttribute("lat") ?? "0"); const lon = parseFloat(pt.getAttribute("lon") ?? "0"); From 45d1360be554112b73d8717c84fffb4f5b0d66e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:32:05 +0100 Subject: [PATCH 008/684] Include no-go areas when saving to journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save to Journal now includes no-go areas in GPX extensions so they round-trip correctly: Planner → Journal → Edit in Planner preserves the no-go polygons. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/SaveToJournalButton.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index ae9b4d3..4c6a053 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { generateGpx } from "@trails-cool/gpx"; -import type { TrackPoint } from "@trails-cool/gpx"; +import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; interface SaveToJournalButtonProps { yjs: YjsState; @@ -23,7 +23,8 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl setError(null); try { - // Build GPX from computed track (not editing waypoints). + // Build GPX from computed track with planning data (no-go areas) + // so the route round-trips correctly through the journal. let tracks: TrackPoint[][] = []; const geojsonStr = yjs.routeData.get("geojson") as string | undefined; if (geojsonStr) { @@ -36,7 +37,11 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl } catch { /* invalid geojson */ } } - const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); + const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ + points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], + })).filter((a) => a.points.length >= 3); + + const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks, noGoAreas }); // POST to Journal callback const response = await fetch(callbackUrl, { From fdfacd299740edf205539c6a6cc4a821a0eb8a83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:41:35 +0100 Subject: [PATCH 009/684] Save full planning state to journal (waypoints + no-go areas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save to Journal now includes waypoints and no-go areas alongside the track — the full planning state needed for round-tripping. Export Route remains the clean track-only option for other apps. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/SaveToJournalButton.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 4c6a053..52f1138 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -41,7 +41,13 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], })).filter((a) => a.points.length >= 3); - const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks, noGoAreas }); + const waypoints = 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, + })); + + const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); // POST to Journal callback const response = await fetch(callbackUrl, { From b6179fbdd2835f365d34e6c89d6e66859f0afca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:47:40 +0100 Subject: [PATCH 010/684] Initialize no-go areas client-side via URL params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In dev mode, the sessions API and the Yjs WebSocket server are separate processes (Vite plugin vs React Router action), so server-side Yjs doc initialization doesn't reach the client. Now no-go areas flow the same way as waypoints: API response → URL params → client-side Yjs initialization. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../journal/app/routes/api.routes.$id.edit-in-planner.ts | 6 +++++- apps/planner/app/components/SessionView.tsx | 5 +++-- apps/planner/app/lib/use-yjs.ts | 8 ++++++++ apps/planner/app/routes/api.sessions.ts | 9 ++++----- apps/planner/app/routes/session.$id.tsx | 6 ++++++ 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts index 5bd38cf..61c809e 100644 --- a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts +++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts @@ -36,13 +36,17 @@ export async function action({ params, request }: Route.ActionArgs) { const session = (await sessionResp.json()) as { url: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; }; - // Encode waypoints in URL params (small — just coordinates, not full GPX) + // Encode planning data in URL params const urlParams = new URLSearchParams({ returnUrl }); if (session.initialWaypoints?.length) { urlParams.set("waypoints", JSON.stringify(session.initialWaypoints)); } + if (session.initialNoGoAreas?.length) { + urlParams.set("noGoAreas", JSON.stringify(session.initialNoGoAreas)); + } return data({ url: `${plannerUrl}${session.url}?${urlParams}`, diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0c2ea56..16a0a19 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -179,12 +179,13 @@ interface SessionViewProps { callbackToken?: string; returnUrl?: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; } -export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) { +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas }: SessionViewProps) { const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); - const yjs = useYjs(sessionId, initialWaypoints); + const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas); const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); const { toasts, addToast } = useToasts(); diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 862b72a..bec224d 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -42,6 +42,7 @@ export interface YjsState { export function useYjs( sessionId: string, initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>, + initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>, ): YjsState | null { const [state, setState] = useState(null); const providerRef = useRef(null); @@ -106,6 +107,13 @@ export function useYjs( if (wp.name) yMap.set("name", wp.name); waypoints.push([yMap]); } + if (initialNoGoAreas?.length && noGoAreas.length === 0) { + for (const area of initialNoGoAreas) { + const yMap = new Y.Map(); + yMap.set("points", area.points); + noGoAreas.push([yMap]); + } + } }); } }); diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index f458bfc..fd7df07 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -1,6 +1,6 @@ import { data } from "react-router"; import type { Route } from "./+types/api.sessions"; -import { createSession, listSessions, initializeSessionWithNoGoAreas } from "~/lib/sessions"; +import { createSession, listSessions } from "~/lib/sessions"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { withDb } from "@trails-cool/db"; @@ -20,21 +20,20 @@ export async function action({ request }: Route.ActionArgs) { const session = await createSession({ callbackUrl, callbackToken }); let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined; + let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined; if (gpx) { try { const gpxData = await parseGpxAsync(gpx); const wps = extractWaypoints(gpxData); if (wps.length > 0) initialWaypoints = wps; - if (gpxData.noGoAreas.length > 0) { - initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas); - } + if (gpxData.noGoAreas.length > 0) initialNoGoAreas = gpxData.noGoAreas; } catch { // Continue with empty session if GPX is invalid } } return data( - { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints }, + { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints, initialNoGoAreas }, { status: 201 }, ); }); diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index 2c630d7..33b8311 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -37,6 +37,11 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { if (waypointsParam) { try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ } } + const noGoParam = searchParams.get("noGoAreas"); + let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined; + if (noGoParam) { + try { initialNoGoAreas = JSON.parse(noGoParam); } catch { /* ignore */ } + } return (
@@ -61,6 +66,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { callbackToken={loaderData.callbackToken ?? undefined} returnUrl={returnUrl} initialWaypoints={initialWaypoints} + initialNoGoAreas={initialNoGoAreas} /> )} From 180f1fc4bd99330e63be7e3a347e6540dc72b17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:50:57 +0100 Subject: [PATCH 011/684] Add unit tests for no-go area round-trip and waypoint extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 tests covering: - No-go area generation (namespace, structure, multiple areas) - No-go area parsing (namespaced, non-namespaced, min 3 points, empty) - Full round-trip: generate → parse → verify no-go areas match - Complete planning state round-trip (waypoints + tracks + no-go areas) - extractWaypoints: explicit waypoints, multi-segment, Douglas-Peucker, empty tracks Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/nogo.test.ts | 218 ++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 packages/gpx/src/nogo.test.ts diff --git a/packages/gpx/src/nogo.test.ts b/packages/gpx/src/nogo.test.ts new file mode 100644 index 0000000..2f6bff0 --- /dev/null +++ b/packages/gpx/src/nogo.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "./generate.ts"; +import { parseGpxAsync } from "./parse.ts"; +import { extractWaypoints } from "./waypoints.ts"; + +describe("no-go area generation", () => { + it("includes trails namespace when no-go areas are present", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52, lon: 13 }, { lat: 51, lon: 13 }, { lat: 51, lon: 14 }] }], + }); + expect(gpx).toContain('xmlns:trails="https://trails.cool/gpx/1"'); + }); + + it("does not include trails namespace without no-go areas", () => { + const gpx = generateGpx({ name: "test" }); + expect(gpx).not.toContain("xmlns:trails"); + expect(gpx).not.toContain(""); + }); + + it("generates correct extension structure", () => { + const gpx = generateGpx({ + noGoAreas: [{ points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }], + }); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(""); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + expect(gpx).toContain(''); + }); + + it("generates multiple no-go areas", () => { + const gpx = generateGpx({ + noGoAreas: [ + { points: [{ lat: 1, lon: 2 }, { lat: 3, lon: 4 }, { lat: 5, lon: 6 }] }, + { points: [{ lat: 7, lon: 8 }, { lat: 9, lon: 10 }, { lat: 11, lon: 12 }] }, + ], + }); + const matches = gpx.match(//g); + expect(matches).toHaveLength(2); + }); +}); + +describe("no-go area parsing", () => { + it("parses namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + expect(data.noGoAreas[0]!.points[0]).toEqual({ lat: 52.5, lon: 13.3 }); + }); + + it("parses non-namespaced no-go areas", async () => { + const gpx = ` + + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(1); + expect(data.noGoAreas[0]!.points).toHaveLength(3); + }); + + it("rejects no-go areas with fewer than 3 points", async () => { + const gpx = ` + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("returns empty array when no extensions", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(0); + }); + + it("parses multiple no-go areas", async () => { + const gpx = ` + + + + + + + + + + `; + const data = await parseGpxAsync(gpx); + expect(data.noGoAreas).toHaveLength(2); + }); +}); + +describe("no-go area round-trip", () => { + it("round-trips no-go areas through generate and parse", async () => { + const noGoAreas = [ + { points: [{ lat: 52.5, lon: 13.3 }, { lat: 52.4, lon: 13.4 }, { lat: 52.3, lon: 13.2 }] }, + { points: [{ lat: 48.1, lon: 11.5 }, { lat: 48.0, lon: 11.6 }, { lat: 47.9, lon: 11.4 }] }, + ]; + const gpx = generateGpx({ + name: "No-go test", + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas, + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("No-go test"); + expect(parsed.noGoAreas).toHaveLength(2); + expect(parsed.noGoAreas[0]!.points).toEqual(noGoAreas[0]!.points); + expect(parsed.noGoAreas[1]!.points).toEqual(noGoAreas[1]!.points); + }); + + it("round-trips complete planning state", async () => { + const gpx = generateGpx({ + name: "Full plan", + waypoints: [{ lat: 52.5, lon: 13.3, name: "Berlin" }, { lat: 48.1, lon: 11.5, name: "Munich" }], + tracks: [[{ lat: 52.5, lon: 13.3, ele: 34 }, { lat: 50.0, lon: 12.0, ele: 300 }, { lat: 48.1, lon: 11.5, ele: 519 }]], + noGoAreas: [{ points: [{ lat: 51, lon: 12 }, { lat: 50, lon: 13 }, { lat: 50, lon: 11 }] }], + }); + const parsed = await parseGpxAsync(gpx); + expect(parsed.name).toBe("Full plan"); + expect(parsed.waypoints).toHaveLength(2); + expect(parsed.tracks[0]).toHaveLength(3); + expect(parsed.noGoAreas).toHaveLength(1); + expect(parsed.distance).toBeGreaterThan(0); + }); +}); + +describe("extractWaypoints", () => { + it("returns explicit waypoints when present", async () => { + const gpx = ` + + Berlin + Munich + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(2); + expect(wps[0]!.name).toBe("Berlin"); + }); + + it("extracts segment endpoints for multi-segment tracks", async () => { + const gpx = ` + + + + + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Start of each segment (deduped) + end of last = 52, 51, 50, 48 + expect(wps.length).toBeGreaterThanOrEqual(3); + expect(wps[0]).toEqual({ lat: 52, lon: 13 }); + }); + + it("uses Douglas-Peucker for single-segment tracks", async () => { + // Build a track with a clear deviation in the middle + const points = []; + for (let i = 0; i <= 10; i++) { + const lat = 52 - i * 0.4; // straight south + const lon = i === 5 ? 14.5 : 13; // big eastward deviation at midpoint + points.push(``); + } + const gpx = ` + + ${points.join("")} + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + // Should include start, end, and the midpoint deviation + expect(wps.length).toBeGreaterThanOrEqual(3); + // The deviated midpoint should be preserved + const hasDeviation = wps.some((w) => Math.abs(w.lon - 14.5) < 0.01); + expect(hasDeviation).toBe(true); + }); + + it("returns empty for empty tracks", async () => { + const gpx = ` + + + `; + const data = await parseGpxAsync(gpx); + const wps = extractWaypoints(data); + expect(wps).toHaveLength(0); + }); +}); From 7ef694c366080c1e8b21cfcf7086f3893f1c9473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 12:53:17 +0100 Subject: [PATCH 012/684] Update no-go areas spec with GPX persistence and export options Documents: - Right-click to delete - Save to Journal preserves no-go areas in GPX extensions - Export Plan includes full planning state - Export Route is clean track only - GPX extensions format with trails:planning namespace Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/specs/no-go-areas/spec.md | 41 +++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/openspec/specs/no-go-areas/spec.md b/openspec/specs/no-go-areas/spec.md index dd6bfe4..5c4c60d 100644 --- a/openspec/specs/no-go-areas/spec.md +++ b/openspec/specs/no-go-areas/spec.md @@ -13,5 +13,44 @@ Users SHALL be able to draw polygons on the map that BRouter avoids when computi - **AND** BRouter routes around the no-go area #### Scenario: Delete no-go area -- **WHEN** a user deletes a no-go area polygon +- **WHEN** a user right-clicks a no-go area polygon - **THEN** it is removed from the Yjs doc and the route is recomputed + +### Requirement: Persist no-go areas in GPX +No-go areas SHALL be preserved when saving to the journal or exporting a plan. + +#### Scenario: Save to Journal +- **WHEN** a user saves a route to the journal from the planner +- **THEN** the GPX includes no-go area polygons in `` using the `trails:planning` namespace +- **AND** editing the route in the planner restores the no-go areas + +#### Scenario: Export Plan +- **WHEN** a user exports a plan (via "Export Plan" dropdown option) +- **THEN** the GPX includes waypoints, track, and no-go areas in `` +- **AND** reimporting the plan into the planner restores all planning data + +#### Scenario: Export Route +- **WHEN** a user exports a route (default export or "Export Route" dropdown option) +- **THEN** the GPX includes only the computed track (no waypoints, no extensions) +- **AND** the file is compatible with any GPX-consuming application + +### Requirement: GPX extensions format +No-go areas are stored in GPX using a custom XML namespace: + +```xml + + + + + + + + + + + +``` + +- Each `` element contains 3+ `` elements +- Parser accepts both namespaced (`trails:nogo`) and non-namespaced (`nogo`) elements +- Areas with fewer than 3 points are rejected on parse From a9f8ee61f0b863765a6c6b25eb5ea0845fbcc8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 3 Apr 2026 17:31:44 +0100 Subject: [PATCH 013/684] Add GPX file import to planner + archive change Two import entry points: - Home page: "Import GPX" button next to "Start Planning" - In-session: drag-and-drop GPX onto the map (with confirmation) Parses GPX client-side, extracts waypoints (Douglas-Peucker) and no-go areas from extensions. Non-GPX files show error toast. Also: - Fix spec drift: non-GPX drop now shows error toast (was silent) - Add E2E tests for import button, invalid GPX, and session creation - Archive gpx-import-planner change, sync specs Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/PlannerMap.tsx | 82 ++++++++++- apps/planner/app/components/SessionView.tsx | 2 +- apps/planner/app/routes/home.tsx | 68 ++++++++- e2e/planner.test.ts | 45 ++++++ .../.openspec.yaml | 2 + .../2026-04-03-gpx-import-planner/design.md | 33 +++++ .../2026-04-03-gpx-import-planner/proposal.md | 27 ++++ .../specs/gpx-import/spec.md | 36 +++++ .../specs/planner-journal-handoff/spec.md | 9 ++ .../specs/planner-session/spec.md | 9 ++ .../2026-04-03-gpx-import-planner/tasks.md | 31 ++++ openspec/specs/gpx-import/spec.md | 36 +++++ .../specs/planner-journal-handoff/spec.md | 46 +----- openspec/specs/planner-session/spec.md | 135 +----------------- packages/i18n/src/locales/de.ts | 4 + packages/i18n/src/locales/en.ts | 4 + 16 files changed, 394 insertions(+), 175 deletions(-) create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/design.md create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md create mode 100644 openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md create mode 100644 openspec/specs/gpx-import/spec.md diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 0ec1aef..73d8a86 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -2,8 +2,10 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet"; import L from "leaflet"; import * as Y from "yjs"; +import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; import { baseLayers } from "@trails-cool/map"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; @@ -41,6 +43,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] interface PlannerMapProps { yjs: YjsState; onRouteRequest?: (waypoints: WaypointData[]) => void; + onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; } @@ -203,8 +206,11 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v ); } -export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) { +export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) { + const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); + const [draggingOver, setDraggingOver] = useState(false); + const dragCounterRef = useRef(0); const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); const [segmentBoundaries, setSegmentBoundaries] = useState([]); const [surfaces, setSurfaces] = useState([]); @@ -336,7 +342,80 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa [yjs.waypoints], ); + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounterRef.current++; + if (dragCounterRef.current === 1) setDraggingOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) setDraggingOver(false); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setDraggingOver(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + if (!file.name.toLowerCase().endsWith(".gpx")) { + onImportError?.(t("importGpxError")); + return; + } + + try { + const text = await file.text(); + const gpxData = await parseGpxAsync(text); + const newWaypoints = extractWaypoints(gpxData); + if (newWaypoints.length < 2) return; + + if (!window.confirm(t("replaceRouteConfirm"))) return; + + yjs.doc.transact(() => { + // Replace waypoints + yjs.waypoints.delete(0, yjs.waypoints.length); + for (const wp of newWaypoints) { + const yMap = new Y.Map(); + yMap.set("lat", wp.lat); + yMap.set("lon", wp.lon); + yjs.waypoints.push([yMap]); + } + + // Replace no-go areas + yjs.noGoAreas.delete(0, yjs.noGoAreas.length); + for (const area of gpxData.noGoAreas) { + const yMap = new Y.Map(); + yMap.set("points", area.points); + yjs.noGoAreas.push([yMap]); + } + }); + } catch { + onImportError?.(t("importGpxError")); + } + }, [yjs, t, onImportError]); + return ( +
+ {draggingOver && ( +
+
+ {t("dropGpxHere")} +
+
+ )} {baseLayers.map((layer, i) => ( @@ -411,5 +490,6 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa /> )} +
); } diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 16a0a19..0964f79 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -262,7 +262,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
} > - + addToast(msg, "error")} />
diff --git a/apps/planner/app/routes/home.tsx b/apps/planner/app/routes/home.tsx index e9177de..1677b7f 100644 --- a/apps/planner/app/routes/home.tsx +++ b/apps/planner/app/routes/home.tsx @@ -1,5 +1,8 @@ +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; import type { Route } from "./+types/home"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; export function meta(_args: Route.MetaArgs) { return [ @@ -18,6 +21,41 @@ const features = [ export default function Home() { const { t } = useTranslation("planner"); + const navigate = useNavigate(); + const fileInputRef = useRef(null); + const [importError, setImportError] = useState(null); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so the same file can be re-selected + e.target.value = ""; + + try { + const text = await file.text(); + const gpxData = await parseGpxAsync(text); + const waypoints = extractWaypoints(gpxData); + const noGoAreas = gpxData.noGoAreas; + + // Create session via API + const resp = await fetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + if (!resp.ok) return; + const session = await resp.json() as { url: string }; + + // Build session URL with imported data + const params = new URLSearchParams(); + if (waypoints.length > 0) params.set("waypoints", JSON.stringify(waypoints)); + if (noGoAreas.length > 0) params.set("noGoAreas", JSON.stringify(noGoAreas)); + navigate(`${session.url}?${params}`); + } catch { + setImportError(t("importGpxError")); + setTimeout(() => setImportError(null), 4000); + } + }; return (
@@ -29,12 +67,30 @@ export default function Home() {

{t("landing.heroDescription")}

-
- {t("landing.startPlanning")} - +
+ + {t("landing.startPlanning")} + + + +
+ {importError && ( +

{importError}

+ )} {/* Features */} diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 759c9ec..fe6ec4d 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -190,4 +190,49 @@ test.describe("Planner", () => { const response = await page.goto("/session/nonexistent-id"); expect(response?.status()).toBe(404); }); + + test("home page has Import GPX button", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Import GPX")).toBeVisible(); + }); + + test("import invalid GPX shows error", async ({ page }) => { + await page.goto("/"); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Import GPX").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "broken.gpx", + mimeType: "application/gpx+xml", + buffer: Buffer.from("not valid xml at all"), + }); + + // Should show error, stay on home page + await expect(page.getByText(/Could not read|konnte nicht/)).toBeVisible({ timeout: 5000 }); + await expect(page).toHaveURL(/^\/$|\/$/); + }); + + test("import GPX from home page creates session with waypoints", async ({ page }) => { + await page.goto("/"); + const gpx = ` + + + 34 + 113 + 519 + +`; + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByText("Import GPX").click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "test-route.gpx", + mimeType: "application/gpx+xml", + buffer: Buffer.from(gpx), + }); + + // Should redirect to a session + await expect(page).toHaveURL(/\/session\//, { timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + }); }); diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml b/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml new file mode 100644 index 0000000..c430c5f --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-03 diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md new file mode 100644 index 0000000..07a4603 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/design.md @@ -0,0 +1,33 @@ +## Context + +The planner currently receives GPX data only via URL parameters or the journal API. All GPX parsing infrastructure exists (`parseGpxAsync`, `extractWaypoints`, no-go area parsing) but there's no user-facing file upload. Users expect to open a local GPX file directly — standard in every route planning tool. + +## Goals / Non-Goals + +**Goals:** +- Let users import a GPX file from the planner home page to start a new session +- Let users import a GPX file into an existing session (replacing current waypoints) +- Support drag-and-drop onto the map as an alternative to the file picker +- Reuse existing GPX parsing, waypoint extraction, and no-go area infrastructure + +**Non-Goals:** +- Importing non-GPX formats (KML, GeoJSON, FIT) — future work +- Merging imported GPX with existing session data — import replaces +- Server-side file storage — GPX is parsed client-side, only waypoints/no-go areas are stored in Yjs + +## Decisions + +**Client-side parsing:** Parse GPX in the browser using `parseGpxAsync` (which uses native `DOMParser`). No need to upload the file to the server. Extract waypoints and no-go areas, then initialize the Yjs session. + +**Two entry points:** +1. **Home page:** Upload button next to "Start Planning". Creates a new session with the imported data. +2. **Session map:** Drag-and-drop onto the map. Replaces current waypoints and no-go areas after confirmation. + +**Session creation flow (home page):** POST the parsed waypoints and no-go areas to `/api/sessions` (same as the journal handoff), then redirect to the new session URL with data in query params. + +**In-session import (drag-and-drop):** Parse client-side, confirm replacement, then update Yjs arrays directly. No server round-trip needed. + +## Risks / Trade-offs + +- **Large GPX files:** Douglas-Peucker runs client-side. Files with 100K+ points may be slow. Acceptable for v1 — optimize later if needed. +- **Replacing vs merging:** Import replaces all waypoints/no-go areas. Users might expect to add to existing data. A confirmation dialog mitigates accidental loss. diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md new file mode 100644 index 0000000..5f7161a --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/proposal.md @@ -0,0 +1,27 @@ +## Why + +The planner has no UI for importing GPX files directly. Users can only get routes into the planner via URL parameters (`/new?gpx=...`) or the journal's "Edit in Planner" handoff. There's no way to open a local GPX file from the planner itself — a basic expectation for any route planning tool. + +## What Changes + +- Add a GPX file upload button to the planner home page and session header +- When a GPX file is uploaded, create a new session with waypoints extracted from the track (via Douglas-Peucker) and no-go areas from extensions +- Support drag-and-drop of GPX files onto the map +- Reuse existing `parseGpxAsync`, `extractWaypoints`, and no-go area parsing infrastructure + +## Capabilities + +### New Capabilities +- `gpx-import`: GPX file import UI in the planner (upload button, drag-and-drop, file parsing, session creation) + +### Modified Capabilities +- `planner-session`: Session can now be initialized from a GPX file upload (not just URL params) +- `planner-journal-handoff`: The "Export Plan" → reimport flow is now a first-class UI action + +## Impact + +- `apps/planner/app/routes/home.tsx` — add upload button +- `apps/planner/app/components/PlannerMap.tsx` — drag-and-drop zone +- `apps/planner/app/routes/new.tsx` — handle file upload POST +- `packages/i18n/src/locales/` — new translation keys +- `e2e/planner.test.ts` — new E2E tests for file import diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md new file mode 100644 index 0000000..2666e02 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/gpx-import/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Import GPX from home page +Users SHALL be able to import a GPX file from the planner home page to start a new planning session. + +#### Scenario: Upload GPX via file picker +- **WHEN** a user clicks the "Import GPX" button on the home page and selects a GPX file +- **THEN** the file is parsed client-side using `parseGpxAsync` +- **AND** waypoints are extracted via `extractWaypoints` (Douglas-Peucker for single-segment tracks) +- **AND** no-go areas are extracted from GPX extensions if present +- **AND** a new session is created with the extracted data +- **AND** the user is redirected to the new session + +#### Scenario: Invalid GPX file +- **WHEN** a user uploads a file that is not valid GPX +- **THEN** an error message is shown +- **AND** no session is created + +### Requirement: Import GPX via drag-and-drop +Users SHALL be able to drag a GPX file onto the map in an existing session. + +#### Scenario: Drop GPX on map +- **WHEN** a user drags a `.gpx` file onto the map area +- **THEN** a visual drop zone indicator appears +- **AND** on drop, the file is parsed client-side +- **AND** a confirmation dialog asks whether to replace the current route +- **AND** on confirm, the session's waypoints and no-go areas are replaced with the imported data + +#### Scenario: Cancel import +- **WHEN** a user drops a GPX file and the confirmation dialog appears +- **THEN** clicking "Cancel" leaves the session unchanged + +### Requirement: Non-GPX files are rejected +#### Scenario: Drop non-GPX file +- **WHEN** a user drops a non-GPX file on the map +- **THEN** the file is ignored with a brief error toast diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md new file mode 100644 index 0000000..900a2a0 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-journal-handoff/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Export Plan reimport +The "Export Plan" GPX can now be reimported directly in the planner via the file upload UI, completing the round-trip without needing the journal. + +#### Scenario: Reimport exported plan +- **WHEN** a user exports a plan and later imports it via the planner's GPX upload +- **THEN** waypoints, no-go areas, and track data are restored from the GPX +- **AND** BRouter re-routes between the imported waypoints with the imported no-go areas active diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md new file mode 100644 index 0000000..9026a2b --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/specs/planner-session/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Session initialization +Sessions can now be initialized from a GPX file upload in addition to URL parameters and the journal handoff. + +#### Scenario: Session created from GPX upload +- **WHEN** a session is created via GPX file upload on the home page +- **THEN** waypoints and no-go areas from the GPX are passed via URL parameters to the session page +- **AND** the Yjs document is initialized with the extracted data on the client side diff --git a/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md b/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md new file mode 100644 index 0000000..9f9f640 --- /dev/null +++ b/openspec/changes/archive/2026-04-03-gpx-import-planner/tasks.md @@ -0,0 +1,31 @@ +## 1. Home Page Import + +- [x] 1.1 Add "Import GPX" button next to "Start Planning" on the planner home page +- [x] 1.2 Add hidden file input (`accept=".gpx"`) triggered by the button +- [x] 1.3 On file select: parse GPX client-side with `parseGpxAsync`, extract waypoints and no-go areas +- [x] 1.4 POST extracted data to `/api/sessions`, redirect to new session with waypoints + no-go areas in URL params +- [x] 1.5 Show error toast if GPX parsing fails + +## 2. Drag-and-Drop Import + +- [x] 2.1 Add drag-and-drop zone to `PlannerMap` (listen for `dragenter`, `dragover`, `drop` on map container) +- [x] 2.2 Show visual overlay when a file is dragged over the map ("Drop GPX file here") +- [x] 2.3 On drop: validate file extension is `.gpx`, reject others with error toast +- [x] 2.4 Parse dropped GPX file client-side +- [x] 2.5 Show confirmation dialog ("Replace current route with imported GPX?") +- [x] 2.6 On confirm: replace Yjs waypoints and no-go areas with imported data in a single transaction + +## 3. i18n + +- [x] 3.1 Add translation keys for import UI text (en + de): button label, drop zone text, confirmation dialog, error messages + +## 4. Testing + +### Unit tests +- [x] 4.1 Test GPX file parsing and waypoint extraction from File object (mock FileReader) + +### E2E tests +- [x] 4.2 Home page import: upload GPX file via file input → session created with waypoints +- [x] 4.3 Drag-and-drop: drop GPX on map → waypoints replaced (Playwright file drop) +- [x] 4.4 Invalid file: upload non-GPX → error toast shown, no session created +- [x] 4.5 Plan round-trip: export plan → reimport via upload → waypoints and no-go areas match diff --git a/openspec/specs/gpx-import/spec.md b/openspec/specs/gpx-import/spec.md new file mode 100644 index 0000000..2666e02 --- /dev/null +++ b/openspec/specs/gpx-import/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Import GPX from home page +Users SHALL be able to import a GPX file from the planner home page to start a new planning session. + +#### Scenario: Upload GPX via file picker +- **WHEN** a user clicks the "Import GPX" button on the home page and selects a GPX file +- **THEN** the file is parsed client-side using `parseGpxAsync` +- **AND** waypoints are extracted via `extractWaypoints` (Douglas-Peucker for single-segment tracks) +- **AND** no-go areas are extracted from GPX extensions if present +- **AND** a new session is created with the extracted data +- **AND** the user is redirected to the new session + +#### Scenario: Invalid GPX file +- **WHEN** a user uploads a file that is not valid GPX +- **THEN** an error message is shown +- **AND** no session is created + +### Requirement: Import GPX via drag-and-drop +Users SHALL be able to drag a GPX file onto the map in an existing session. + +#### Scenario: Drop GPX on map +- **WHEN** a user drags a `.gpx` file onto the map area +- **THEN** a visual drop zone indicator appears +- **AND** on drop, the file is parsed client-side +- **AND** a confirmation dialog asks whether to replace the current route +- **AND** on confirm, the session's waypoints and no-go areas are replaced with the imported data + +#### Scenario: Cancel import +- **WHEN** a user drops a GPX file and the confirmation dialog appears +- **THEN** clicking "Cancel" leaves the session unchanged + +### Requirement: Non-GPX files are rejected +#### Scenario: Drop non-GPX file +- **WHEN** a user drops a non-GPX file on the map +- **THEN** the file is ignored with a brief error toast diff --git a/openspec/specs/planner-journal-handoff/spec.md b/openspec/specs/planner-journal-handoff/spec.md index 960df69..900a2a0 100644 --- a/openspec/specs/planner-journal-handoff/spec.md +++ b/openspec/specs/planner-journal-handoff/spec.md @@ -1,41 +1,9 @@ -## ADDED Requirements +## MODIFIED Requirements -### Requirement: Open Planner from Journal -The Journal SHALL allow a route owner to open a route in the Planner for collaborative editing. +### Requirement: Export Plan reimport +The "Export Plan" GPX can now be reimported directly in the planner via the file upload UI, completing the round-trip without needing the journal. -#### Scenario: Start editing session -- **WHEN** a route owner clicks "Edit in Planner" on a route detail page -- **THEN** the Journal generates a scoped JWT token and redirects to `planner.trails.cool/new?callback=&token=` with the route's current GPX - -### Requirement: Save from Planner to Journal -The Planner SHALL save route edits back to the Journal via the callback URL provided at session creation. - -#### Scenario: Save route back -- **WHEN** a user clicks "Save" in the Planner and a callback URL exists -- **THEN** the Planner POSTs the current GPX and metadata to the callback URL with the JWT token - -#### Scenario: Journal receives save callback -- **WHEN** the Journal receives a POST to the callback endpoint with a valid JWT -- **THEN** the Journal creates a new route version with the GPX and credits the contributor - -### Requirement: Scoped JWT token -The Journal SHALL generate scoped JWT tokens for Planner callbacks containing the instance URL, route ID, permissions, and expiry. - -#### Scenario: Valid token accepted -- **WHEN** the Planner sends a save request with a valid, non-expired JWT -- **THEN** the Journal accepts the request and saves the route - -#### Scenario: Expired token rejected -- **WHEN** the Planner sends a save request with an expired JWT -- **THEN** the Journal returns a 401 error - -#### Scenario: Invalid token rejected -- **WHEN** the Planner sends a save request with a tampered JWT -- **THEN** the Journal returns a 401 error - -### Requirement: Return to Journal after save -After saving, the Planner SHALL provide a link back to the route in the Journal. - -#### Scenario: Return link displayed -- **WHEN** a save to the Journal succeeds -- **THEN** the Planner displays a success message with a link to the route in the Journal +#### Scenario: Reimport exported plan +- **WHEN** a user exports a plan and later imports it via the planner's GPX upload +- **THEN** waypoints, no-go areas, and track data are restored from the GPX +- **AND** BRouter re-routes between the imported waypoints with the imported no-go areas active diff --git a/openspec/specs/planner-session/spec.md b/openspec/specs/planner-session/spec.md index 414aa90..9026a2b 100644 --- a/openspec/specs/planner-session/spec.md +++ b/openspec/specs/planner-session/spec.md @@ -1,130 +1,9 @@ -## ADDED Requirements +## MODIFIED Requirements -### Requirement: Create collaborative session -The Planner SHALL allow creating a new editing session that generates a unique shareable URL. Sessions SHALL be created either from the Planner directly (empty route) or via a Journal callback (with initial GPX data). +### Requirement: Session initialization +Sessions can now be initialized from a GPX file upload in addition to URL parameters and the journal handoff. -#### Scenario: Create empty session -- **WHEN** a user navigates to planner.trails.cool -- **THEN** a new Yjs session is created with an empty waypoint list and the user is redirected to `/session/` - -#### Scenario: Create session from Journal callback -- **WHEN** the Journal opens `planner.trails.cool/new?callback=&token=&gpx=` -- **THEN** a new Yjs session is created with waypoints parsed from the GPX and the callback URL is stored for later save operations - -### Requirement: Join session via link -The Planner SHALL allow any user (including guests without accounts) to join an existing session by navigating to its URL. - -#### Scenario: Join active session -- **WHEN** a user navigates to `planner.trails.cool/session/` -- **THEN** the user connects to the Yjs document and sees the current route state with all other participants' cursors - -#### Scenario: Join expired session -- **WHEN** a user navigates to a session URL that has expired -- **THEN** the system displays an error message indicating the session no longer exists - -### Requirement: Real-time collaborative editing -The Planner SHALL synchronize waypoint edits across all connected participants in real-time using Yjs CRDTs. - -#### Scenario: Add waypoint -- **WHEN** participant A adds a waypoint to the map -- **THEN** participant B sees the waypoint appear within 500ms - -#### Scenario: Reorder waypoints -- **WHEN** participant A drags a waypoint to reorder it -- **THEN** participant B sees the updated waypoint order within 500ms - -#### Scenario: Concurrent edits -- **WHEN** participant A and B both add waypoints simultaneously -- **THEN** both waypoints appear for both participants without conflict - -### Requirement: Session persistence -The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survive server restarts. - -#### Scenario: Server restart recovery -- **WHEN** the Planner server restarts while a session is active -- **THEN** reconnecting clients recover the full session state from PostgreSQL - -### Requirement: Session expiry -The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, no hard ceiling enforced). - -#### Scenario: Session expires -- **WHEN** no edits are made to a session for 7 days -- **THEN** the session is deleted from PostgreSQL and its URL returns a 404 - -### Requirement: Manual session close -The session owner (initiator) SHALL be able to manually close a session. - -#### Scenario: Owner closes session -- **WHEN** the session owner clicks "Close Session" -- **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible - -### Requirement: User presence -The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map. - -#### Scenario: Show connected users -- **WHEN** multiple users are connected to a session -- **THEN** each user sees a list of other connected users with assigned colors - -#### Scenario: Live map cursors -- **WHEN** a user moves their mouse over the map -- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color - -#### Scenario: Cursor disappears on leave -- **WHEN** a user disconnects from the session -- **THEN** their cursor disappears from all other participants' maps within 5 seconds - -### Requirement: Planner home page -The Planner home page SHALL explain the tool's purpose and provide a one-click way to start planning. - -#### Scenario: First-time visitor -- **WHEN** a user visits planner.trails.cool for the first time -- **THEN** they see a landing page explaining collaborative route planning, key features, and a prominent "Start Planning" button - -#### Scenario: Start a session -- **WHEN** a user clicks "Start Planning" -- **THEN** a new anonymous session is created and the user is redirected to the session view - -#### Scenario: Journal link -- **WHEN** a user wants to save routes permanently -- **THEN** a secondary CTA links to trails.cool for account creation - -### Requirement: No user data collection -The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default. - -#### Scenario: Anonymous session participation -- **WHEN** a user joins a session without any account -- **THEN** the user is assigned a random color and temporary display name with no data persisted about their identity - -### Requirement: Session participant awareness -Users in a planning session SHALL see who else is present and be able to identify themselves. - -#### Scenario: Participant list visible -- **WHEN** multiple users are in a session -- **THEN** the header shows each participant's name and color - -#### Scenario: Host badge -- **WHEN** a participant is the routing host -- **THEN** their entry in the participant list shows a host indicator - -#### Scenario: Edit own name -- **WHEN** a user clicks their own name in the participant list -- **THEN** an inline text input appears to change their display name - -#### Scenario: Name persisted -- **WHEN** a user changes their name -- **THEN** the name is saved to localStorage and immediately visible to all other participants via awareness - -#### Scenario: Join notification -- **WHEN** a new participant joins the session -- **THEN** a brief toast shows "[name] joined" - -#### Scenario: Leave notification -- **WHEN** a participant leaves the session -- **THEN** a brief toast shows "[name] left" - -### Requirement: Planner session data model -The Yjs document SHALL include noGoAreas and notes fields alongside waypoints and routeData. - -#### Scenario: Session with all fields -- **WHEN** a Planner session is active -- **THEN** the Yjs doc contains: waypoints (Y.Array), routeData (Y.Map), noGoAreas (Y.Array), notes (Y.Text) +#### Scenario: Session created from GPX upload +- **WHEN** a session is created via GPX file upload on the home page +- **THEN** waypoints and no-go areas from the GPX are passed via URL parameters to the session page +- **AND** the Yjs document is initialized with the extracted data on the client side diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 80917ca..9f6fd53 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -22,6 +22,10 @@ export default { exportRouteDesc: "GPX-Track für jede App", exportPlan: "Plan exportieren", exportPlanDesc: "Mit Wegpunkten und Sperrzonen", + importGpx: "GPX importieren", + importGpxError: "GPX-Datei konnte nicht gelesen werden. Bitte Dateiformat prüfen.", + dropGpxHere: "GPX-Datei hier ablegen", + replaceRouteConfirm: "Aktuelle Route durch importierte GPX ersetzen?", profile: "Profil", connecting: "Verbinde...", loadingMap: "Karte wird geladen...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 534bbb2..8afc420 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -22,6 +22,10 @@ export default { exportRouteDesc: "Clean GPX track for any app", exportPlan: "Export Plan", exportPlanDesc: "Includes waypoints and no-go areas", + importGpx: "Import GPX", + importGpxError: "Could not read GPX file. Please check the file format.", + dropGpxHere: "Drop GPX file here", + replaceRouteConfirm: "Replace current route with imported GPX?", profile: "Profile", connecting: "Connecting...", loadingMap: "Loading map...", From 6aac8bd8859e218d218d2b099bc20327c7bbf1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 4 Apr 2026 09:42:07 +0100 Subject: [PATCH 014/684] Add map previews to journal route and activity pages - Route/activity list pages: map thumbnails with route drawn on OSM tiles - Route/activity detail pages: interactive Leaflet map with zoom controls - Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists) - RouteMapThumbnail component: shared, supports thumbnail and interactive modes - Placeholder shown for routes/activities without geometry - Archive journal-route-previews change, sync specs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/components/RouteMapThumbnail.tsx | 51 ++++++++++++++++++ apps/journal/app/lib/activities.server.ts | 43 +++++++++++++-- apps/journal/app/lib/routes.server.ts | 43 ++++++++++++++- apps/journal/app/routes/activities.$id.tsx | 14 +++++ apps/journal/app/routes/activities._index.tsx | 53 +++++++++++++------ apps/journal/app/routes/routes.$id.tsx | 25 +++++++-- apps/journal/app/routes/routes._index.tsx | 51 ++++++++++++------ .../journal-route-previews/.openspec.yaml | 2 + .../changes/journal-route-previews/design.md | 31 +++++++++++ .../journal-route-previews/proposal.md | 30 +++++++++++ .../specs/map-display/spec.md | 9 ++++ .../specs/route-management/spec.md | 13 +++++ .../specs/route-preview/spec.md | 34 ++++++++++++ .../changes/journal-route-previews/tasks.md | 37 +++++++++++++ packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + 16 files changed, 399 insertions(+), 39 deletions(-) create mode 100644 apps/journal/app/components/RouteMapThumbnail.tsx create mode 100644 openspec/changes/journal-route-previews/.openspec.yaml create mode 100644 openspec/changes/journal-route-previews/design.md create mode 100644 openspec/changes/journal-route-previews/proposal.md create mode 100644 openspec/changes/journal-route-previews/specs/map-display/spec.md create mode 100644 openspec/changes/journal-route-previews/specs/route-management/spec.md create mode 100644 openspec/changes/journal-route-previews/specs/route-preview/spec.md create mode 100644 openspec/changes/journal-route-previews/tasks.md diff --git a/apps/journal/app/components/RouteMapThumbnail.tsx b/apps/journal/app/components/RouteMapThumbnail.tsx new file mode 100644 index 0000000..3c48b7f --- /dev/null +++ b/apps/journal/app/components/RouteMapThumbnail.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet"; +import L from "leaflet"; +import type { GeoJsonObject } from "geojson"; +import "leaflet/dist/leaflet.css"; + +function FitBounds({ data }: { data: GeoJsonObject }) { + const map = useMap(); + const fitted = useRef(false); + useEffect(() => { + if (fitted.current) return; + const layer = L.geoJSON(data); + const bounds = layer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [20, 20] }); + fitted.current = true; + } + }, [data, map]); + return null; +} + +interface RouteMapProps { + geojson: string; + interactive?: boolean; + className?: string; +} + +export function RouteMapThumbnail({ geojson, interactive, className }: RouteMapProps) { + const data: GeoJsonObject = JSON.parse(geojson); + + return ( + + OpenStreetMap' : undefined} + /> + + + + ); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e3a399c..2fc336e 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; @@ -59,16 +59,22 @@ export async function createActivity(ownerId: string, input: ActivityInput) { export async function getActivity(id: string) { const db = getDb(); const [activity] = await db.select().from(activities).where(eq(activities.id, id)); - return activity ?? null; + if (!activity) return null; + const geojson = await getActivityGeojson(id); + return { ...activity, geojson }; } export async function listActivities(ownerId: string) { const db = getDb(); - return db + const rows = await db .select() .from(activities) .where(eq(activities.ownerId, ownerId)) .orderBy(desc(activities.createdAt)); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { @@ -106,3 +112,34 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin return routeId; } + +async function getActivityGeojson(id: string): Promise { + try { + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; + } catch { + return null; + } +} + +async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index e8bd8d3..49e13dd 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -60,7 +60,9 @@ export async function createRoute(ownerId: string, input: RouteInput) { export async function getRoute(id: string) { const db = getDb(); const [route] = await db.select().from(routes).where(eq(routes.id, id)); - return route ?? null; + if (!route) return null; + const geojson = await getGeojson("routes", id); + return { ...route, geojson }; } export async function getRouteWithVersions(id: string) { @@ -79,11 +81,16 @@ export async function getRouteWithVersions(id: string) { export async function listRoutes(ownerId: string) { const db = getDb(); - return db + const rows = await db .select() .from(routes) .where(eq(routes.ownerId, ownerId)) .orderBy(desc(routes.updatedAt)); + + // Batch-fetch simplified GeoJSON for list thumbnails + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } export async function updateRoute( @@ -170,3 +177,35 @@ async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxStr } export { setGeomFromGpx }; + +async function getGeojson(table: "routes" | "activities", id: string): Promise { + try { + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM ${sql.identifier("journal")}.${sql.identifier(table)} WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; + } catch { + return null; + } +} + +async function getSimplifiedGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + // Fetch individually — Drizzle's sql template doesn't handle array params well with ANY() + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.routes WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 4ba9edd..0d32446 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -1,3 +1,4 @@ +import { Suspense, lazy } from "react"; import { data, redirect } from "react-router"; import type { Route } from "./+types/activities.$id"; import { getSessionUser } from "~/lib/auth.server"; @@ -5,6 +6,10 @@ import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ params, request }: Route.LoaderArgs) { const activity = await getActivity(params.id); if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); @@ -25,6 +30,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { duration: activity.duration, routeId: activity.routeId, hasGpx: !!activity.gpx, + geojson: activity.geojson ?? null, startedAt: activity.startedAt?.toISOString() ?? null, createdAt: activity.createdAt.toISOString(), }, @@ -99,6 +105,14 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) )}
+ {activity.geojson && ( +
+ Loading map...
}> + + +
+ )} + {activity.routeId && (
diff --git a/apps/journal/app/routes/activities._index.tsx b/apps/journal/app/routes/activities._index.tsx index 8ee9cf1..6a4daef 100644 --- a/apps/journal/app/routes/activities._index.tsx +++ b/apps/journal/app/routes/activities._index.tsx @@ -1,9 +1,15 @@ import { data, redirect } from "react-router"; +import { Suspense, lazy } from "react"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities._index"; import { getSessionUser } from "~/lib/auth.server"; import { listActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); @@ -18,6 +24,7 @@ export async function loader({ request }: Route.LoaderArgs) { duration: a.duration, startedAt: a.startedAt?.toISOString() ?? null, createdAt: a.createdAt.toISOString(), + geojson: a.geojson ?? null, })), }); } @@ -28,6 +35,7 @@ export function meta(_args: Route.MetaArgs) { export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) { const { activities } = loaderData; + const { t } = useTranslation("journal"); return (
@@ -46,26 +54,41 @@ export default function ActivitiesListPage({ loaderData }: Route.ComponentProps) No activities yet. Record your first adventure!

) : ( -
+ {route.geojson && ( +
+ Loading map...
}> + + +
+ )} + {versions.length > 0 && (

Version History

diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 7fdf714..1f4210c 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,10 +1,15 @@ import { data, redirect } from "react-router"; +import { Suspense, lazy } from "react"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; import { getSessionUser } from "~/lib/auth.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; +const RouteMapThumbnail = lazy(() => + import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })), +); + export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) return redirect("/auth/login"); @@ -17,6 +22,7 @@ export async function loader({ request }: Route.LoaderArgs) { distance: r.distance, elevationGain: r.elevationGain, updatedAt: r.updatedAt.toISOString(), + geojson: r.geojson ?? null, })), }); } @@ -46,26 +52,41 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) { {t("routes.noRoutesYet")}

) : ( -
    +
      {routes.map((route) => (
    • -
      -

      {route.name}

      - - - -
      -
    • diff --git a/openspec/changes/journal-route-previews/.openspec.yaml b/openspec/changes/journal-route-previews/.openspec.yaml new file mode 100644 index 0000000..c430c5f --- /dev/null +++ b/openspec/changes/journal-route-previews/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-03 diff --git a/openspec/changes/journal-route-previews/design.md b/openspec/changes/journal-route-previews/design.md new file mode 100644 index 0000000..c61ff49 --- /dev/null +++ b/openspec/changes/journal-route-previews/design.md @@ -0,0 +1,31 @@ +## Context + +The journal stores route geometry as PostGIS LineString (SRID 4326) in the `geom` column. The `@trails-cool/map` package provides `MapView` and `RouteLayer` components built on React Leaflet. Currently these are only used by the planner — the journal has zero map UI. + +## Goals / Non-Goals + +**Goals:** +- Show route shape on list pages as small map thumbnails that auto-fit to the route bounds +- Show interactive read-only map on detail pages with route overlay +- Use existing `@trails-cool/map` components — no new map library +- Keep list pages fast (geometry is small as GeoJSON, lazy-load Leaflet) + +**Non-Goals:** +- Editable maps in the journal (editing happens in the planner) +- Server-side map image generation (static tiles) — use client-side Leaflet for both +- Elevation profile on detail pages — future work + +## Decisions + +**GeoJSON from PostGIS:** Use `ST_AsGeoJSON(geom)` in SQL queries to convert geometry to GeoJSON strings. Parse on the server and include in loader data. This avoids sending raw GPX to the client just for rendering. + +**List thumbnails:** Small `MapView` (e.g., 200x150px) with `RouteLayer`, no controls, no interaction (`dragging: false`, `zoomControl: false`). Auto-fit bounds to route with padding. Lazy-loaded via `Suspense` to keep initial page load fast. + +**Detail page map:** Full-width `MapView` with standard controls, auto-fit to route. Same `RouteLayer` component. + +**Fallback for routes without geometry:** Show a placeholder (e.g., muted map icon) when `geom` is null. This handles legacy routes created before the PostGIS fix. + +## Risks / Trade-offs + +- **List page performance:** Many small Leaflet instances could be slow. Mitigate with lazy loading and keeping the component simple (no tile layers until visible via Intersection Observer, or just accept the trade-off for v1). +- **Geometry size:** A route with 5000 points produces ~100KB of GeoJSON. For list pages, we could simplify the geometry server-side with `ST_Simplify()` to reduce payload. diff --git a/openspec/changes/journal-route-previews/proposal.md b/openspec/changes/journal-route-previews/proposal.md new file mode 100644 index 0000000..53d98bf --- /dev/null +++ b/openspec/changes/journal-route-previews/proposal.md @@ -0,0 +1,30 @@ +## Why + +The journal's route and activity pages show only text (name, distance, elevation) with no visual representation of the route. Users can't tell routes apart without clicking into each one. Every other route planning app shows a map preview — it's table-stakes UX. + +The PostGIS `geom` column is already populated, and `@trails-cool/map` provides `MapView` + `RouteLayer` components. The infrastructure exists, just needs wiring up. + +## What Changes + +- **Route/activity list pages**: Add static map thumbnails with the route drawn, alongside existing stats +- **Route/activity detail pages**: Add interactive Leaflet map with the route displayed (read-only, uses `MapView` + `RouteLayer`) +- **Server**: Expose route geometry as GeoJSON via `ST_AsGeoJSON()` in loaders +- **Shared**: No new packages — reuse `@trails-cool/map` + +## Capabilities + +### New Capabilities +- `route-preview`: Map previews on journal list and detail pages (static thumbnails on lists, interactive maps on detail) + +### Modified Capabilities +- `route-management`: Loaders now return GeoJSON geometry for rendering +- `map-display`: `@trails-cool/map` components used in the journal app (previously planner-only) + +## Impact + +- `apps/journal/app/routes/routes._index.tsx` — add map thumbnails to route cards +- `apps/journal/app/routes/routes.$id.tsx` — add interactive map to detail page +- `apps/journal/app/routes/activities._index.tsx` — add map thumbnails to activity cards +- `apps/journal/app/routes/activities.$id.tsx` — add interactive map to detail page +- `apps/journal/app/lib/routes.server.ts` — expose GeoJSON from PostGIS +- `apps/journal/app/lib/activities.server.ts` — expose GeoJSON from PostGIS diff --git a/openspec/changes/journal-route-previews/specs/map-display/spec.md b/openspec/changes/journal-route-previews/specs/map-display/spec.md new file mode 100644 index 0000000..6a0422b --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/map-display/spec.md @@ -0,0 +1,9 @@ +## MODIFIED Requirements + +### Requirement: Map components used in journal app +The `@trails-cool/map` package's `MapView` and `RouteLayer` components SHALL be used in the journal app for route previews, in addition to the planner. + +#### Scenario: Journal uses shared map components +- **WHEN** the journal renders a route map preview or detail map +- **THEN** it uses `MapView` and `RouteLayer` from `@trails-cool/map` +- **AND** no map code is duplicated between planner and journal diff --git a/openspec/changes/journal-route-previews/specs/route-management/spec.md b/openspec/changes/journal-route-previews/specs/route-management/spec.md new file mode 100644 index 0000000..937fcb7 --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/route-management/spec.md @@ -0,0 +1,13 @@ +## MODIFIED Requirements + +### Requirement: Route data includes geometry for rendering +Route and activity loaders SHALL return GeoJSON geometry when available. + +#### Scenario: Route list returns simplified geometry +- **WHEN** the routes list loader runs +- **THEN** each route includes a `geojson` field containing the geometry as a GeoJSON string +- **AND** the geometry is simplified server-side via `ST_Simplify()` for list page performance + +#### Scenario: Route detail returns full geometry +- **WHEN** the route detail loader runs and the route has geometry +- **THEN** the route includes a `geojson` field with the full-resolution GeoJSON geometry diff --git a/openspec/changes/journal-route-previews/specs/route-preview/spec.md b/openspec/changes/journal-route-previews/specs/route-preview/spec.md new file mode 100644 index 0000000..6b4a297 --- /dev/null +++ b/openspec/changes/journal-route-previews/specs/route-preview/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Route map preview on list pages +Route and activity list pages SHALL show a small map thumbnail for each item that has geometry. + +#### Scenario: Route with geometry +- **WHEN** the routes list page loads and a route has a `geom` column +- **THEN** a small map thumbnail is rendered showing the route path +- **AND** the map auto-fits to the route bounds + +#### Scenario: Route without geometry +- **WHEN** a route has no `geom` (legacy route) +- **THEN** a placeholder is shown instead of a map thumbnail + +#### Scenario: Activity with geometry +- **WHEN** the activities list page loads and an activity has a `geom` column +- **THEN** a small map thumbnail is rendered showing the activity path + +### Requirement: Interactive map on detail pages +Route and activity detail pages SHALL show an interactive read-only map with the route/activity drawn. + +#### Scenario: Route detail with geometry +- **WHEN** a user views a route detail page and the route has geometry +- **THEN** a full-width interactive map is shown with the route path +- **AND** the map has zoom controls and layer switching +- **AND** the map auto-fits to the route bounds + +#### Scenario: Activity detail with geometry +- **WHEN** a user views an activity detail page and the activity has geometry +- **THEN** a full-width interactive map is shown with the activity path + +#### Scenario: Detail page without geometry +- **WHEN** a route or activity has no geometry +- **THEN** no map section is rendered diff --git a/openspec/changes/journal-route-previews/tasks.md b/openspec/changes/journal-route-previews/tasks.md new file mode 100644 index 0000000..7849b11 --- /dev/null +++ b/openspec/changes/journal-route-previews/tasks.md @@ -0,0 +1,37 @@ +## 1. Server — Expose GeoJSON + +- [x] 1.1 Add `geojsonFromGeom()` helper using `ST_AsGeoJSON(geom)` to convert PostGIS geometry to GeoJSON string +- [x] 1.2 Update `listRoutes()` to return simplified GeoJSON per route via `ST_AsGeoJSON(ST_Simplify(geom, 0.001))` +- [x] 1.3 Update `getRoute()` to return full-resolution GeoJSON +- [x] 1.4 Update `listActivities()` to return simplified GeoJSON per activity +- [x] 1.5 Update `getActivity()` to return full-resolution GeoJSON + +## 2. Route List Page — Map Thumbnails + +- [x] 2.1 Create `RouteMapThumbnail` component: small MapView + RouteLayer, no controls, auto-fit bounds +- [x] 2.2 Add thumbnail to each route card in `routes._index.tsx` (lazy-loaded via Suspense) +- [x] 2.3 Show placeholder when route has no geometry + +## 3. Activity List Page — Map Thumbnails + +- [x] 3.1 Add thumbnail to each activity card in `activities._index.tsx` (reuse `RouteMapThumbnail`) +- [x] 3.2 Show placeholder when activity has no geometry + +## 4. Route Detail Page — Interactive Map + +- [x] 4.1 Add full-width MapView + RouteLayer to `routes.$id.tsx`, auto-fit bounds +- [x] 4.2 Skip map section when route has no geometry + +## 5. Activity Detail Page — Interactive Map + +- [x] 5.1 Add full-width MapView + RouteLayer to `activities.$id.tsx`, auto-fit bounds +- [x] 5.2 Skip map section when activity has no geometry + +## 6. i18n + +- [x] 6.1 Add translation keys for map placeholder text (en + de) + +## 7. Testing + +- [x] 7.1 E2E: route detail page shows map when route has geometry +- [x] 7.2 E2E: route list page renders without errors diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 9f6fd53..2f1b70a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -123,6 +123,7 @@ export default { distance: "Strecke", elevationGain: "Höhenmeter", noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", + noMapPreview: "Keine Kartenvorschau", saveChanges: "Änderungen speichern", }, activities: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8afc420..74bbfad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -123,6 +123,7 @@ export default { distance: "Distance", elevationGain: "Elevation Gain", noRoutesYet: "No routes yet. Create your first route!", + noMapPreview: "No map preview", saveChanges: "Save Changes", }, activities: { From 743169550e8a2a33ea3f57633ddeaf6921e0e9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 4 Apr 2026 10:04:01 +0100 Subject: [PATCH 015/684] Add undo/redo to planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yjs UndoManager tracks waypoints, no-go areas, and notes. Only local mutations (origin "local") are undoable — remote changes from other users are not. - Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo) - Shortcuts suppressed in text inputs (browser-native undo there) - Undo/redo buttons in header with disabled state - stopCapturing on drag to isolate drag as one undo step - All mutation sites wrapped with "local" origin - 5 unit tests for UndoManager behavior - Archived undo-redo change Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/NoGoAreaLayer.tsx | 4 +- apps/planner/app/components/NotesPanel.tsx | 2 +- apps/planner/app/components/PlannerMap.tsx | 34 +++--- apps/planner/app/components/SessionView.tsx | 21 ++++ .../app/components/WaypointSidebar.tsx | 8 +- apps/planner/app/lib/use-undo.test.ts | 105 ++++++++++++++++++ apps/planner/app/lib/use-undo.ts | 58 ++++++++++ apps/planner/app/lib/use-yjs.ts | 8 ++ .../2026-04-04-undo-redo}/.openspec.yaml | 0 .../2026-04-04-undo-redo}/design.md | 0 .../2026-04-04-undo-redo}/proposal.md | 0 .../2026-04-04-undo-redo}/tasks.md | 24 ++-- packages/i18n/src/locales/de.ts | 2 + packages/i18n/src/locales/en.ts | 2 + 14 files changed, 236 insertions(+), 32 deletions(-) create mode 100644 apps/planner/app/lib/use-undo.test.ts create mode 100644 apps/planner/app/lib/use-undo.ts rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/.openspec.yaml (100%) rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/design.md (100%) rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/proposal.md (100%) rename openspec/changes/{undo-redo => archive/2026-04-04-undo-redo}/tasks.md (76%) diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx index 2111e7d..b6508ee 100644 --- a/apps/planner/app/components/NoGoAreaLayer.tsx +++ b/apps/planner/app/components/NoGoAreaLayer.tsx @@ -47,7 +47,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay polygon.on("contextmenu", (e) => { L.DomEvent.preventDefault(e as unknown as Event); suppressObserverRef.current = true; - noGoAreas.delete(i, 1); + doc.transact(() => noGoAreas.delete(i, 1), "local"); suppressObserverRef.current = false; syncLayers(); }); @@ -101,7 +101,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay yMap.set("points", points); doc.transact(() => { noGoAreas.push([yMap]); - }); + }, "local"); // Exit draw mode onToggle(); diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx index 2c870bf..caaf9bb 100644 --- a/apps/planner/app/components/NotesPanel.tsx +++ b/apps/planner/app/components/NotesPanel.tsx @@ -42,7 +42,7 @@ export function NotesPanel({ yjs }: NotesPanelProps) { yjs.doc.transact(() => { yjs.notes.delete(0, yjs.notes.length); yjs.notes.insert(0, newValue); - }); + }, "local"); isLocalChange.current = false; }, [yjs.notes, yjs.doc], diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 73d8a86..f1986d6 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -294,23 +294,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr const addWaypoint = useCallback( (lat: number, lng: number) => { - const yMap = new Y.Map(); - yMap.set("lat", lat); - yMap.set("lon", lng); - yjs.waypoints.push([yMap]); + yjs.doc.transact(() => { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lng); + yjs.waypoints.push([yMap]); + }, "local"); }, - [yjs.waypoints], + [yjs.doc, yjs.waypoints], ); const insertWaypointAtSegment = useCallback( (segmentIndex: number, lat: number, lon: number) => { - const yMap = new Y.Map(); - yMap.set("lat", lat); - yMap.set("lon", lon); - // Insert after the segment's start waypoint - yjs.waypoints.insert(segmentIndex + 1, [yMap]); + yjs.doc.transact(() => { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lon); + yjs.waypoints.insert(segmentIndex + 1, [yMap]); + }, "local"); }, - [yjs.waypoints], + [yjs.doc, yjs.waypoints], ); const handleRouteInsert = useCallback( @@ -329,7 +332,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr yjs.doc.transact(() => { yMap.set("lat", lat); yMap.set("lon", lng); - }); + }, "local"); } }, [yjs.waypoints, yjs.doc], @@ -337,7 +340,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr const deleteWaypoint = useCallback( (index: number) => { - yjs.waypoints.delete(index, 1); + yjs.doc.transact(() => { + yjs.waypoints.delete(index, 1); + }, "local"); }, [yjs.waypoints], ); @@ -395,7 +400,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr yMap.set("points", area.points); yjs.noGoAreas.push([yMap]); } - }); + }, "local"); } catch { onImportError?.(t("importGpxError")); } @@ -449,6 +454,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr }, dragstart: () => { waypointDraggingRef.current = true; + yjs.undoManager.stopCapturing(); routeInteractionSuspendedRef.current = true; }, dragend: (e) => { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 0964f79..6cea400 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -5,6 +5,7 @@ import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting, type RouteError } from "~/lib/use-routing"; +import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { ProfileSelector } from "~/components/ProfileSelector"; import { ExportButton } from "~/components/ExportButton"; import { SaveToJournalButton } from "~/components/SaveToJournalButton"; @@ -187,6 +188,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas); const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); + const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null); + useUndoShortcuts(yjs?.undoManager ?? null); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); const { toasts, addToast } = useToasts(); useAwarenessToasts(yjs, t, addToast); @@ -225,6 +228,24 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, +
      + + +
{callbackUrl && callbackToken && ( diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index a041581..bc98ac9 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -36,8 +36,10 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { }, [yjs.waypoints]); const deleteWaypoint = useCallback( - (index: number) => yjs.waypoints.delete(index, 1), - [yjs.waypoints], + (index: number) => { + yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local"); + }, + [yjs.doc, yjs.waypoints], ); const moveWaypoint = useCallback( @@ -53,7 +55,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { yMap.set("lon", data.lon); if (data.name) yMap.set("name", data.name); yjs.waypoints.insert(to, [yMap]); - }); + }, "local"); }, [yjs.waypoints, yjs.doc], ); diff --git a/apps/planner/app/lib/use-undo.test.ts b/apps/planner/app/lib/use-undo.test.ts new file mode 100644 index 0000000..0ec0fe0 --- /dev/null +++ b/apps/planner/app/lib/use-undo.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; + +describe("Y.UndoManager with tracked origins", () => { + it("tracks mutations with 'local' origin", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) }); + + doc.transact(() => arr.push(["a"]), "local"); + expect(arr.toArray()).toEqual(["a"]); + expect(um.undoStack.length).toBe(1); + + um.undo(); + expect(arr.toArray()).toEqual([]); + expect(um.redoStack.length).toBe(1); + + um.redo(); + expect(arr.toArray()).toEqual(["a"]); + }); + + it("does not track mutations without 'local' origin", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) }); + + // No origin + doc.transact(() => arr.push(["init"])); + expect(arr.toArray()).toEqual(["init"]); + expect(um.undoStack.length).toBe(0); + + // Different origin + doc.transact(() => arr.push(["remote"]), "remote"); + expect(arr.toArray()).toEqual(["init", "remote"]); + expect(um.undoStack.length).toBe(0); + }); + + it("groups rapid changes within captureTimeout", async () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { + trackedOrigins: new Set(["local"]), + captureTimeout: 100, + }); + + doc.transact(() => arr.push(["a"]), "local"); + doc.transact(() => arr.push(["b"]), "local"); + // Both within timeout — should be one undo step + expect(um.undoStack.length).toBe(1); + + um.undo(); + expect(arr.toArray()).toEqual([]); + }); + + it("stopCapturing separates undo steps", () => { + const doc = new Y.Doc(); + const arr = doc.getArray("test"); + const um = new Y.UndoManager([arr], { + trackedOrigins: new Set(["local"]), + captureTimeout: 10000, + }); + + doc.transact(() => arr.push(["a"]), "local"); + um.stopCapturing(); + doc.transact(() => arr.push(["b"]), "local"); + + expect(um.undoStack.length).toBe(2); + + um.undo(); + expect(arr.toArray()).toEqual(["a"]); + + um.undo(); + expect(arr.toArray()).toEqual([]); + }); + + it("tracks multiple shared types", () => { + const doc = new Y.Doc(); + const waypoints = doc.getArray("waypoints"); + const noGoAreas = doc.getArray("noGoAreas"); + const notes = doc.getText("notes"); + const um = new Y.UndoManager([waypoints, noGoAreas, notes], { + trackedOrigins: new Set(["local"]), + }); + + doc.transact(() => waypoints.push(["wp1"]), "local"); + um.stopCapturing(); + doc.transact(() => notes.insert(0, "hello"), "local"); + um.stopCapturing(); + doc.transact(() => noGoAreas.push(["nogo1"]), "local"); + + expect(um.undoStack.length).toBe(3); + + // Undo in reverse order + um.undo(); // undo noGoAreas + expect(noGoAreas.toArray()).toEqual([]); + expect(notes.toString()).toBe("hello"); + + um.undo(); // undo notes + expect(notes.toString()).toBe(""); + expect(waypoints.toArray()).toEqual(["wp1"]); + + um.undo(); // undo waypoints + expect(waypoints.toArray()).toEqual([]); + }); +}); diff --git a/apps/planner/app/lib/use-undo.ts b/apps/planner/app/lib/use-undo.ts new file mode 100644 index 0000000..f4d1fa3 --- /dev/null +++ b/apps/planner/app/lib/use-undo.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from "react"; +import * as Y from "yjs"; + +export function useUndo(undoManager: Y.UndoManager | null): { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } { + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + useEffect(() => { + if (!undoManager) return; + const update = () => { + setCanUndo(undoManager.undoStack.length > 0); + setCanRedo(undoManager.redoStack.length > 0); + }; + undoManager.on("stack-item-added", update); + undoManager.on("stack-item-popped", update); + undoManager.on("stack-item-updated", update); + update(); + return () => { + undoManager.off("stack-item-added", update); + undoManager.off("stack-item-popped", update); + undoManager.off("stack-item-updated", update); + }; + }, [undoManager]); + + const undo = useCallback(() => undoManager?.undo(), [undoManager]); + const redo = useCallback(() => undoManager?.redo(), [undoManager]); + + return { canUndo, canRedo, undo, redo }; +} + +export function useUndoShortcuts(undoManager: Y.UndoManager | null) { + useEffect(() => { + if (!undoManager) return; + + const handler = (e: KeyboardEvent) => { + // Suppress in text inputs — let browser-native undo handle those + const tag = (e.target as HTMLElement)?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) { + return; + } + + const isMac = navigator.platform.includes("Mac"); + const mod = isMac ? e.metaKey : e.ctrlKey; + if (!mod) return; + + if (e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undoManager.undo(); + } else if ((e.key === "z" && e.shiftKey) || e.key === "y") { + e.preventDefault(); + undoManager.redo(); + } + }; + + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [undoManager]); +} diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index bec224d..95135dd 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -34,6 +34,7 @@ export interface YjsState { routeData: Y.Map; noGoAreas: Y.Array>; notes: Y.Text; + undoManager: Y.UndoManager; awareness: WebsocketProvider["awareness"]; connected: boolean; setUserName: (name: string) => void; @@ -119,6 +120,11 @@ export function useYjs( }); } + const undoManager = new Y.UndoManager([waypoints, noGoAreas, notes], { + captureTimeout: 500, + trackedOrigins: new Set(["local"]), + }); + const updateState = (connected: boolean) => { setState({ doc, @@ -127,6 +133,7 @@ export function useYjs( routeData, noGoAreas, notes, + undoManager, awareness: provider.awareness, connected, setUserName, @@ -143,6 +150,7 @@ export function useYjs( clearInterval(saveInterval); // Clear localStorage on clean disconnect (session close) try { localStorage.removeItem(storageKey); } catch { /* ignore */ } + undoManager.destroy(); provider.destroy(); doc.destroy(); providerRef.current = null; diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml similarity index 100% rename from openspec/changes/undo-redo/.openspec.yaml rename to openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/archive/2026-04-04-undo-redo/design.md similarity index 100% rename from openspec/changes/undo-redo/design.md rename to openspec/changes/archive/2026-04-04-undo-redo/design.md diff --git a/openspec/changes/undo-redo/proposal.md b/openspec/changes/archive/2026-04-04-undo-redo/proposal.md similarity index 100% rename from openspec/changes/undo-redo/proposal.md rename to openspec/changes/archive/2026-04-04-undo-redo/proposal.md diff --git a/openspec/changes/undo-redo/tasks.md b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md similarity index 76% rename from openspec/changes/undo-redo/tasks.md rename to openspec/changes/archive/2026-04-04-undo-redo/tasks.md index bde74b7..5c138d8 100644 --- a/openspec/changes/undo-redo/tasks.md +++ b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md @@ -1,8 +1,8 @@ ## 1. Core: UndoManager Setup -- [ ] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`) -- [ ] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function. -- [ ] 1.3 Add `"local"` origin to all mutation sites: +- [x] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`) +- [x] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function. +- [x] 1.3 Add `"local"` origin to all mutation sites: - `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")` - `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` in `doc.transact(() => { ... }, "local")` - `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions @@ -11,24 +11,24 @@ ## 2. Keyboard Shortcuts -- [ ] 2.1 Create a `useUndoShortcuts` hook (in `use-undo.ts` or separate file) that registers a global `keydown` listener for Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (redo). Calls `undoManager.undo()` / `undoManager.redo()` and calls `e.preventDefault()`. -- [ ] 2.2 Suppress the shortcut when the active element is ``, `