Add E2E tests for waypoint notes and nearby POI snap; archive waypoint-notes

- E2E: type a note, blur, verify it persists and appears in GPX <desc> on <wpt>
- E2E: mock Overpass, select waypoint, verify Nearby list, snap to POI,
  verify note prefix prepended
- Sync waypoint-notes delta spec → openspec/specs/waypoint-notes/spec.md
- Archive waypoint-notes change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-18 00:06:07 +02:00
parent 763aa475ea
commit 41217ef658
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
7 changed files with 149 additions and 2 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View file

@ -0,0 +1,250 @@
## 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.
```typescript
// 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:
```typescript
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:
```xml
<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: `node` elements 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:
1. **Move waypoint**: Set `lat`/`lon` on the waypoint Y.Map to the POI's
coordinates. This triggers the existing route recalculation.
2. **Set name**: If the POI has an OSM `name` tag and the waypoint has no name
(or the user confirms overwrite), set the waypoint name from the POI.
3. **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.
4. **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.

View file

@ -0,0 +1,76 @@
## Why
Waypoints in the Planner are just coordinates with optional names. When planning
a multi-day route, users need to note *why* a waypoint matters: "Water refill at
gas station", "Tricky gravel crossing - dismount", "Campsite with shelter",
"Scenic viewpoint over the valley". Without notes, this context lives outside the
tool (paper, chat messages, memory) and gets lost.
A related problem: waypoints placed by clicking on the map often miss nearby
points of interest by a few meters. A user clicks near a water source, campsite,
or shelter, but the waypoint lands on the road 30 meters away. They have to
manually look up the POI name, type it in, and nudge the waypoint. This is
tedious and error-prone, especially on mobile. POIs give immediate context to
waypoints - the same kind of context that notes provide in free-form text.
## What Changes
- **Per-waypoint text notes**: Each waypoint in the Yjs document gets an optional
`note` string field. Plain text, no rich formatting.
- **Inline editing in sidebar**: Click the note area under a waypoint name to
edit. Auto-saves on blur. Placeholder "Add a note..." when empty.
- **Map indicator**: Markers with notes show a small note icon. Hover/tap reveals
the note in a tooltip.
- **GPX export**: Notes are included as `<desc>` elements in waypoint GPX output.
- **Shared type update**: The `Waypoint` interface in `@trails-cool/types` gets
an optional `note` field.
- **Nearby POI display**: When a waypoint is selected, query nearby POIs from
OpenStreetMap (via Overpass API) and show them as small markers on the map and
as a list in the sidebar.
- **Snap to POI**: Click a nearby POI to move the waypoint to its exact
coordinates, inheriting the POI's name and type as a note prefix (e.g.,
"⛺ Campsite - Waldcamp Fichtelberg"). The user can edit after snapping.
## Capabilities
### New Capabilities
- `waypoint-notes`: Per-waypoint text notes with inline editing, map indicators,
and GPX export
- `poi-snapping`: Nearby POI lookup via Overpass API with snap-to-POI interaction
on map and sidebar
### Modified Capabilities
- `planner-session`: Waypoint Y.Map objects gain a `note` string field
- `gpx-export`: Waypoint `<desc>` element populated from notes
- `shared-types`: `Waypoint` interface extended with optional `note`
- `planner-map`: POI markers shown near selected waypoint, click to snap
## Non-Goals
- Rich text (bold, links, images) - plain text only
- Comments from other users - notes belong to the waypoint, not a thread
- Note history or versioning beyond what Yjs provides
- Markdown rendering
- Full OSM data overlay across the entire map (separate feature)
- POI search or filtering across the whole map viewport
- Custom POI database - we use OpenStreetMap data exclusively
- Offline POI data - requires network access to Overpass API
## Impact
- **Yjs schema**: New `note` string key on waypoint Y.Map (backward compatible -
old clients ignore unknown keys)
- **Shared types**: `Waypoint.note?: string` added to `@trails-cool/types`
- **GPX package**: `generateGpx` writes `<desc>` when waypoint has a note
- **Sidebar**: Inline note display and editing below waypoint name; POI list
under selected waypoint with snap buttons
- **Map markers**: Note indicator icon and tooltip; POI markers near selected
waypoint
- **New Overpass API client**: Fetches nearby POIs for a given coordinate,
with caching and rate limiting
- **i18n**: New translation keys for note placeholder, labels, POI types,
and snap actions (en + de)
- **No database changes**: Planner is stateless, all state lives in Yjs. POI
cache is ephemeral (in-memory, not persisted)

View file

@ -0,0 +1,46 @@
## ADDED 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 `<desc>` elements in GPX output.
#### Scenario: Export notes
- **WHEN** a user exports a plan with waypoint notes
- **THEN** each waypoint's note appears as a `<desc>` 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")

View file

@ -0,0 +1,65 @@
## 1. Data Model
- [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
- [x] 2.1 Add note display below waypoint name in sidebar list item (italic, muted text, truncated to 2 lines)
- [x] 2.2 Show "Add a note..." placeholder when note is empty (i18n key: `planner.waypoint.notePlaceholder`)
- [x] 2.3 Add inline `<textarea>` editing: click to edit, auto-focus, auto-resize, save on blur, cancel on Escape
- [x] 2.4 Add character counter (e.g., "127 / 500") visible during editing
## 3. Map Markers
- [x] 3.1 Add note indicator icon on waypoint markers that have a non-empty note
- [x] 3.2 Show note text in Leaflet tooltip on hover/tap for markers with notes
## 4. GPX Export & Import
- [x] 4.1 Emit `<desc>` on `<wpt>` in `generateGpx` when waypoint has a note (note: route-level `<desc>` already exists in `<metadata>`, this is per-waypoint)
- [x] 4.2 Read `<desc>` inside `<wpt>` into waypoint `note` field in `parseWaypoints` (note: `metadata > desc` is already parsed separately)
## 5. i18n
- [x] 5.1 Add translation keys for note UI strings (en + de): `planner.waypoint.notePlaceholder`, character counter label, Escape cancel hint
- [x] 5.2 Add translation keys for POI snap action labels and nearby POI status messages (en + de) — POI type labels already exist in `@trails-cool/map-core`
## 6. Testing (Notes)
- [x] 6.1 Unit tests: per-waypoint `<desc>` in GPX generation; `<desc>` inside `<wpt>` parsed into `note`; verify no collision with `metadata > desc`
- [x] 6.2 E2E test: type a note in sidebar, blur, verify note persists; verify GPX export contains `<desc>` inside `<wpt>`
## 7. POI Data Layer (per-waypoint)
- [x] 7.1 Overpass client (`overpass.ts`), proxy endpoint, `buildQuery`, `parseResponse` — already shipped
- [x] 7.2 `Poi` interface and category definitions — in `overpass.ts` + `@trails-cool/map-core`
- [x] 7.3 Overpass query building per routing profile — already in `buildQuery`
- [x] 7.4 POI cache (`poi-cache.ts`) with quantized bbox, TTL, eviction — already shipped
- [x] 7.5 Add `fetchNearbyPois(lat, lon, radiusMeters, categories)` to `overpass.ts`: constructs a ~500m bbox from a single coordinate and reuses `buildQuery` + the existing `/api/overpass` proxy
## 8. POI Map Display
- [x] 8.1 Add `selectedWaypointIndex` state lifted to `SessionView` (or passed via callback from `WaypointSidebar``PlannerMap`)
- [x] 8.2 Create `NearbyPoiMarkers` component: receives `Poi[]` for the selected waypoint + renders small circle markers by category with name tooltip — distinct from the overlay `PoiLayer` markers
- [x] 8.3 Wire click on `NearbyPoiMarkers` to call snap handler
## 9. POI Sidebar
- [x] 9.1 Add "Nearby" section in `WaypointSidebar` for the selected waypoint: call `fetchNearbyPois`, show spinner / empty state, list POIs sorted by distance (max 15, "Show more" toggle)
- [x] 9.2 Snap button per POI: move waypoint coords, set name from POI (if waypoint unnamed), prepend `"<icon> <type>"` to note — all in one Yjs transaction via `use-waypoint-manager`
- [x] 9.3 Debounce POI fetch 500ms after waypoint selection; cancel in-flight fetch via `AbortController` when selection changes
## 10. POI Rate Limiting & Error Handling
- [x] 10.1 Handle HTTP 429 from nearby-POI fetch: suppress for 60s, show subtle "POI lookup unavailable" notice in the Nearby section
- [x] 10.2 Handle network errors and 10s timeout: fail silently, show empty Nearby list — no error modals
## 11. Testing (POI)
- [x] 11.1 Overpass client unit tests (query construction, response parsing) — `overpass.test.ts` already covers this
- [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
- [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