Break up route-features into focused specs, add new changes
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>
This commit is contained in:
parent
9c7891402f
commit
0a330e4466
46 changed files with 2968 additions and 0 deletions
2
openspec/changes/waypoint-notes/.openspec.yaml
Normal file
2
openspec/changes/waypoint-notes/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
250
openspec/changes/waypoint-notes/design.md
Normal file
250
openspec/changes/waypoint-notes/design.md
Normal 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.
|
||||
76
openspec/changes/waypoint-notes/proposal.md
Normal file
76
openspec/changes/waypoint-notes/proposal.md
Normal 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)
|
||||
64
openspec/changes/waypoint-notes/tasks.md
Normal file
64
openspec/changes/waypoint-notes/tasks.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
## 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
|
||||
|
||||
## 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 `<textarea>` editing: click to edit, auto-focus, auto-resize, save on blur, cancel on Escape
|
||||
- [ ] 2.4 Add character counter (e.g., "127 / 500") visible during editing
|
||||
|
||||
## 3. Map Markers
|
||||
|
||||
- [ ] 3.1 Add note indicator icon on waypoint markers that have a non-empty note
|
||||
- [ ] 3.2 Show note text in Leaflet tooltip on hover/tap for markers with notes
|
||||
|
||||
## 4. GPX Export & Import
|
||||
|
||||
- [ ] 4.1 Update `generateGpx` in `packages/gpx/src/generate.ts` to emit `<desc>` element when waypoint has a note
|
||||
- [ ] 4.2 Update `parseGpx` in `packages/gpx/src/parse.ts` to read `<desc>` into waypoint `note` field
|
||||
|
||||
## 5. i18n
|
||||
|
||||
- [ ] 5.1 Add translation keys for note UI strings (en + de): placeholder, character counter label, tooltip "more" link
|
||||
- [ ] 5.2 Add translation keys for POI types, snap action labels, and POI status messages (en + de)
|
||||
|
||||
## 6. Testing (Notes)
|
||||
|
||||
- [ ] 6.1 Unit tests: GPX generation with notes (`<desc>` output), GPX parsing with `<desc>`, character counter logic
|
||||
- [ ] 6.2 E2E test: add a waypoint, type a note in sidebar, verify note persists after page interaction, verify GPX export contains note
|
||||
|
||||
## 7. POI Data Layer
|
||||
|
||||
- [ ] 7.1 Create Overpass API client in `apps/planner/app/lib/overpass.ts`: `fetchNearbyPOIs(lat, lon, radius)` function that builds an Overpass QL query, fetches from `https://overpass-api.de/api/interpreter`, and parses the JSON response into a typed `POI[]` array
|
||||
- [ ] 7.2 Define `POI` interface and `POI_TYPES` constant: category (water, shelter, camping, food, viewpoint, bicycle), OSM tag mapping, icon, i18n label key. Place in `apps/planner/app/lib/poi-types.ts`
|
||||
- [ ] 7.3 Build Overpass query per routing profile: filter `POI_TYPES` by the active profile (e.g., bicycle categories only for trekking/fastbike profiles) and generate the corresponding Overpass QL
|
||||
- [ ] 7.4 Implement POI cache in `apps/planner/app/lib/poi-cache.ts`: `Map<string, { data: POI[]; timestamp: number }>` keyed by quantized bounding box tile, 1-hour TTL, max 50 entries, expired-on-access eviction
|
||||
|
||||
## 8. POI Map Display
|
||||
|
||||
- [ ] 8.1 Add `selectedWaypointIndex` state to PlannerMap (set when a waypoint marker is clicked or selected in sidebar)
|
||||
- [ ] 8.2 Create `POIMarkers` component: receives `POI[]` and renders small circle markers colored by category, with tooltip showing POI name and type
|
||||
- [ ] 8.3 Add click handler on POI markers to trigger snap (calls snap handler that updates waypoint Y.Map in a single transaction)
|
||||
|
||||
## 9. POI Sidebar
|
||||
|
||||
- [ ] 9.1 Add "Nearby" section below note area in WaypointSidebar for the selected waypoint: grouped by category, sorted by distance, max 15 items with "Show more" toggle
|
||||
- [ ] 9.2 Add snap button per POI item: clicking snaps waypoint to POI coordinates, sets name, prepends note prefix (icon + type label), all in one Yjs transaction
|
||||
- [ ] 9.3 Show POI loading state (spinner) and empty state ("No nearby POIs found") with appropriate i18n strings
|
||||
|
||||
## 10. POI Rate Limiting & Error Handling
|
||||
|
||||
- [ ] 10.1 Implement debounce (500ms) on waypoint selection before triggering Overpass query; abort in-flight requests via `AbortController` when selection changes
|
||||
- [ ] 10.2 Handle HTTP 429 from Overpass: disable POI queries for 60 seconds, show subtle "POI lookup unavailable" message
|
||||
- [ ] 10.3 Handle network errors and timeouts (10s) gracefully: fail silently, show empty POI list, no error modals
|
||||
|
||||
## 11. Testing (POI)
|
||||
|
||||
- [ ] 11.1 Unit tests for Overpass client: query construction, response parsing, error handling (mock `fetch`)
|
||||
- [ ] 11.2 Unit tests for POI cache: cache hit, cache miss, TTL expiry, eviction at max entries
|
||||
- [ ] 11.3 Unit tests for snap behavior: waypoint coordinates updated, name set from POI, note prefix prepended, existing note preserved
|
||||
- [ ] 11.4 E2E test: select a waypoint, verify POI list appears in sidebar (mock Overpass response), click snap, verify waypoint moved and note updated
|
||||
Loading…
Add table
Add a link
Reference in a new issue