Archive the monolithic route-features spec and replace with 9 focused OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping), undo-redo, local-dev-stack, route-sharing, route-discovery, activity-photos, osm-overlays, plus the existing changelog and komoot-import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
Context
The Planner stores waypoints as a Y.Array<Y.Map<unknown>> with lat, lon,
and optional name keys. The visual-redesign mockup (D4, D5) already shows
waypoint notes as italic text under the waypoint name in the sidebar, and a note
icon on map markers. This change implements the data model and interaction logic
behind those placeholders.
Decisions
D1: Note storage in Yjs
Each waypoint is a Y.Map in the waypoints Y.Array. Add an optional note
key of type string. Plain text only, no markup.
// Existing keys
yMap.get("lat") // number
yMap.get("lon") // number
yMap.get("name") // string | undefined
// New key
yMap.get("note") // string | undefined
Soft limit of ~500 characters enforced in the UI (character counter, input truncation) but not in the Yjs document itself. This keeps the data layer simple and avoids breaking collaborative edits if two users type near the limit simultaneously.
The Waypoint interface in @trails-cool/types gets a corresponding optional
field:
export interface Waypoint {
lat: number;
lon: number;
name?: string;
note?: string; // new
isDayBreak?: boolean;
}
D2: Editing UX in sidebar
The note appears as italic text below the waypoint name in the sidebar list item.
When empty, a muted placeholder reads "Add a note..." (i18n key:
planner.waypoint.notePlaceholder).
Clicking the note text (or placeholder) activates an inline <textarea> with:
- Auto-focus on click
- Auto-resize to content height
- Character counter showing remaining chars (e.g., "127 / 500")
- Save on blur (write to Y.Map)
- Cancel on Escape (revert to last saved value)
- No explicit save button - auto-save keeps the interaction lightweight
The textarea uses the same font styling as the display text (italic, --text-md color) so the transition between view and edit is seamless.
D3: Map marker note indicator
Waypoint markers that have a non-empty note display a small note icon (a document/pencil glyph) as a CSS pseudo-element or secondary Leaflet DivIcon element, positioned at the top-right of the marker.
Hover (desktop) or tap (mobile) on a marker with a note shows a Leaflet tooltip containing the note text. The tooltip has a max-width of 250px and truncates after 3 lines with ellipsis for long notes. Clicking "more" in the tooltip scrolls the sidebar to that waypoint and opens the editor.
D4: Collaborative editing
Notes are plain strings stored via yMap.set("note", value). Yjs conflict
resolution for Y.Map string values is last-write-wins, which is acceptable for
short text notes. This is the same behavior as waypoint names.
If finer-grained collaborative editing were needed (two users editing the same
note simultaneously), we would use a nested Y.Text. That complexity is not
justified for short notes that rarely have concurrent edits. If usage patterns
prove otherwise, upgrading to Y.Text is a backward-compatible change.
D5: GPX export
The generateGpx function in @trails-cool/gpx includes the note as a <desc>
element inside the <wpt> element, per GPX 1.1 spec:
<wpt lat="51.0504" lon="13.7373">
<name>Dresden Neustadt</name>
<desc>Water refill at train station</desc>
</wpt>
The <desc> element is only emitted when the note is non-empty. XML special
characters are escaped via the existing escapeXml helper.
GPX import (parsing) should also read <desc> into the note field when
present, so round-tripping preserves notes.
D6: Note display styling
Per the visual-redesign D4 mockup, notes render as italic text in --text-md
color below the waypoint name in the sidebar. Styling:
font-style: italic
color: var(--text-md) /* #5C5847 */
font-size: 0.8125rem /* 13px, one step below body 14px */
line-height: 1.3
max-height: 2.6em /* ~2 lines in display mode */
overflow: hidden
text-overflow: ellipsis
When the sidebar is in its current pre-redesign state (before visual-redesign
lands), use equivalent Tailwind classes (italic text-gray-500 text-xs).
D7: POI source - Overpass API
POI data comes from the Overpass API, which provides read access to OpenStreetMap data. When a waypoint is selected in the sidebar, we query for amenity, tourism, and natural nodes within a radius of the waypoint.
Query strategy:
- Endpoint:
https://overpass-api.de/api/interpreter(public instance) - Query type:
nodeelements within a bounding box derived from the waypoint coordinates +/- ~500m (~0.0045 degrees latitude, adjusted for longitude) - Filter by relevant tags (see D8 for POI types)
- Return compact JSON format (
[out:json])
Example query skeleton:
[out:json][timeout:10];
(
node["amenity"~"drinking_water|water_point|shelter|restaurant|cafe|fast_food"]({{bbox}});
node["tourism"~"camp_site|camp_pitch|alpine_hut|wilderness_hut|viewpoint"]({{bbox}});
node["natural"="spring"]({{bbox}});
node["amenity"="bicycle_parking"]({{bbox}});
node["amenity"="bicycle_repair_station"]({{bbox}});
);
out body;
The query runs client-side from the browser (no server proxy needed). The Overpass API supports CORS.
D8: POI types
POIs are categorized by type, each with an icon and OSM tag mapping. The set of displayed types depends on the active routing profile.
| Category | OSM Tags | Icon | Profiles |
|---|---|---|---|
| Water | amenity=drinking_water, amenity=water_point, natural=spring |
💧 | all |
| Shelter | tourism=alpine_hut, tourism=wilderness_hut, amenity=shelter |
🏠 | all |
| Camping | tourism=camp_site, tourism=camp_pitch |
⛺ | all |
| Food | amenity=restaurant, amenity=cafe, amenity=fast_food |
🍴 | all |
| Viewpoint | tourism=viewpoint |
👁 | all |
| Bicycle | amenity=bicycle_parking, amenity=bicycle_repair_station |
🔧 | trekking, fastbike |
The type list is defined in a POI_TYPES constant, making it easy to extend.
Each entry maps OSM tags to a category, icon, and i18n label key.
D9: POI display
When a waypoint is selected (clicked in sidebar or on map):
Map: Small circle markers appear around the selected waypoint showing nearby POIs. Each marker uses the category icon as a tooltip and is colored by type (blue for water, green for camping, orange for food, etc.). Markers are smaller than waypoint markers to avoid visual confusion. They disappear when the waypoint is deselected or a different waypoint is selected.
Sidebar: Below the waypoint's note area, a "Nearby" section lists POIs grouped by category. Each item shows: icon, POI name (or "Unnamed" + type), distance from waypoint (e.g., "120m"), and a snap button. The list is sorted by distance, closest first. Maximum 15 POIs shown to avoid overwhelming the UI; remaining are hidden behind "Show more".
Clicking a POI in the sidebar highlights it on the map. Clicking a POI marker on the map scrolls the sidebar to that POI.
D10: Snap behavior
Clicking the snap button (or clicking a POI marker on the map) performs:
- Move waypoint: Set
lat/lonon the waypoint Y.Map to the POI's coordinates. This triggers the existing route recalculation. - Set name: If the POI has an OSM
nametag and the waypoint has no name (or the user confirms overwrite), set the waypoint name from the POI. - Set note prefix: Prepend the POI type icon and label to the waypoint's note. For example, snapping to a campsite named "Waldcamp" sets the note to "⛺ Campsite" (or "⛺ Campsite - Waldcamp" if named). If the waypoint already has a note, the prefix is prepended on a new line.
- Update POI list: Re-fetch nearby POIs for the new location (since the waypoint moved).
All changes happen in a single Yjs transaction to keep the undo stack clean.
The user can always edit the name and note after snapping. Snapping is a convenience, not a constraint.
D11: POI caching
Overpass responses are cached in memory to avoid redundant network requests:
- Cache key: Quantized bounding box tile. Coordinates are rounded to a grid (~500m tiles) so nearby waypoints share a cache entry.
- TTL: 1 hour. POI data changes infrequently; stale data is acceptable.
- Storage: Plain
Map<string, { data: POI[]; timestamp: number }>in a module-level variable. Not stored in Yjs (POI data is ephemeral, not shared between collaborators). - Eviction: On access, expired entries are removed. Maximum 50 cached tiles to bound memory usage.
D12: Overpass rate limiting
The public Overpass API has usage policies (no more than 2 concurrent requests, 10,000 requests/day). We respect these with:
- Debounce: Wait 500ms after a waypoint is selected before querying. If the user clicks through waypoints quickly, only the last selection triggers a query.
- Concurrency: Maximum 1 in-flight Overpass request. If a new request is
needed while one is pending, the pending one is aborted (via
AbortController). - Backoff on 429: If Overpass returns HTTP 429 (rate limit), disable POI queries for 60 seconds and show a subtle "POI lookup unavailable" message.
- Timeout: 10-second timeout on Overpass requests. On timeout, fail silently (POIs are a nice-to-have, not critical).
Risks / Trade-offs
- Last-write-wins for notes: If two users edit the same waypoint's note simultaneously, one edit is lost. Acceptable for short notes; upgrade path to Y.Text exists.
- Soft character limit: Not enforced at the data layer. A programmatic client could write longer notes. UI handles gracefully with truncation.
- Note icon clutter on map: Many waypoints with notes could make the map busy. Mitigate by keeping the indicator small and subtle. Consider hiding indicators at low zoom levels in a future iteration.
- Overpass API availability: The public Overpass instance may be slow or unavailable. POI features degrade gracefully - the Planner works fine without them. No error modals; just empty POI lists.
- POI data quality: OSM data varies by region. Some areas have sparse POI coverage. Users should not rely solely on POI snapping for critical waypoints like water sources.
- Snap overwrites: Snapping moves the waypoint and can change its name/note. Mitigated by keeping the action explicit (requires click) and making all fields editable after snap. Undo via Yjs history restores the previous state.