From 08070cdd9003858e33f1578cfdf87bacaf98cab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 03:35:40 +0200 Subject: [PATCH] Sync notes through GPX, Journal, and Planner roundtrip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full notes lifecycle: - GPX: description field in GpxData, in generate/parse - Export: Plan GPX and Save to Journal include notes as description - Journal: updateRoute extracts description from GPX, stores on route - Reimport: Edit in Planner passes notes via URL params → Yjs Y.Text - Drop import: GPX with restores notes in session Spec updated: session-notes gains GPX export, Journal sync, and reimport requirements. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/routes.server.ts | 6 ++++- .../routes/api.routes.$id.edit-in-planner.ts | 4 ++++ apps/planner/app/components/ExportButton.tsx | 3 ++- apps/planner/app/components/PlannerMap.tsx | 6 +++++ .../app/components/SaveToJournalButton.tsx | 3 ++- apps/planner/app/components/SessionView.tsx | 5 +++-- apps/planner/app/lib/use-yjs.ts | 4 ++++ apps/planner/app/routes/api.sessions.ts | 4 +++- apps/planner/app/routes/session.$id.tsx | 2 ++ openspec/specs/session-notes/spec.md | 22 +++++++++++++++++++ packages/gpx/src/generate.ts | 8 +++++-- packages/gpx/src/parse.ts | 3 ++- packages/gpx/src/types.ts | 1 + 13 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 1bc0880..2a77a14 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -114,6 +114,9 @@ export async function updateRoute( updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; updateData.dayBreaks = stats.dayBreaks; + if (stats.description && input.description === undefined) { + updateData.description = stats.description; + } // Get next version number const existingVersions = await db @@ -163,9 +166,10 @@ async function computeRouteStats(gpxString: string) { elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, dayBreaks, + description: gpxData.description, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[] }; + return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[], description: undefined }; } } 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 61c809e..3fa5b1d 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 @@ -37,6 +37,7 @@ export async function action({ params, request }: Route.ActionArgs) { url: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>; initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; + initialNotes?: string; }; // Encode planning data in URL params @@ -47,6 +48,9 @@ export async function action({ params, request }: Route.ActionArgs) { if (session.initialNoGoAreas?.length) { urlParams.set("noGoAreas", JSON.stringify(session.initialNoGoAreas)); } + if (session.initialNotes) { + urlParams.set("notes", session.initialNotes); + } return data({ url: `${plannerUrl}${session.url}?${urlParams}`, diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 2b42b34..d673d0b 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -69,7 +69,8 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { const tracks = getTracks(yjs); const waypoints = getWaypoints(yjs); const noGoAreas = getNoGoAreas(yjs); - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); + const notes = yjs.notes.toString() || undefined; + const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); download(gpx, "route-plan.gpx"); setOpen(false); }, [yjs]); diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 2a9407d..efb5d42 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -614,6 +614,12 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted yMap.set("points", area.points); yjs.noGoAreas.push([yMap]); } + + // Replace notes if GPX has a description + if (gpxData.description) { + yjs.notes.delete(0, yjs.notes.length); + yjs.notes.insert(0, gpxData.description); + } }, "local"); } catch { onImportError?.(t("importGpxError")); diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 56a753c..a2e178a 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -48,7 +48,8 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl isDayBreak: yMap.get("overnight") === true ? true : undefined, })); - const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas }); + const notes = yjs.notes.toString() || undefined; + const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); // POST to Journal callback const response = await fetch(callbackUrl, { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 8bf0228..08fb9f1 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -152,12 +152,13 @@ interface SessionViewProps { returnUrl?: string; initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>; initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>; + initialNotes?: string; } -export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas }: SessionViewProps) { +export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints, initialNoGoAreas, initialNotes }: SessionViewProps) { const { t } = useTranslation("planner"); useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); - const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas); + const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas, initialNotes); const { computing, routeError, routeStats, requestRoute } = useRouting(yjs); const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null); useUndoShortcuts(yjs?.undoManager ?? null); diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index d573a34..2681edc 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -44,6 +44,7 @@ export function useYjs( sessionId: string, initialWaypoints?: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }>, initialNoGoAreas?: Array<{ points: Array<{ lat: number; lon: number }> }>, + initialNotes?: string, ): YjsState | null { const [state, setState] = useState(null); const providerRef = useRef(null); @@ -116,6 +117,9 @@ export function useYjs( noGoAreas.push([yMap]); } } + if (initialNotes && notes.length === 0) { + notes.insert(0, initialNotes); + } }); } }); diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index af238d2..df4e17c 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -21,19 +21,21 @@ export async function action({ request }: Route.ActionArgs) { let initialWaypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> | undefined; let initialNoGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }> | undefined; + let initialNotes: string | undefined; if (gpx) { try { const gpxData = await parseGpxAsync(gpx); const wps = extractWaypoints(gpxData); if (wps.length > 0) initialWaypoints = wps; if (gpxData.noGoAreas.length > 0) initialNoGoAreas = gpxData.noGoAreas; + if (gpxData.description) initialNotes = gpxData.description; } catch { // Continue with empty session if GPX is invalid } } return data( - { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints, initialNoGoAreas }, + { sessionId: session.id, url: `/session/${session.id}`, initialWaypoints, initialNoGoAreas, initialNotes }, { status: 201 }, ); }); diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index a0f93ad..fdcf518 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -42,6 +42,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { if (noGoParam) { try { initialNoGoAreas = JSON.parse(noGoParam); } catch { /* ignore */ } } + const initialNotes = searchParams.get("notes") ?? undefined; return (
@@ -67,6 +68,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) { returnUrl={returnUrl} initialWaypoints={initialWaypoints} initialNoGoAreas={initialNoGoAreas} + initialNotes={initialNotes} /> )} diff --git a/openspec/specs/session-notes/spec.md b/openspec/specs/session-notes/spec.md index 9399e89..41743fc 100644 --- a/openspec/specs/session-notes/spec.md +++ b/openspec/specs/session-notes/spec.md @@ -34,3 +34,25 @@ The notes editor SHALL use CodeMirror 6 with y-codemirror.next for Yjs binding. #### Scenario: Awareness field isolation - **WHEN** the notes editor sets cursor awareness state - **THEN** it does not conflict with map cursor awareness (uses separate awareness fields) + +### Requirement: Notes in GPX export +Notes SHALL be included in GPX exports as ``. + +#### Scenario: Export plan with notes +- **WHEN** a user exports a plan GPX and notes are present +- **THEN** the GPX contains `` with the notes text + +#### Scenario: Import GPX with notes +- **WHEN** a GPX file with `` is imported +- **THEN** the notes editor is populated with the description text + +### Requirement: Notes sync to Journal +Notes SHALL be saved to the Journal route description when saving via callback. + +#### Scenario: Save to Journal with notes +- **WHEN** a user saves a route to the Journal and notes are present +- **THEN** the route's description field is set from the notes text + +#### Scenario: Edit in Planner restores notes +- **WHEN** a user clicks "Edit in Planner" on a Journal route with a description +- **THEN** the Planner session's notes editor is populated with the description diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index d108bc1..064a5be 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -10,6 +10,7 @@ export interface NoGoArea { export function generateGpx(options: { name?: string; + description?: string; waypoints?: Waypoint[]; tracks?: TrackPoint[][]; noGoAreas?: NoGoArea[]; @@ -25,8 +26,11 @@ export function generateGpx(options: { ">", ]; - if (options.name) { - lines.push(" ", ` ${escapeXml(options.name)}`, " "); + if (options.name || options.description) { + lines.push(" "); + if (options.name) lines.push(` ${escapeXml(options.name)}`); + if (options.description) lines.push(` ${escapeXml(options.description)}`); + lines.push(" "); } if (options.waypoints) { diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index 114aad9..efe8f72 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -44,12 +44,13 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData { } const name = doc.querySelector("metadata > name")?.textContent ?? undefined; + const description = doc.querySelector("metadata > desc")?.textContent ?? undefined; const waypoints = parseWaypoints(doc); const tracks = parseTracks(doc); const noGoAreas = parseNoGoAreas(doc); const { totalDistance, ...elevation } = computeElevation(tracks); - return { name, waypoints, tracks, noGoAreas, distance: totalDistance, elevation }; + return { name, description, waypoints, tracks, noGoAreas, distance: totalDistance, elevation }; } function parseWaypoints(doc: Document): Waypoint[] { diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts index 9d8a30f..d1d7a70 100644 --- a/packages/gpx/src/types.ts +++ b/packages/gpx/src/types.ts @@ -20,6 +20,7 @@ export interface NoGoArea { export interface GpxData { name?: string; + description?: string; waypoints: Waypoint[]; tracks: TrackPoint[][]; noGoAreas: NoGoArea[];