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 [];