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/route-discovery/.openspec.yaml
Normal file
2
openspec/changes/route-discovery/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
177
openspec/changes/route-discovery/design.md
Normal file
177
openspec/changes/route-discovery/design.md
Normal 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.
|
||||
62
openspec/changes/route-discovery/proposal.md
Normal file
62
openspec/changes/route-discovery/proposal.md
Normal 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)
|
||||
43
openspec/changes/route-discovery/tasks.md
Normal file
43
openspec/changes/route-discovery/tasks.md
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue