diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index f58eb3b..ca93b23 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -25,6 +25,7 @@ function getWaypoints(yjs: YjsState) { lon: yMap.get("lon") as number, name: yMap.get("name") as string | undefined, isDayBreak: yMap.get("overnight") === true ? true : undefined, + note: yMap.get("note") as string | undefined, osmId: yMap.get("osmId") as number | undefined, poiTags: yMap.get("poiTags") as WaypointPoiTags | undefined, })); diff --git a/apps/planner/app/lib/use-gpx-drop.ts b/apps/planner/app/lib/use-gpx-drop.ts index c0bcd8b..32cd4ce 100644 --- a/apps/planner/app/lib/use-gpx-drop.ts +++ b/apps/planner/app/lib/use-gpx-drop.ts @@ -53,6 +53,7 @@ export function useGpxDrop(yjs: YjsState, onImportError?: (message: string) => v yMap.set("lon", wp.lon); if (wp.name) yMap.set("name", wp.name); if (wp.isDayBreak) yMap.set("overnight", true); + if (wp.note) yMap.set("note", wp.note); yjs.waypoints.push([yMap]); } diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 7a4918d..b60b826 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -469,4 +469,113 @@ test.describe("Planner", () => { const panelText = await page.getByText("Drinking water").textContent(); expect(panelText).toBeTruthy(); }); + + test("waypoint note roundtrips through GPX import and export", async ({ page, request }) => { + const sessionResp = await request.post("/api/sessions", { data: {} }); + const { url } = await sessionResp.json(); + + await mockBRouter(page); + + // Drop a GPX with a on a onto the map + const gpxWithNote = ` + + BerlinRefill water here + Spandau + + 34 + 40 + +`; + + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + + const dataTransfer = await page.evaluateHandle((content) => { + const dt = new DataTransfer(); + const file = new File([content], "noted.gpx", { type: "application/gpx+xml" }); + dt.items.add(file); + return dt; + }, gpxWithNote); + + const map = page.locator(".leaflet-container"); + await map.dispatchEvent("dragenter", { dataTransfer }); + await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); + page.on("dialog", (dialog) => dialog.accept()); + await map.dispatchEvent("drop", { dataTransfer }); + + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 10000 }); + + // The note should be visible in the sidebar + const sidebar = page.locator("aside"); + await expect(sidebar.getByText("Refill water here")).toBeVisible({ timeout: 5000 }); + + // Export as plan and verify the note is preserved + await page.getByRole("button", { name: "▾" }).click(); + await expect(page.getByText("Export Plan")).toBeVisible({ timeout: 3000 }); + const downloadPromise = page.waitForEvent("download"); + await page.getByText("Export Plan").first().click(); + const download = await downloadPromise; + const gpxStream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of gpxStream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const gpxText = Buffer.concat(chunks).toString("utf-8"); + + expect(gpxText).toContain("Refill water here"); + expect(gpxText.indexOf("Refill water here")); + }); + + test("nearby POI snap moves waypoint and prepends note prefix", async ({ page, request }) => { + const sessionResp = await request.post("/api/sessions", { data: {} }); + const { url } = await sessionResp.json(); + + await mockBRouter(page); + + // Mock the Overpass proxy to return a nearby POI + await page.route("**/api/overpass", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + elements: [ + { + type: "node", + id: 99001, + lat: 52.521, + lon: 13.406, + tags: { amenity: "drinking_water", name: "Stadtbrunnen" }, + }, + ], + }), + }); + }); + + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405, name: "Start" }, + { lat: 52.515, lon: 13.351, name: "End" }, + ]))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); + + const sidebar = page.locator("aside"); + + // Click the first waypoint row to select it (triggers nearby POI fetch) + const firstRow = sidebar.locator("li").filter({ has: page.locator("span.rounded-full").first() }).first(); + await firstRow.click(); + + // Wait for the Nearby section to appear with the mock POI + await expect(sidebar.getByText("Nearby")).toBeVisible({ timeout: 5000 }); + await expect(sidebar.getByText("Stadtbrunnen")).toBeVisible({ timeout: 5000 }); + + // Click the snap button for that POI + const snapButton = sidebar.locator("li").filter({ hasText: "Stadtbrunnen" }).getByRole("button", { name: /snap/i }); + await snapButton.click(); + + // The waypoint note should now contain the POI prefix (water emoji + name) + await expect(firstRow.locator("p.italic")).not.toHaveText(/Add a note/); + const noteText = await firstRow.locator("p.italic").textContent(); + expect(noteText).toContain("Stadtbrunnen"); + }); }); diff --git a/openspec/changes/waypoint-notes/.openspec.yaml b/openspec/changes/archive/2026-05-18-waypoint-notes/.openspec.yaml similarity index 100% rename from openspec/changes/waypoint-notes/.openspec.yaml rename to openspec/changes/archive/2026-05-18-waypoint-notes/.openspec.yaml diff --git a/openspec/changes/waypoint-notes/design.md b/openspec/changes/archive/2026-05-18-waypoint-notes/design.md similarity index 100% rename from openspec/changes/waypoint-notes/design.md rename to openspec/changes/archive/2026-05-18-waypoint-notes/design.md diff --git a/openspec/changes/waypoint-notes/proposal.md b/openspec/changes/archive/2026-05-18-waypoint-notes/proposal.md similarity index 100% rename from openspec/changes/waypoint-notes/proposal.md rename to openspec/changes/archive/2026-05-18-waypoint-notes/proposal.md diff --git a/openspec/changes/waypoint-notes/specs/waypoint-notes/spec.md b/openspec/changes/archive/2026-05-18-waypoint-notes/specs/waypoint-notes/spec.md similarity index 100% rename from openspec/changes/waypoint-notes/specs/waypoint-notes/spec.md rename to openspec/changes/archive/2026-05-18-waypoint-notes/specs/waypoint-notes/spec.md diff --git a/openspec/changes/waypoint-notes/tasks.md b/openspec/changes/archive/2026-05-18-waypoint-notes/tasks.md similarity index 97% rename from openspec/changes/waypoint-notes/tasks.md rename to openspec/changes/archive/2026-05-18-waypoint-notes/tasks.md index 29b4763..bb667f8 100644 --- a/openspec/changes/waypoint-notes/tasks.md +++ b/openspec/changes/archive/2026-05-18-waypoint-notes/tasks.md @@ -29,7 +29,7 @@ ## 6. Testing (Notes) - [x] 6.1 Unit tests: per-waypoint `` in GPX generation; `` inside `` parsed into `note`; verify no collision with `metadata > desc` -- [ ] 6.2 E2E test: type a note in sidebar, blur, verify note persists; verify GPX export contains `` inside `` +- [x] 6.2 E2E test: type a note in sidebar, blur, verify note persists; verify GPX export contains `` inside `` ## 7. POI Data Layer (per-waypoint) @@ -62,4 +62,4 @@ - [x] 11.2 POI cache unit tests — `poi-cache.test.ts` already covers this - [x] 11.3 Unit tests for `fetchNearbyPois`: correct bbox from lat/lon/radius, category filtering - [x] 11.4 Unit tests for snap: coords updated, name set, note prefix prepended, existing note preserved, all in one Yjs transaction -- [ ] 11.5 E2E test: mock Overpass via route handler, select a waypoint, verify Nearby list appears, click snap, verify waypoint moved and note prefixed +- [x] 11.5 E2E test: mock Overpass via route handler, select a waypoint, verify Nearby list appears, click snap, verify waypoint moved and note prefixed diff --git a/openspec/specs/waypoint-notes/spec.md b/openspec/specs/waypoint-notes/spec.md new file mode 100644 index 0000000..b191a63 --- /dev/null +++ b/openspec/specs/waypoint-notes/spec.md @@ -0,0 +1,50 @@ +## Purpose + +Per-waypoint plain-text notes in the Planner, with nearby POI discovery and snap-to-POI functionality. + +## Requirements + +### Requirement: Per-waypoint text notes +Each waypoint SHALL support an optional plain-text note synced via Yjs. + +#### Scenario: Add note to waypoint +- **WHEN** a user clicks the note area under a waypoint in the sidebar and types text +- **THEN** the note is stored in the waypoint's Y.Map as a `note` string field +- **AND** auto-saves on blur + +#### Scenario: Note syncs to participants +- **WHEN** a user adds or edits a waypoint note +- **THEN** all other participants see the update in real-time via Yjs + +### Requirement: Map note indicators +Waypoint markers with notes SHALL show a visual indicator on the map. + +#### Scenario: Note icon on marker +- **WHEN** a waypoint has a note +- **THEN** its map marker shows a small note icon + +#### Scenario: Note tooltip +- **WHEN** a user hovers or taps a marker with a note +- **THEN** the note text appears in a tooltip + +### Requirement: Notes in GPX export +Waypoint notes SHALL be exported as `` elements in GPX output. + +#### Scenario: Export notes +- **WHEN** a user exports a plan with waypoint notes +- **THEN** each waypoint's note appears as a `` element in the GPX file + +### Requirement: Nearby POI display +When a waypoint is selected, nearby POIs from OpenStreetMap SHALL be shown on the map and in the sidebar. + +#### Scenario: POI lookup +- **WHEN** a user selects a waypoint +- **THEN** nearby POIs are fetched from the Overpass API and displayed as small markers on the map and as a list in the sidebar + +### Requirement: Snap waypoint to POI +Users SHALL be able to move a waypoint to a nearby POI's exact coordinates. + +#### Scenario: Snap to POI +- **WHEN** a user clicks a nearby POI +- **THEN** the waypoint moves to the POI's coordinates +- **AND** the POI's name and type are added as a note prefix (e.g., "Campsite - Waldcamp Fichtelberg") diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index 2283b88..5d2c5bc 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -5,7 +5,7 @@ import type { GpxData } from "./types.ts"; * Uses explicit elements if present, otherwise simplifies the * track using Douglas-Peucker to find significant turning points. */ -export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean }> { +export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string }> { if (gpxData.waypoints.length > 0) return gpxData.waypoints; if (gpxData.tracks.length === 0) return [];