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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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