+
{t("nearbyPoi.heading")}
+ {nearbyPoisState.status === "loading" && (
+
{t("nearbyPoi.loading")}
+ )}
+ {nearbyPoisState.status === "rate_limited" && (
+
{t("nearbyPoi.rateLimited")}
+ )}
+ {(nearbyPoisState.status === "done" || nearbyPoisState.status === "error") && nearbyPoisState.pois.length === 0 && (
+
{t("nearbyPoi.empty")}
+ )}
+ {nearbyPoisState.pois.length > 0 && (
+ <>
+
+ {(showAllNearby ? nearbyPoisState.pois : nearbyPoisState.pois.slice(0, 5)).map((poi) => {
+ const cat = poiCategories.find((c) => c.id === poi.category);
+ return (
+ -
+ {cat?.icon ?? "📍"}
+
+ {poi.name ?? cat?.id ?? poi.category}
+
+
+
+ );
+ })}
+
+ {nearbyPoisState.pois.length > 5 && !showAllNearby && (
+
+ )}
+ >
+ )}
+
+ )}
+
{/* Stats footer — only shown for single-day view (multi-day shows per-day stats inline) */}
{!hasMultipleDays && routeStats && routeStats.distance !== undefined && (
diff --git a/apps/planner/app/lib/overpass.test.ts b/apps/planner/app/lib/overpass.test.ts
index 0e284e3..a6036ae 100644
--- a/apps/planner/app/lib/overpass.test.ts
+++ b/apps/planner/app/lib/overpass.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect } from "vitest";
-import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { buildQuery, parseResponse, deduplicateById, quantizeBbox, fetchNearbyPois, type Poi } from "./overpass.ts";
import { poiCategories } from "@trails-cool/map-core";
describe("buildQuery", () => {
@@ -141,3 +141,59 @@ describe("deduplicateById", () => {
expect(result[1]!.id).toBe(2);
});
});
+
+describe("fetchNearbyPois", () => {
+ const emptyOverpassResponse = { elements: [] };
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: true,
+ text: async () => JSON.stringify(emptyOverpassResponse),
+ json: async () => emptyOverpassResponse,
+ }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("constructs a bbox from lat/lon/radius and calls fetch", async () => {
+ const categories = poiCategories.filter((c) => c.id === "drinking_water");
+ await fetchNearbyPois(50.0, 10.0, 500, categories);
+
+ expect(fetch).toHaveBeenCalledOnce();
+ const rawBody = (((fetch as ReturnType
).mock.calls[0] as unknown[])[1]) as unknown as { body: string };
+ const body = decodeURIComponent(rawBody.body.replace("data=", ""));
+ // bbox quantized to 0.01° grid; 500m at lat=50 well within one cell
+ expect(body).toMatch(/\[bbox:4\d\.\d+,\d+\.\d+,50\.\d+,10\.\d+\]/);
+ });
+
+ it("bbox is symmetric around the input point", async () => {
+ const lat = 48.0;
+ const lon = 11.0;
+ const radius = 1000;
+ const categories = poiCategories.filter((c) => c.id === "drinking_water");
+ await fetchNearbyPois(lat, lon, radius, categories);
+
+ const rawBody = (((fetch as ReturnType).mock.calls[0] as unknown[])[1]) as unknown as { body: string };
+ const body = decodeURIComponent(rawBody.body.replace("data=", ""));
+ const match = body.match(/\[bbox:([\d.]+),([\d.]+),([\d.]+),([\d.]+)\]/);
+ expect(match).not.toBeNull();
+ const [, south, west, north, east] = (match as RegExpMatchArray).map(Number);
+ // After quantization the center might not be exactly lat/lon, but the extent should be ≥ radius
+ const DEG_PER_METER_LAT = 1 / 111320;
+ const dLat = radius * DEG_PER_METER_LAT;
+ expect((north as number) - (south as number)).toBeGreaterThanOrEqual(dLat * 2 - 0.02);
+ expect((east as number) - (west as number)).toBeGreaterThan(0);
+ });
+
+ it("forwards AbortSignal to fetch", async () => {
+ const controller = new AbortController();
+ const categories = poiCategories.filter((c) => c.id === "drinking_water");
+ await fetchNearbyPois(50.0, 10.0, 500, categories, controller.signal);
+
+ expect(fetch).toHaveBeenCalledOnce();
+ const callOptions = (((fetch as ReturnType).mock.calls[0] as unknown[])[1]) as unknown as { signal: AbortSignal };
+ expect(callOptions?.signal).toBe(controller.signal);
+ });
+});
diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts
index d6bc543..cccc642 100644
--- a/apps/planner/app/lib/overpass.ts
+++ b/apps/planner/app/lib/overpass.ts
@@ -183,3 +183,29 @@ export class OverpassRateLimitError extends Error {
this.name = "OverpassRateLimitError";
}
}
+
+// Degrees of latitude per meter (approximate, valid globally).
+const DEG_PER_METER_LAT = 1 / 111320;
+
+/**
+ * Fetch POIs within `radiusMeters` of a coordinate.
+ * Builds a bbox around the point and reuses queryPois + the existing proxy.
+ */
+export async function fetchNearbyPois(
+ lat: number,
+ lon: number,
+ radiusMeters: number,
+ categories: import("@trails-cool/map-core").PoiCategory[],
+ signal?: AbortSignal,
+ sessionId = "nearby",
+): Promise {
+ const dLat = radiusMeters * DEG_PER_METER_LAT;
+ const dLon = dLat / Math.cos((lat * Math.PI) / 180);
+ const bbox: BBox = {
+ south: lat - dLat,
+ north: lat + dLat,
+ west: lon - dLon,
+ east: lon + dLon,
+ };
+ return queryPois(bbox, categories, sessionId, signal);
+}
diff --git a/apps/planner/app/lib/snap-to-poi.test.ts b/apps/planner/app/lib/snap-to-poi.test.ts
new file mode 100644
index 0000000..70a6f3a
--- /dev/null
+++ b/apps/planner/app/lib/snap-to-poi.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect } from "vitest";
+import * as Y from "yjs";
+import { poiCategories } from "@trails-cool/map-core";
+import type { Poi } from "./overpass.ts";
+
+// Mirrors the snapToPoi logic from WaypointSidebar / PlannerMap
+function snapToPoi(
+ doc: Y.Doc,
+ waypoints: Y.Array>,
+ index: number,
+ poi: Poi,
+) {
+ const yMap = waypoints.get(index);
+ if (!yMap) return;
+ const cat = poiCategories.find((c) => c.id === poi.category);
+ const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? "");
+ doc.transact(() => {
+ yMap.set("lat", poi.lat);
+ yMap.set("lon", poi.lon);
+ if (poi.name && !yMap.get("name")) yMap.set("name", poi.name);
+ const existing = yMap.get("note") as string | undefined;
+ yMap.set("note", existing ? `${notePrefix}\n${existing}` : notePrefix);
+ if (poi.id) yMap.set("osmId", poi.id);
+ }, "local");
+}
+
+function makeWaypoint(doc: Y.Doc, lat: number, lon: number, extra?: Record): Y.Map {
+ const yMap = new Y.Map();
+ yMap.set("lat", lat);
+ yMap.set("lon", lon);
+ if (extra) {
+ for (const [k, v] of Object.entries(extra)) yMap.set(k, v);
+ }
+ return yMap;
+}
+
+describe("snapToPoi", () => {
+ it("updates coords to POI position", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
+
+ const poi: Poi = { id: 42, lat: 50.1, lon: 10.1, category: "drinking_water", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ expect(waypoints.get(0)!.get("lat")).toBe(50.1);
+ expect(waypoints.get(0)!.get("lon")).toBe(10.1);
+ });
+
+ it("sets osmId from POI", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
+
+ const poi: Poi = { id: 99, lat: 50.1, lon: 10.1, category: "shelter", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ expect(waypoints.get(0)!.get("osmId")).toBe(99);
+ });
+
+ it("sets name from POI when waypoint has no name", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
+
+ const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Stadtbrunnen", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ expect(waypoints.get(0)!.get("name")).toBe("Stadtbrunnen");
+ });
+
+ it("does not overwrite an existing name", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0, { name: "My Stop" })]);
+
+ const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ expect(waypoints.get(0)!.get("name")).toBe("My Stop");
+ });
+
+ it("prepends icon+name prefix to note", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
+
+ const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ const note = waypoints.get(0)!.get("note") as string;
+ expect(note).toContain("Brunnen");
+ const cat = poiCategories.find((c) => c.id === "drinking_water")!;
+ expect(note).toContain(cat.icon);
+ });
+
+ it("preserves existing note content after prefix", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0, { note: "Refill here" })]);
+
+ const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ const note = waypoints.get(0)!.get("note") as string;
+ expect(note).toContain("Refill here");
+ expect(note.indexOf("Brunnen")).toBeLessThan(note.indexOf("Refill here"));
+ });
+
+ it("performs all changes in a single Yjs transaction", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray>("waypoints");
+ waypoints.insert(0, [makeWaypoint(doc, 50.0, 10.0)]);
+
+ let txCount = 0;
+ doc.on("afterTransaction", () => { txCount++; });
+
+ const poi: Poi = { id: 1, lat: 50.1, lon: 10.1, category: "drinking_water", name: "Brunnen", tags: {} };
+ snapToPoi(doc, waypoints, 0, poi);
+
+ expect(txCount).toBe(1);
+ });
+});
diff --git a/apps/planner/app/lib/use-nearby-pois.ts b/apps/planner/app/lib/use-nearby-pois.ts
new file mode 100644
index 0000000..af83872
--- /dev/null
+++ b/apps/planner/app/lib/use-nearby-pois.ts
@@ -0,0 +1,74 @@
+import { useState, useEffect, useRef } from "react";
+import { fetchNearbyPois, OverpassRateLimitError, type Poi } from "./overpass";
+import { poiCategories } from "@trails-cool/map-core";
+
+const NEARBY_RADIUS_METERS = 500;
+const DEBOUNCE_MS = 500;
+const TIMEOUT_MS = 10_000;
+const RATE_LIMIT_SUPPRESS_MS = 60_000;
+
+export type NearbyPoisStatus = "idle" | "loading" | "done" | "rate_limited" | "error";
+
+export interface NearbyPoisState {
+ pois: Poi[];
+ status: NearbyPoisStatus;
+}
+
+const rateLimitedUntilRef = { current: 0 };
+
+export function useNearbyPois(
+ lat: number | undefined,
+ lon: number | undefined,
+): NearbyPoisState {
+ const [state, setState] = useState({ pois: [], status: "idle" });
+ const abortRef = useRef(null);
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (lat === undefined || lon === undefined) {
+ setState({ pois: [], status: "idle" });
+ return;
+ }
+
+ // Clear previous debounce
+ if (timerRef.current) clearTimeout(timerRef.current);
+ abortRef.current?.abort();
+
+ timerRef.current = setTimeout(async () => {
+ if (Date.now() < rateLimitedUntilRef.current) {
+ setState({ pois: [], status: "rate_limited" });
+ return;
+ }
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
+ setState((prev) => ({ ...prev, status: "loading" }));
+
+ try {
+ const pois = await fetchNearbyPois(lat, lon, NEARBY_RADIUS_METERS, poiCategories, controller.signal);
+ if (!controller.signal.aborted) {
+ setState({ pois, status: "done" });
+ }
+ } catch (err) {
+ if (controller.signal.aborted) return;
+ if (err instanceof OverpassRateLimitError) {
+ rateLimitedUntilRef.current = Date.now() + RATE_LIMIT_SUPPRESS_MS;
+ setState({ pois: [], status: "rate_limited" });
+ } else {
+ setState({ pois: [], status: "error" });
+ }
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }, DEBOUNCE_MS);
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ abortRef.current?.abort();
+ };
+ }, [lat, lon]);
+
+ return state;
+}
diff --git a/apps/planner/app/lib/use-waypoint-manager.ts b/apps/planner/app/lib/use-waypoint-manager.ts
index 085f4bb..bc6e204 100644
--- a/apps/planner/app/lib/use-waypoint-manager.ts
+++ b/apps/planner/app/lib/use-waypoint-manager.ts
@@ -11,6 +11,7 @@ export interface WaypointData {
lat: number;
lon: number;
name?: string;
+ note?: string;
overnight: boolean;
}
@@ -19,6 +20,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[]
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
+ note: yMap.get("note") as string | undefined,
overnight: isOvernight(yMap),
}));
}
diff --git a/openspec/changes/waypoint-notes/tasks.md b/openspec/changes/waypoint-notes/tasks.md
index f4545df..29b4763 100644
--- a/openspec/changes/waypoint-notes/tasks.md
+++ b/openspec/changes/waypoint-notes/tasks.md
@@ -1,64 +1,65 @@
## 1. Data Model
-- [ ] 1.1 Add optional `note?: string` field to `Waypoint` interface in `packages/types/src/index.ts`
-- [ ] 1.2 Update `WaypointData` interface and `getWaypointsFromYjs` in `apps/planner/app/components/WaypointSidebar.tsx` to read `note` from Y.Map
-- [ ] 1.3 Update `moveWaypoint` in WaypointSidebar to preserve the `note` field when reconstructing the Y.Map
+- [x] 1.1 Add optional `note?: string` field to `Waypoint` interface in `packages/types/src/index.ts`
+- [x] 1.2 Read `note` from Y.Map in `WaypointSidebar.tsx` (add to `WaypointData` interface and the mapping loop)
+- [x] 1.3 Preserve `note` when reconstructing the Y.Map in `moveWaypoint` (WaypointSidebar)
## 2. Sidebar Note Display
-- [ ] 2.1 Add note display below waypoint name in sidebar list item (italic, muted text, truncated to 2 lines)
-- [ ] 2.2 Show "Add a note..." placeholder when note is empty (i18n key: `planner.waypoint.notePlaceholder`)
-- [ ] 2.3 Add inline `