From c2abb64ee0b5175b425ea83b374af3bde0fc4599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 17 May 2026 23:58:21 +0200 Subject: [PATCH] Add per-waypoint notes and nearby POI snap to Planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `note` field on Waypoint type, stored in Yjs Y.Map and exported as `` inside `` in GPX - Inline textarea note editing in WaypointSidebar with auto-resize, character counter (500 max), save-on-blur, Escape cancel - Note indicator dot on map markers; note tooltip on hover - `useNearbyPois` hook: fetches POIs within 500m of selected waypoint via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit suppression - NearbyPoiMarkers component: renders POI markers on map for selected waypoint with snap-to-POI on click - Nearby section in WaypointSidebar: list with snap buttons, spinner, empty/rate-limited states, "Show more" toggle (5 → all) - Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs transaction behaviour Co-Authored-By: Claude Sonnet 4.6 --- .../app/components/NearbyPoiMarkers.tsx | 49 +++++ apps/planner/app/components/PlannerMap.tsx | 71 +++++-- apps/planner/app/components/SessionView.tsx | 9 +- .../app/components/WaypointSidebar.tsx | 176 +++++++++++++++++- apps/planner/app/lib/overpass.test.ts | 60 +++++- apps/planner/app/lib/overpass.ts | 26 +++ apps/planner/app/lib/snap-to-poi.test.ts | 123 ++++++++++++ apps/planner/app/lib/use-nearby-pois.ts | 74 ++++++++ apps/planner/app/lib/use-waypoint-manager.ts | 2 + openspec/changes/waypoint-notes/tasks.md | 67 +++---- packages/gpx/src/generate.test.ts | 35 ++++ packages/gpx/src/generate.ts | 3 + packages/gpx/src/parse.ts | 3 +- packages/i18n/src/locales/de.ts | 12 ++ packages/i18n/src/locales/en.ts | 12 ++ packages/types/src/index.ts | 1 + 16 files changed, 665 insertions(+), 58 deletions(-) create mode 100644 apps/planner/app/components/NearbyPoiMarkers.tsx create mode 100644 apps/planner/app/lib/snap-to-poi.test.ts create mode 100644 apps/planner/app/lib/use-nearby-pois.ts diff --git a/apps/planner/app/components/NearbyPoiMarkers.tsx b/apps/planner/app/components/NearbyPoiMarkers.tsx new file mode 100644 index 0000000..d5878fd --- /dev/null +++ b/apps/planner/app/components/NearbyPoiMarkers.tsx @@ -0,0 +1,49 @@ +import L from "leaflet"; +import { Marker, Tooltip } from "react-leaflet"; +import type { Poi } from "~/lib/overpass"; +import type { PoiCategory } from "@trails-cool/map-core"; + +interface NearbyPoiMarkersProps { + pois: Poi[]; + categories: PoiCategory[]; + onSnap: (poi: Poi) => void; +} + +function nearbyPoiIcon(color: string, icon: string): L.DivIcon { + return L.divIcon({ + className: "", + html: `
${icon}
`, + iconSize: [0, 0], + }); +} + +export function NearbyPoiMarkers({ pois, categories, onSnap }: NearbyPoiMarkersProps) { + return ( + <> + {pois.map((poi) => { + const cat = categories.find((c) => c.id === poi.category); + if (!cat) return null; + return ( + onSnap(poi) }} + zIndexOffset={900} + > + + {poi.name ?? cat.icon} {cat.icon !== poi.name ? cat.icon : ""} + + + ); + })} + + ); +} diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 25eceda..76c9de5 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useRef } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline } from "react-leaflet"; +import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; import L from "leaflet"; import { useTranslation } from "react-i18next"; import type { DayStage } from "@trails-cool/gpx"; @@ -26,22 +26,30 @@ import { } from "./MapHelpers"; import { useWaypointManager } from "~/lib/use-waypoint-manager"; import { useGpxDrop } from "~/lib/use-gpx-drop"; +import { useNearbyPois } from "~/lib/use-nearby-pois"; +import { NearbyPoiMarkers } from "./NearbyPoiMarkers"; +import { poiCategories } from "@trails-cool/map-core"; +import type { Poi } from "~/lib/overpass"; import "leaflet/dist/leaflet.css"; -function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon { +function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean, hasNote?: boolean): L.DivIcon { const bg = overnight ? "#8B6D3A" : "#2563eb"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; + const noteIndicator = hasNote + ? `` + : ""; return L.divIcon({ className: "", - html: `
${overnight ? "☾" : index + 1}
`, + html: `
+
${overnight ? "☾" : index + 1}
+ ${noteIndicator} +
`, iconSize: [0, 0], }); } @@ -68,11 +76,12 @@ interface PlannerMapProps { onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; highlightedWaypoint?: number | null; + selectedWaypointIndex?: number | null; onRouteHover?: (distance: number | null) => void; days?: DayStage[]; } -export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) { +export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, selectedWaypointIndex, onRouteHover, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const poiState = usePois(sessionId); useProfileDefaults(yjs, poiState); @@ -107,6 +116,25 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError); + const selectedWp = selectedWaypointIndex != null ? waypoints[selectedWaypointIndex] : undefined; + const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon); + + const handleSnapToPoi = useCallback((poi: Poi) => { + if (selectedWaypointIndex == null) return; + const yMap = yjs.waypoints.get(selectedWaypointIndex); + if (!yMap) return; + const cat = poiCategories.find((c) => c.id === poi.category); + const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? ""); + yjs.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"); + }, [selectedWaypointIndex, yjs, poiCategories]); + const handleRoutePolylineOut = useCallback(() => { onRouteHover?.(null); }, [onRouteHover]); @@ -150,6 +178,13 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, + {selectedWaypointIndex != null && nearbyPoisState.pois.length > 0 && ( + + )} {waypoints.map((wp, i) => ( { routeInteractionSuspendedRef.current = true; }, mouseout: () => { @@ -180,7 +215,15 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, setContextMenu({ x: orig.clientX, y: orig.clientY, index: i }); }, }} - /> + > + {wp.note && ( + + + {wp.note.length > 120 ? wp.note.slice(0, 120) + "…" : wp.note} + + + )} + ))} {days && days.length > 1 && days.map((day) => { diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 98783ee..0cf1936 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -113,7 +113,7 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction, addToast: (messa } -function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void }) { +function SidebarTabs({ yjs, routeStats, days, onWaypointHover, onWaypointSelect }: { yjs: YjsState; routeStats: ReturnType["routeStats"]; days: ReturnType; onWaypointHover: (index: number | null) => void; onWaypointSelect: (index: number | null) => void }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); @@ -136,7 +136,7 @@ function SidebarTabs({ yjs, routeStats, days, onWaypointHover }: { yjs: YjsState
{tab === "waypoints" ? ( - + ) : ( @@ -166,6 +166,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, const days = useDays(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); const [highlightedWaypoint, setHighlightedWaypoint] = useState(null); + const [selectedWaypointIndex, setSelectedWaypointIndex] = useState(null); const [highlightChartDistance, setHighlightChartDistance] = useState(null); const [isZoomedByChart, setIsZoomedByChart] = useState(false); const { toasts, addToast } = useToasts(); @@ -286,7 +287,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
} > - addToast(msg, "error")} days={days} /> + addToast(msg, "error")} days={days} /> @@ -303,7 +304,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, - + {toasts.length > 0 && ( diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index 3c2d645..cabcab6 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -1,15 +1,21 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import * as Y from "yjs"; import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; import type { DayStage } from "@trails-cool/gpx"; import { setOvernight, isOvernight } from "~/lib/overnight"; import { DayBreakdown } from "./DayBreakdown"; +import { useNearbyPois } from "~/lib/use-nearby-pois"; +import { poiCategories } from "@trails-cool/map-core"; +import type { Poi } from "~/lib/overpass"; + +const NOTE_MAX = 500; interface WaypointData { lat: number; lon: number; name?: string; + note?: string; overnight: boolean; } @@ -18,6 +24,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), })); } @@ -31,11 +38,16 @@ interface WaypointSidebarProps { }; days: DayStage[]; onWaypointHover?: (index: number | null) => void; + onWaypointSelect?: (index: number | null) => void; } -export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: WaypointSidebarProps) { +export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWaypointSelect }: WaypointSidebarProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); + const [editingNoteIndex, setEditingNoteIndex] = useState(null); + const [noteEditValue, setNoteEditValue] = useState(""); + const textareaRef = useRef(null); useEffect(() => { const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints)); @@ -60,7 +72,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp lat: item.get("lat") as number, lon: item.get("lon") as number, name: item.get("name") as string | undefined, + note: item.get("note") as string | undefined, overnight: isOvernight(item), + osmId: item.get("osmId") as number | undefined, + poiTags: item.get("poiTags") as Record | undefined, }; yjs.doc.transact(() => { yjs.waypoints.delete(from, 1); @@ -68,7 +83,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp yMap.set("lat", data.lat); yMap.set("lon", data.lon); if (data.name) yMap.set("name", data.name); + if (data.note) yMap.set("note", data.note); if (data.overnight) yMap.set("overnight", true); + if (data.osmId !== undefined) yMap.set("osmId", data.osmId); + if (data.poiTags) yMap.set("poiTags", data.poiTags); yjs.waypoints.insert(to, [yMap]); }, "local"); }, @@ -84,22 +102,125 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover }: Wayp [yjs, waypoints], ); + const setNote = useCallback( + (index: number, note: string) => { + const yMap = yjs.waypoints.get(index); + if (!yMap) return; + yjs.doc.transact(() => { + if (note.trim()) { + yMap.set("note", note.trim()); + } else { + yMap.delete("note"); + } + }, "local"); + }, + [yjs], + ); + + const selectWaypoint = useCallback( + (index: number | null) => { + setSelectedIndex(index); + onWaypointSelect?.(index); + }, + [onWaypointSelect], + ); + + const openNoteEditor = useCallback( + (index: number) => { + setNoteEditValue(waypoints[index]?.note ?? ""); + setEditingNoteIndex(index); + setTimeout(() => { + textareaRef.current?.focus(); + // auto-resize + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; + } + }, 0); + }, + [waypoints], + ); + + const snapToPoi = useCallback( + (poi: Poi) => { + if (selectedIndex === null) return; + const yMap = yjs.waypoints.get(selectedIndex); + if (!yMap) return; + const cat = poiCategories.find((c) => c.id === poi.category); + const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? ""); + yjs.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"); + }, + [selectedIndex, yjs, poiCategories], + ); + + const selectedWp = selectedIndex !== null ? waypoints[selectedIndex] : undefined; + const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon); + const [showAllNearby, setShowAllNearby] = useState(false); + const hasMultipleDays = days.length > 1; const renderWaypointRow = (wp: WaypointData, i: number) => (
  • selectWaypoint(selectedIndex === i ? null : i)} onMouseEnter={() => onWaypointHover?.(i)} onMouseLeave={() => onWaypointHover?.(null)} > - + {i + 1}

    {wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}

    + {/* Note display / editor */} + {editingNoteIndex === i ? ( +
    e.stopPropagation()}> +