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:
Ullrich Schäfer 2026-03-29 09:53:41 +02:00
parent 9c7891402f
commit 0a330e4466
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
46 changed files with 2968 additions and 0 deletions

View file

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

View file

@ -0,0 +1,177 @@
## Context
Routes in the Journal store geometry as PostGIS `LineString(4326)` in the
`journal.routes.geom` column (see `packages/db/src/schema/journal.ts`). The
`@trails-cool/map` package provides `MapView` (Leaflet map with layer controls)
and `RouteLayer` (GeoJSON polyline rendering). The route-features change adds a
`visibility` column to routes, enabling public/private distinction. This change
builds on all of that to let users discover public routes by browsing a map.
The existing route-features tasks.md (section 4) sketches spatial search in five
bullet points. This change breaks it out into a standalone, fully specified
implementation plan.
## Goals / Non-Goals
**Goals:**
- Full-page map at `/routes/explore` for browsing public routes
- Efficient PostGIS bounding box queries with spatial indexing
- Clickable route polylines with preview popups
- Debounced viewport-based fetching with result caching
**Non-Goals:**
- Text search, filtering, sorting, recommendations
- Sidebar with route list (map-only for v1)
- Federated route discovery across instances
- Route clustering for dense areas
## Decisions
### D1: Explore page -- full-page Leaflet map
The explore page at `/routes/explore` renders a full-page `MapView` from
`@trails-cool/map` with no sidebar or list panel. The map fills the viewport
below the navigation bar. This is the simplest useful interface and avoids
premature layout decisions.
The page is accessible to all users (including unauthenticated visitors) since
it only shows public routes. The initial map center and zoom come from the
user's last position (stored in localStorage) or fall back to the default
center (Europe overview, `[50.1, 10.0]` zoom 6).
Route: `route("routes/explore", "routes/routes.explore.tsx")` added to
`apps/journal/app/routes.ts`.
### D2: Bounding box query API
A new API route at `GET /api/routes/explore` accepts the map viewport as query
parameters and returns public routes within the bounds:
```
GET /api/routes/explore?south=47.2&west=5.8&north=55.1&east=15.0
```
The server query:
```sql
SELECT id, name, distance, elevation_gain, owner_id,
ST_AsGeoJSON(ST_Simplify(geom, 0.001)) AS geom_json
FROM journal.routes
WHERE visibility = 'public'
AND geom IS NOT NULL
AND ST_Intersects(
geom,
ST_MakeEnvelope(:west, :south, :east, :north, 4326)
)
ORDER BY distance DESC NULLS LAST
LIMIT 50;
```
Key decisions:
- **`ST_Intersects`** over `ST_Within`: routes that cross the viewport boundary
should still appear, not just routes fully contained.
- **`ST_Simplify(geom, 0.001)`**: Simplify geometries for transfer (~100m
tolerance at European latitudes). The explore map doesn't need full-resolution
tracks -- users click through to the detail page for that.
- **Limit 50**: Prevents overwhelming the map and keeps response times fast.
Ordered by distance descending so longer (likely more interesting) routes
appear first when the limit is hit.
- **Owner join**: Include owner username and display name for the popup author
attribution.
Response format:
```json
{
"routes": [
{
"id": "abc123",
"name": "Berlin to Prague",
"distance": 343000,
"elevationGain": 1240,
"author": { "username": "ullrich", "displayName": "Ullrich" },
"geometry": { "type": "LineString", "coordinates": [...] }
}
]
}
```
### D3: Route rendering with interactive popups
Public routes are rendered as polylines on the explore map. Each route is a
clickable Leaflet polyline. Clicking opens a Leaflet popup showing:
- Route name (linked to `/routes/:id`)
- Distance (formatted: "343 km")
- Elevation gain (formatted: "+1,240 m")
- Author name (linked to `/users/:username`)
This requires a new component in `@trails-cool/map` -- an `ExploreRouteLayer`
that takes an array of route objects and renders them as interactive polylines.
Unlike the existing `RouteLayer` (which renders a single GeoJSON object), this
component manages multiple routes with distinct click handlers.
Styling:
- Default: blue polyline (`#2563eb`, weight 3, opacity 0.6)
- Hover: increase opacity to 0.9 and weight to 5
- Active (popup open): keep highlighted styling
### D4: Spatial index verification
The `journal.routes.geom` column uses `geometry(LineString, 4326)`. PostGIS
does not automatically create a spatial index. A GiST index is required for
`ST_Intersects` to perform well:
```sql
CREATE INDEX IF NOT EXISTS idx_routes_geom ON journal.routes USING GIST (geom);
```
This should be added as a Drizzle migration or verified to already exist. If
using Drizzle's `db:push`, the index needs to be added via a custom SQL
migration since Drizzle ORM does not natively support GiST index declarations
on custom types.
### D5: Debounced viewport fetching
The explore page fetches routes when the map viewport changes (Leaflet
`moveend` event). To avoid excessive API calls during panning and zooming:
- **Debounce 300ms**: Wait 300ms after the last `moveend` before fetching.
- **Abort previous**: Cancel in-flight requests when a new fetch starts
(AbortController).
- **Cache by bounds**: Store the last response keyed by rounded bounds. If the
user pans back to a previously viewed area, serve from cache. Simple
Map-based cache with a max of 20 entries (LRU eviction).
- **Loading state**: Show a subtle loading indicator (spinner in map corner)
during fetches. Don't clear existing routes while loading -- overlay new
results when they arrive.
This logic lives in a custom hook: `useExploreRoutes(map)` that returns
`{ routes, isLoading }`.
### D6: Dependency on route-sharing
This change cannot function without the `visibility` column on
`journal.routes`. The bounding box query filters on `visibility = 'public'`.
If the column doesn't exist, the query fails.
Implementation order: route-sharing schema changes (route-features section 1)
must be completed first. The explore page can be built in parallel but only
tested after visibility exists and at least one route is set to public.
## Risks / Trade-offs
- **50-result limit may frustrate users**: In dense areas (Alps, popular hiking
regions) many routes could exist. The limit means some routes are invisible.
Mitigate by ordering by distance (longer routes first) and noting this is v1.
Clustering or pagination can come later.
- **Geometry simplification may look rough**: `ST_Simplify(geom, 0.001)` is
aggressive. At the explore zoom level this is fine, but if a user zooms in
close, simplified routes look jagged. Acceptable because clicking opens the
detail page with full geometry.
- **No spatial index in Drizzle**: Drizzle doesn't support GiST indexes on
custom types declaratively. The index must be managed via raw SQL migration.
This is a minor operational concern, not a technical risk.
- **Unauthenticated access**: The explore endpoint is public. This is
intentional (discovery should not require login) but means rate limiting
should be considered to prevent abuse.

View file

@ -0,0 +1,62 @@
## Why
The Journal currently has no way to discover routes other users have published.
Each user's route list is isolated -- you can only see your own routes. Once
route-sharing adds public visibility, there needs to be a way to actually find
those public routes. Without discovery, making a route public has no effect.
Outdoor platforms live and die by discovery. A hiker planning a trip to the
Harz Mountains should be able to pan the map there and see what routes exist.
This is the most natural interface for spatial data: a map.
## What Changes
- **Explore page**: A new `/routes/explore` page in the Journal with a full-page
Leaflet map. Users pan and zoom to browse public routes in any area.
- **Bounding box API**: A server endpoint that takes the current map viewport
bounds and returns public routes whose geometry intersects the bounding box,
using PostGIS `ST_Intersects`. Results are limited to 50 and geometries are
simplified for transfer performance.
- **Route polylines**: Public routes rendered as clickable polylines on the
explore map. Clicking a route shows a popup with name, distance, elevation
gain, author, and a link to the route detail page.
- **Spatial index**: Verify (or create) a GiST index on `journal.routes.geom`
to keep bounding box queries fast.
## Capabilities
### New Capabilities
- `route-discovery`: Map-based exploration of public routes via spatial queries
### Modified Capabilities
- `journal-navigation`: Add explore link to main navigation
- `map-display`: Route polylines with interactive popups on explore map
## Non-Goals
- **Full-text search**: No searching routes by name or description. Map browsing
is the only discovery mechanism for now.
- **Filtering**: No filtering by distance, elevation, activity type, difficulty,
or tags. These are useful but add complexity -- defer until real users ask.
- **Recommendations**: No "routes you might like" or personalized suggestions.
- **Clustering**: No marker clustering for dense areas. The 50-result limit and
geometry simplification keep the map readable. Clustering can come later.
- **Federated discovery**: Routes from other Journal instances are out of scope.
This only covers routes on the local instance.
## Dependencies
- **route-sharing** (route-features change, section 1): The `visibility` column
on `journal.routes` must exist before this change can query for public routes.
Without it, there are no public routes to discover.
## Impact
- **Database**: GiST spatial index on `journal.routes.geom` (may already exist)
- **New route**: `/routes/explore` page + API endpoint
- **Navigation**: Explore link added to Journal nav
- **Packages**: Uses `@trails-cool/map` (MapView, RouteLayer) -- may need a new
component for interactive route polylines with popups
- **i18n**: New keys for explore page UI (en + de)

View file

@ -0,0 +1,43 @@
## 1. Spatial Index & Database
- [ ] 1.1 Verify whether a GiST index exists on `journal.routes.geom` -- if not, create one via raw SQL migration: `CREATE INDEX IF NOT EXISTS idx_routes_geom ON journal.routes USING GIST (geom);`
- [ ] 1.2 Verify the `visibility` column exists on `journal.routes` (dependency on route-sharing) -- if not, note this as a blocker
## 2. Bounding Box API
- [ ] 2.1 Create `apps/journal/app/routes/api.routes.explore.ts` with a GET loader that accepts `south`, `west`, `north`, `east` query params, validates bounds, and returns public routes within the bounding box using `ST_Intersects` + `ST_Simplify(geom, 0.001)`, limited to 50 results
- [ ] 2.2 Join route owner to include `username` and `displayName` in response
- [ ] 2.3 Register the API route in `apps/journal/app/routes.ts`: `route("api/routes/explore", "routes/api.routes.explore.ts")`
## 3. Explore Page
- [ ] 3.1 Create `apps/journal/app/routes/routes.explore.tsx` with a full-page `MapView` from `@trails-cool/map`, filling the viewport below the nav bar
- [ ] 3.2 Register the page route in `apps/journal/app/routes.ts`: `route("routes/explore", "routes/routes.explore.tsx")` (before the `routes/:id` route to avoid param conflict)
- [ ] 3.3 Create `useExploreRoutes` hook: listens to Leaflet `moveend`, debounces 300ms, fetches `/api/routes/explore` with current viewport bounds, returns `{ routes, isLoading }`. Use AbortController to cancel in-flight requests.
- [ ] 3.4 Add simple bounds-based cache (Map with max 20 entries) to `useExploreRoutes` to avoid re-fetching previously viewed areas
- [ ] 3.5 Store and restore last map center/zoom in localStorage so the explore page remembers the user's last viewport
## 4. Route Rendering & Interaction
- [ ] 4.1 Create `ExploreRouteLayer` component in `@trails-cool/map`: renders an array of route objects as Leaflet polylines with hover highlighting (opacity 0.6 -> 0.9, weight 3 -> 5)
- [ ] 4.2 Add click handler to each polyline that opens a Leaflet popup with route name (linked to `/routes/:id`), formatted distance, elevation gain, and author name (linked to `/users/:username`)
- [ ] 4.3 Export `ExploreRouteLayer` from `@trails-cool/map` package index
## 5. Navigation
- [ ] 5.1 Add "Explore" link to Journal navigation bar, pointing to `/routes/explore`
## 6. Performance
- [ ] 6.1 Add loading indicator (small spinner in map corner) shown during API fetches, without clearing existing routes from the map
- [ ] 6.2 Verify query performance with `EXPLAIN ANALYZE` on the bounding box query with the GiST index -- should use index scan, not sequential scan
## 7. i18n
- [ ] 7.1 Add translation keys (en + de) for: explore page title ("Explore Routes" / "Routen entdecken"), loading indicator, popup labels (distance, elevation, author), empty state ("No public routes in this area" / "Keine offentlichen Routen in diesem Bereich"), nav link
## 8. Testing
- [ ] 8.1 Unit test for bounding box API loader: mock database, verify correct SQL parameters, response format, 50-result limit, handling of missing/invalid bounds
- [ ] 8.2 Unit test for `useExploreRoutes` hook: verify debouncing, abort behavior, cache hit/miss
- [ ] 8.3 E2E test: navigate to `/routes/explore`, verify map renders, pan the map, verify routes appear as polylines (requires seeded public routes with geometry in test database)