Sync notes through GPX, Journal, and Planner roundtrip
Full notes lifecycle: - GPX: description field in GpxData, <metadata><desc> 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 <desc> 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) <noreply@anthropic.com>
This commit is contained in:
parent
b12b706b77
commit
08070cdd90
13 changed files with 62 additions and 9 deletions
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<YjsState | null>(null);
|
||||
const providerRef = useRef<WebsocketProvider | null>(null);
|
||||
|
|
@ -116,6 +117,9 @@ export function useYjs(
|
|||
noGoAreas.push([yMap]);
|
||||
}
|
||||
}
|
||||
if (initialNotes && notes.length === 0) {
|
||||
notes.insert(0, initialNotes);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
|
|
@ -67,6 +68,7 @@ export default function SessionPage({ loaderData }: Route.ComponentProps) {
|
|||
returnUrl={returnUrl}
|
||||
initialWaypoints={initialWaypoints}
|
||||
initialNoGoAreas={initialNoGoAreas}
|
||||
initialNotes={initialNotes}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 `<metadata><desc>`.
|
||||
|
||||
#### Scenario: Export plan with notes
|
||||
- **WHEN** a user exports a plan GPX and notes are present
|
||||
- **THEN** the GPX contains `<metadata><desc>` with the notes text
|
||||
|
||||
#### Scenario: Import GPX with notes
|
||||
- **WHEN** a GPX file with `<metadata><desc>` 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
|
||||
|
|
|
|||
|
|
@ -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(" <metadata>", ` <name>${escapeXml(options.name)}</name>`, " </metadata>");
|
||||
if (options.name || options.description) {
|
||||
lines.push(" <metadata>");
|
||||
if (options.name) lines.push(` <name>${escapeXml(options.name)}</name>`);
|
||||
if (options.description) lines.push(` <desc>${escapeXml(options.description)}</desc>`);
|
||||
lines.push(" </metadata>");
|
||||
}
|
||||
|
||||
if (options.waypoints) {
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export interface NoGoArea {
|
|||
|
||||
export interface GpxData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
waypoints: Waypoint[];
|
||||
tracks: TrackPoint[][];
|
||||
noGoAreas: NoGoArea[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue