Merge pull request #123 from trails-cool/openspec-breakout-specs
Break up route-features into focused OpenSpec changes
This commit is contained in:
commit
14ab9758c8
46 changed files with 2968 additions and 0 deletions
2
openspec/changes/activity-photos/.openspec.yaml
Normal file
2
openspec/changes/activity-photos/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
187
openspec/changes/activity-photos/design.md
Normal file
187
openspec/changes/activity-photos/design.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
## Context
|
||||
|
||||
The Journal stores activities in `journal.activities` with text fields (name,
|
||||
description, stats) and an optional GPX track. The existing `photos` jsonb
|
||||
column on activities was a placeholder -- it stores an array of strings but has
|
||||
no upload pipeline, no storage backend, and no UI.
|
||||
|
||||
The architecture plan calls for Garage (S3-compatible, self-hosted) as the
|
||||
object store. The Garage container is already defined in docker-compose.yml but
|
||||
commented out. The Journal app has no S3 client code.
|
||||
|
||||
Activity detail page (`activities.$id.tsx`) currently shows stats, description,
|
||||
and route linking -- no photo display.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Photo upload on activity pages, stored in S3-compatible storage (Garage)
|
||||
- Photo gallery display on activity detail page
|
||||
- Client-side image processing (resize, EXIF strip) before upload
|
||||
- Presigned URL upload flow (no server proxying)
|
||||
- Soft delete with S3 cleanup
|
||||
- Privacy manifest update
|
||||
|
||||
**Non-Goals:**
|
||||
- Video uploads
|
||||
- GPS-tagged photo placement on route maps
|
||||
- Photo editing, cropping, or filters
|
||||
- CDN or edge caching
|
||||
- Cross-activity photo albums
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Storage -- Garage (S3-compatible, self-hosted)
|
||||
|
||||
Enable the existing Garage container in docker-compose.yml. Single bucket
|
||||
(`activity-photos`) for all photo objects. Keys follow the pattern:
|
||||
|
||||
```
|
||||
{activityId}/{photoId}.webp
|
||||
```
|
||||
|
||||
Garage runs as an internal service -- not exposed to the public internet. The
|
||||
Journal app generates presigned URLs for both upload (PUT) and download (GET).
|
||||
This keeps the storage layer behind the application.
|
||||
|
||||
Environment variables added to the Journal service:
|
||||
- `S3_ENDPOINT` -- internal Garage URL (e.g. `http://garage:3900`)
|
||||
- `S3_ACCESS_KEY` / `S3_SECRET_KEY` -- Garage API credentials
|
||||
- `S3_BUCKET` -- bucket name (`activity-photos`)
|
||||
- `S3_PUBLIC_ENDPOINT` -- optional public URL for serving photos (falls back
|
||||
to presigned GET URLs if not set)
|
||||
|
||||
### D2: Upload flow -- presigned PUT URLs
|
||||
|
||||
The client never sends photo bytes to the Journal app server. Flow:
|
||||
|
||||
1. Client selects photo(s) and processes them (resize, EXIF strip, convert to
|
||||
WebP)
|
||||
2. Client requests a presigned PUT URL from `POST /api/activities/:id/photos`
|
||||
with metadata (filename, content type, dimensions)
|
||||
3. Server validates the user owns the activity, generates a presigned PUT URL
|
||||
(5 min expiry), creates a pending `activity_photos` row, returns the URL
|
||||
and photo ID
|
||||
4. Client uploads directly to Garage using the presigned PUT URL
|
||||
5. Client confirms upload via `PATCH /api/activities/:id/photos/:photoId` with
|
||||
`status: 'uploaded'`
|
||||
6. Server verifies the object exists in S3 and marks the photo as active
|
||||
|
||||
This avoids proxying large files through the Node.js server and keeps the app
|
||||
server lightweight. Pending photos that are never confirmed are cleaned up by
|
||||
a periodic check (or on next page load).
|
||||
|
||||
### D3: Schema -- `journal.activity_photos` table
|
||||
|
||||
```sql
|
||||
journal.activity_photos (
|
||||
id text PRIMARY KEY,
|
||||
activity_id text NOT NULL REFERENCES journal.activities(id) ON DELETE CASCADE,
|
||||
s3_key text NOT NULL,
|
||||
alt_text text,
|
||||
width integer,
|
||||
height integer,
|
||||
size_bytes integer,
|
||||
status text NOT NULL DEFAULT 'pending', -- 'pending', 'active', 'deleted'
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
The existing `photos` jsonb column on `journal.activities` is superseded by
|
||||
this table. It can be dropped in a future migration once this feature is stable.
|
||||
|
||||
Photos are ordered by `created_at` (upload order). No explicit `position`
|
||||
column initially -- reordering can be added later if users request it.
|
||||
|
||||
### D4: Image processing -- client-side resize and EXIF strip
|
||||
|
||||
All image processing happens in the browser before upload:
|
||||
|
||||
- **Resize**: Max 2048px on the long edge. Maintains aspect ratio. Uses
|
||||
canvas `drawImage` for the resize.
|
||||
- **Format**: Convert to WebP (broad browser support, good compression). Fall
|
||||
back to JPEG for browsers without WebP encode support.
|
||||
- **EXIF stripping**: Read the image via canvas `drawImage` -- this
|
||||
inherently drops EXIF data since canvas does not preserve metadata. This
|
||||
removes GPS coordinates, camera info, timestamps, and other metadata that
|
||||
users may not want to share.
|
||||
- **Size limit**: Max 5 MB per photo after processing. The presigned URL has a
|
||||
content-length condition enforcing this.
|
||||
|
||||
No server-side image processing. This keeps the server simple and avoids
|
||||
adding Sharp or similar native dependencies.
|
||||
|
||||
### D5: Gallery -- grid display with lightbox
|
||||
|
||||
Activity detail page shows photos in a responsive grid:
|
||||
|
||||
- 1 column on mobile, 2 on tablet, 3 on desktop
|
||||
- Aspect ratio preserved via `object-cover` in fixed-height cells
|
||||
- Lazy loading via `loading="lazy"` on `<img>` tags
|
||||
- Click opens a lightbox overlay with full-size image, left/right navigation,
|
||||
close on escape/click-outside
|
||||
- Alt text shown below image in lightbox (if provided)
|
||||
- Owner sees an "Add photos" button and delete icons on each photo
|
||||
|
||||
The gallery uses presigned GET URLs with 1-hour expiry, generated at page load
|
||||
time in the loader. For the lightbox, the same URLs are reused (they are valid
|
||||
long enough for a viewing session).
|
||||
|
||||
### D6: Deletion -- soft delete with background cleanup
|
||||
|
||||
Deleting a photo sets `status = 'deleted'` in the database. The photo
|
||||
immediately disappears from the UI. The actual S3 object is cleaned up:
|
||||
|
||||
- On the next page load of the activity (loader checks for deleted photos and
|
||||
removes them from S3)
|
||||
- Or by a periodic cleanup that runs on app startup and every 24 hours
|
||||
|
||||
This avoids the complexity of a separate job queue while ensuring S3 objects
|
||||
are eventually cleaned up. The cleanup is idempotent -- deleting a
|
||||
non-existent S3 key is a no-op.
|
||||
|
||||
### D7: Privacy -- manifest and EXIF documentation
|
||||
|
||||
Update the privacy page to document:
|
||||
|
||||
- **Photo storage**: Photos uploaded to activities are stored in S3-compatible
|
||||
object storage (Garage) on the same server as the Journal. Self-hosters
|
||||
control their own storage.
|
||||
- **EXIF stripping**: All photo metadata (GPS coordinates, camera info,
|
||||
timestamps) is stripped in the browser before upload. The server never sees
|
||||
original EXIF data.
|
||||
- **Deletion**: Deleted photos are removed from storage. No backups are kept
|
||||
beyond what the storage system provides.
|
||||
- **Data export**: Photos are included in data exports (future).
|
||||
|
||||
### D8: Limits -- per-activity and per-photo constraints
|
||||
|
||||
- Max **20 photos** per activity. Enforced server-side when generating
|
||||
presigned URLs. Client shows remaining count.
|
||||
- Max **5 MB** per photo (after client-side processing). Enforced via
|
||||
presigned URL content-length condition.
|
||||
- Max **2048px** long edge (client-side resize). Not enforced server-side --
|
||||
the 5 MB limit is the hard constraint.
|
||||
- Supported input formats: JPEG, PNG, WebP, HEIC (converted to WebP on
|
||||
client).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Garage operational complexity**: Adding another container increases the
|
||||
operational surface. Garage is lightweight but needs monitoring. Mitigate by
|
||||
adding a Garage health check to the existing Prometheus setup.
|
||||
- **Client-side processing reliability**: Canvas-based resize may behave
|
||||
differently across browsers, especially for HEIC on non-Safari browsers.
|
||||
Mitigate by testing on Chrome, Firefox, and Safari, and falling back to
|
||||
JPEG if WebP encoding fails.
|
||||
- **Presigned URL expiry**: If a user's upload takes longer than 5 minutes
|
||||
(slow connection, large batch), the presigned URL expires. Mitigate by
|
||||
generating URLs one at a time as each upload starts, not all upfront.
|
||||
- **No server-side validation of image content**: The server trusts that the
|
||||
client uploaded a valid image. A malicious user could upload non-image data.
|
||||
Mitigate by checking Content-Type on the S3 object during confirmation step
|
||||
and adding server-side validation later if abuse occurs.
|
||||
- **EXIF stripping depends on canvas**: The canvas approach strips EXIF
|
||||
reliably but loses all metadata including orientation. The resize step
|
||||
handles orientation via `createImageBitmap` with `imageOrientation: 'from-image'`
|
||||
before drawing to canvas.
|
||||
62
openspec/changes/activity-photos/proposal.md
Normal file
62
openspec/changes/activity-photos/proposal.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
## Why
|
||||
|
||||
Activities are text-only. Users complete a hike or bike tour and want to share
|
||||
photos from the trip alongside their route and stats, but there is no way to
|
||||
attach images. This is the most basic social feature missing from the Journal --
|
||||
without it, activities feel like spreadsheet rows rather than trip reports.
|
||||
|
||||
The infrastructure for photo storage (Garage, an S3-compatible self-hosted
|
||||
object store) is already planned and commented out in docker-compose.yml. This
|
||||
change enables it and builds the upload + display pipeline.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Infrastructure**: Enable the Garage container in docker-compose, create a
|
||||
bucket for activity photos, add S3 client utility to the Journal app.
|
||||
- **Schema**: New `journal.activity_photos` table linking photos to activities
|
||||
with metadata (S3 key, alt text, dimensions).
|
||||
- **Upload flow**: Server generates presigned PUT URLs, client uploads directly
|
||||
to Garage. Photos are resized and EXIF-stripped on the client before upload.
|
||||
- **Display**: Photo gallery grid on the activity detail page with lightbox
|
||||
view and lazy loading.
|
||||
- **Deletion**: Soft delete with background S3 cleanup.
|
||||
- **Privacy**: Photo storage documented in privacy manifest, EXIF stripping
|
||||
explained.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `activity-photos`: Photo upload, storage, gallery display, and deletion on
|
||||
Journal activities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `infrastructure`: Garage S3 container enabled, bucket provisioned
|
||||
- `activity-management`: Activity detail page gains photo gallery and upload UI
|
||||
- `privacy-manifest`: Documents photo storage, EXIF handling, S3 retention
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Video uploads**: Photos only. Video adds transcoding complexity.
|
||||
- **GPS-tagged photo mapping**: Placing photos on the route map by GPS
|
||||
coordinates is a future enhancement, not part of this change.
|
||||
- **Photo editing or filters**: Upload as-is (after resize and EXIF strip).
|
||||
- **CDN**: Serve directly from Garage via presigned GET URLs. CDN layer is a
|
||||
future optimization.
|
||||
- **Photo albums or collections**: Photos belong to a single activity. No
|
||||
cross-activity albums.
|
||||
- **Bulk import from external services**: Manual upload only.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Infrastructure**: Garage container added to docker-compose, new volume for
|
||||
object storage, Caddy config for S3 endpoint (internal only)
|
||||
- **Database**: New `journal.activity_photos` table
|
||||
- **Dependencies**: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
|
||||
for presigned URL generation; client-side image resize library
|
||||
- **Files**: Activity detail page, new upload component, new gallery component,
|
||||
storage server utility, privacy page update
|
||||
- **Environment variables**: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
|
||||
`S3_BUCKET` added to Journal service
|
||||
- **Privacy**: Photo storage and EXIF handling documented in manifest
|
||||
61
openspec/changes/activity-photos/tasks.md
Normal file
61
openspec/changes/activity-photos/tasks.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
## 1. Infrastructure
|
||||
|
||||
- [ ] 1.1 Enable Garage container in `infrastructure/docker-compose.yml`: uncomment the existing service, add `garage_data` volume, add health check, add `garage.toml` config file with single-node setup
|
||||
- [ ] 1.2 Create `activity-photos` bucket in Garage: add an init script or document the `garage bucket create` command, generate API key with read/write access to the bucket
|
||||
- [ ] 1.3 Add S3 environment variables to Journal service in docker-compose: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `S3_PUBLIC_ENDPOINT`
|
||||
|
||||
## 2. S3 Client & Server Utilities
|
||||
|
||||
- [ ] 2.1 Add `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` dependencies to the Journal app
|
||||
- [ ] 2.2 Create `apps/journal/app/lib/storage.server.ts`: S3 client singleton, `generateUploadUrl(key, contentType, maxBytes)`, `generateDownloadUrl(key, expiresIn)`, `deleteObject(key)`, `headObject(key)` functions
|
||||
- [ ] 2.3 Add env validation for S3 variables in Journal app startup (fail loudly if missing when photo feature is used)
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
- [ ] 3.1 Add `activityPhotos` table to `packages/db/src/schema/journal.ts`: id, activityId (FK to activities, cascade delete), s3Key, altText, width, height, sizeBytes, status (pending/active/deleted), createdAt
|
||||
- [ ] 3.2 Push schema with `pnpm db:push` and verify table exists in local PostgreSQL
|
||||
|
||||
## 4. Upload API
|
||||
|
||||
- [ ] 4.1 Create `apps/journal/app/routes/api.activities.$id.photos.ts`: POST handler that validates ownership, checks photo count limit (max 20), generates presigned PUT URL, creates pending `activity_photos` row, returns URL + photo ID
|
||||
- [ ] 4.2 Create `apps/journal/app/routes/api.activities.$id.photos.$photoId.ts`: PATCH handler to confirm upload (verify S3 object exists via HEAD, set status to active), DELETE handler to soft-delete (set status to deleted)
|
||||
- [ ] 4.3 Register both API routes in `apps/journal/app/routes.ts`
|
||||
|
||||
## 5. Client-Side Image Processing
|
||||
|
||||
- [ ] 5.1 Create `apps/journal/app/lib/image-processing.ts`: `resizeImage(file, maxDimension)` using canvas, outputs WebP blob (JPEG fallback), strips EXIF by virtue of canvas redraw, handles orientation via `createImageBitmap`
|
||||
- [ ] 5.2 Create `apps/journal/app/components/PhotoUploader.tsx`: file input (accept image types), processes each file through `resizeImage`, requests presigned URL from API, uploads to S3 via fetch PUT, confirms via PATCH, shows progress per photo
|
||||
- [ ] 5.3 Add size and count validation in the uploader: max 5 MB per photo after processing, max 20 photos per activity, show clear error messages
|
||||
|
||||
## 6. Photo Display
|
||||
|
||||
- [ ] 6.1 Update activity detail loader (`activities.$id.tsx`) to fetch active photos for the activity and generate presigned GET URLs (1 hour expiry)
|
||||
- [ ] 6.2 Create `apps/journal/app/components/PhotoGallery.tsx`: responsive grid (1/2/3 columns), `object-cover` thumbnails, `loading="lazy"`, click to open lightbox
|
||||
- [ ] 6.3 Create `apps/journal/app/components/PhotoLightbox.tsx`: full-screen overlay, left/right navigation, close on escape/backdrop click, alt text display
|
||||
- [ ] 6.4 Integrate PhotoGallery into activity detail page, show "Add photos" button for owner
|
||||
|
||||
## 7. Photo Management
|
||||
|
||||
- [ ] 7.1 Add delete button (trash icon) on each photo in gallery view (owner only), calls DELETE endpoint, optimistically removes from UI
|
||||
- [ ] 7.2 Add alt text editing: inline text input below each photo in edit mode, saves via PATCH endpoint
|
||||
|
||||
## 8. Cleanup
|
||||
|
||||
- [ ] 8.1 Add cleanup logic in `storage.server.ts`: function to find photos with status `deleted` or `pending` older than 1 hour, delete S3 objects, remove database rows
|
||||
- [ ] 8.2 Call cleanup on activity detail page load (non-blocking) and on app startup
|
||||
|
||||
## 9. Privacy Manifest
|
||||
|
||||
- [ ] 9.1 Update `apps/journal/app/routes/privacy.tsx`: add "Photos" section documenting S3 storage, EXIF stripping, deletion behavior, data export plans
|
||||
- [ ] 9.2 Add note about EXIF stripping in the upload UI (tooltip or help text explaining GPS/metadata removal)
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- [ ] 10.1 Add translation keys (en + de) for: "Add photos", "Delete photo", "Photo uploaded", upload errors, photo count limit, alt text placeholder, EXIF stripping explanation, privacy manifest photo section
|
||||
|
||||
## 11. Testing
|
||||
|
||||
- [ ] 11.1 Unit tests for `image-processing.ts`: verify resize output dimensions, WebP output, size within limits, EXIF data absent from output
|
||||
- [ ] 11.2 Unit tests for `storage.server.ts`: presigned URL generation, delete, head object (mock S3 client)
|
||||
- [ ] 11.3 Unit tests for photo API routes: ownership validation, count limits, status transitions (pending -> active -> deleted)
|
||||
- [ ] 11.4 E2E test: upload a photo on an activity, verify it appears in the gallery, delete it, verify it disappears
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-25
|
||||
71
openspec/changes/archive/2026-03-28-route-features/design.md
Normal file
71
openspec/changes/archive/2026-03-28-route-features/design.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
## Context
|
||||
|
||||
The architecture doc defines a rich route model with permissions, multi-day
|
||||
support, spatial queries, and photos. Currently routes are owner-only, flat
|
||||
(no days), and have no photos or spatial search. This change adds the route
|
||||
and activity features needed before federation makes them visible to others.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Route visibility (private/public) and sharing with specific users
|
||||
- Fork public routes
|
||||
- Multi-day route support with day-break waypoints
|
||||
- Map-based route discovery via PostGIS
|
||||
- Photo attachments on activities
|
||||
- Contributor tracking on route versions
|
||||
|
||||
**Non-Goals:**
|
||||
- Federation of routes (separate change)
|
||||
- Real-time collaborative permission changes
|
||||
- Photo editing or filters
|
||||
- Route recommendations based on preferences
|
||||
- Activity collections (multi-day trip grouping — future)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Visibility as enum column on routes and activities
|
||||
|
||||
Add `visibility` column (`public`, `private`, default `private`) to both
|
||||
routes and activities. `followers_only` added later when following exists.
|
||||
Public routes appear in spatial search. Private routes are owner-only.
|
||||
|
||||
### D2: Route shares table for explicit sharing
|
||||
|
||||
```
|
||||
journal.route_shares (routeId, userId, permission: 'view' | 'edit')
|
||||
```
|
||||
|
||||
Owner can share with specific users. Shared users see the route in their
|
||||
"Shared with me" section. Edit permission allows starting Planner sessions.
|
||||
|
||||
### D3: Day breaks as waypoint property
|
||||
|
||||
The Planner's Yjs waypoint Y.Map gets an optional `isDayBreak: true` field.
|
||||
The sidebar and elevation chart show day boundaries. GPX export uses one
|
||||
track segment per day. The Journal stores day-break indices in route metadata.
|
||||
|
||||
### D4: PostGIS spatial search
|
||||
|
||||
Routes already store geometry as PostGIS LineString. Add a `/routes/explore`
|
||||
page with a map — as the user pans/zooms, query public routes within the
|
||||
bounding box using `ST_Intersects`. Show route previews on the map.
|
||||
|
||||
### D5: Photo storage via S3 (Garage)
|
||||
|
||||
Use the Garage S3-compatible storage already in the architecture. Upload flow:
|
||||
server generates presigned PUT URL → client uploads directly to S3 → server
|
||||
stores the S3 key in a `journal.activity_photos` table. Serve via presigned
|
||||
GET URLs or a CDN path.
|
||||
|
||||
### D6: Contributor tracking via array column
|
||||
|
||||
Add `contributors` text array to `journal.route_versions`. When a Planner
|
||||
session saves back via callback, the JWT identifies the actor. The version
|
||||
records the contributor's user ID (or ActivityPub URI for future federation).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **S3 setup required** → Garage needs to be enabled in docker-compose (currently commented out). Adds operational complexity.
|
||||
- **Spatial search performance** → PostGIS bounding box queries are fast with spatial indexes. Already have the geometry column.
|
||||
- **Multi-day in Planner requires Yjs changes** → Adding `isDayBreak` to waypoints is backward-compatible (optional field). Existing sessions unaffected.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
## Why
|
||||
|
||||
Routes are currently flat — no permissions beyond owner, no way to share with
|
||||
specific people, no forking, no multi-day support, no spatial discovery, and
|
||||
no contributor tracking. The architecture doc specifies all of these for Phase
|
||||
2. Without them, routes are just personal GPX storage with no social or
|
||||
collaborative dimension.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Route sharing permissions**: Private/public/shared visibility levels with
|
||||
a view/edit permission matrix
|
||||
- **Route forking**: Copy someone else's public route to your own collection
|
||||
- **Multi-day routes**: Day-break waypoints that split a route into stages with
|
||||
per-day stats and GPX segments
|
||||
- **Spatial search**: "Routes near me" or "routes in this area" using PostGIS
|
||||
- **Contributor tracking**: Record who contributed to each route version
|
||||
- **Activity visibility levels**: Public/followers-only/private on activities
|
||||
- **Photo attachments**: Upload photos to activities (requires S3/Garage setup)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `route-sharing`: Permission model (private/public/shared), view/edit access, route forking
|
||||
- `spatial-search`: Map-based route discovery using PostGIS bounding box and proximity queries
|
||||
- `multi-day-routes`: Day-break markers on waypoints, per-day stats, multi-segment GPX export
|
||||
- `activity-photos`: Photo upload and display on activities via S3-compatible storage
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `route-management`: Add visibility, contributor tracking, forking
|
||||
- `activity-feed`: Add visibility levels (public/followers-only/private)
|
||||
- `planner-session`: Support day-break waypoint markers
|
||||
- `infrastructure`: S3/Garage setup for photo storage
|
||||
|
||||
## Impact
|
||||
|
||||
- **Database**: New columns (visibility, contributors) on routes and activities, new route_shares table, photo storage metadata
|
||||
- **Storage**: S3-compatible object storage (Garage) for photos
|
||||
- **Files**: Route detail page, activity pages, search page, Planner waypoint UI, sharing UI
|
||||
- **Dependencies**: S3 client library for photo uploads
|
||||
- **Privacy**: Photo storage, route visibility controls documented in manifest
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Activity visibility
|
||||
Activities SHALL have visibility levels controlling who can see them.
|
||||
|
||||
#### Scenario: Private activity
|
||||
- **WHEN** an activity's visibility is "private"
|
||||
- **THEN** only the owner can view it
|
||||
|
||||
#### Scenario: Public activity
|
||||
- **WHEN** an activity's visibility is "public"
|
||||
- **THEN** anyone can view it on the owner's profile
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Photo attachments on activities
|
||||
Users SHALL be able to upload photos to their activities.
|
||||
|
||||
#### Scenario: Upload photo
|
||||
- **WHEN** a user adds a photo to an activity
|
||||
- **THEN** the photo is uploaded to S3 storage and linked to the activity
|
||||
|
||||
#### Scenario: View photos
|
||||
- **WHEN** a user views an activity with photos
|
||||
- **THEN** the photos are displayed in a gallery on the activity page
|
||||
|
||||
#### Scenario: Delete photo
|
||||
- **WHEN** a user deletes a photo from their activity
|
||||
- **THEN** the photo is removed from storage and unlinked
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Day-break waypoints
|
||||
Waypoints in the Planner SHALL support a day-break marker that splits the route into stages.
|
||||
|
||||
#### Scenario: Mark day break
|
||||
- **WHEN** a user marks a waypoint as a day break in the Planner
|
||||
- **THEN** the route is visually split into days at that point
|
||||
|
||||
#### Scenario: Per-day statistics
|
||||
- **WHEN** a route has day-break markers
|
||||
- **THEN** the sidebar shows distance and elevation per day/stage
|
||||
|
||||
#### Scenario: GPX export with day segments
|
||||
- **WHEN** a multi-day route is exported as GPX
|
||||
- **THEN** each day is a separate track segment in the GPX file
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Route metadata
|
||||
Routes SHALL track visibility, contributors, and support forking.
|
||||
|
||||
#### Scenario: Visibility on route creation
|
||||
- **WHEN** a user creates a route
|
||||
- **THEN** the route defaults to "private" visibility
|
||||
|
||||
#### Scenario: Contributor recorded on version
|
||||
- **WHEN** a Planner session saves a new route version via callback
|
||||
- **THEN** the version records the contributor who made the edit
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Route visibility levels
|
||||
Routes SHALL have a visibility setting controlling who can see them.
|
||||
|
||||
#### Scenario: Private route
|
||||
- **WHEN** a route's visibility is "private"
|
||||
- **THEN** only the owner can view it
|
||||
|
||||
#### Scenario: Public route
|
||||
- **WHEN** a route's visibility is "public"
|
||||
- **THEN** anyone can view it and export its GPX
|
||||
|
||||
### Requirement: Share routes with specific users
|
||||
Route owners SHALL be able to share routes with specific users at view or edit permission levels.
|
||||
|
||||
#### Scenario: Share with view access
|
||||
- **WHEN** owner shares a route with another user as "view"
|
||||
- **THEN** that user can see the route and export GPX but cannot edit
|
||||
|
||||
#### Scenario: Share with edit access
|
||||
- **WHEN** owner shares a route with another user as "edit"
|
||||
- **THEN** that user can start Planner sessions and create new versions
|
||||
|
||||
### Requirement: Fork routes
|
||||
Users SHALL be able to fork (copy) public routes to their own collection.
|
||||
|
||||
#### Scenario: Fork a public route
|
||||
- **WHEN** a user clicks "Fork" on a public route
|
||||
- **THEN** a copy is created in their collection with the original credited
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Map-based route discovery
|
||||
Users SHALL be able to discover public routes by browsing a map.
|
||||
|
||||
#### Scenario: Browse routes on map
|
||||
- **WHEN** a user visits the route explore page and pans/zooms the map
|
||||
- **THEN** public routes within the visible area are shown as polylines on the map
|
||||
|
||||
#### Scenario: Route preview
|
||||
- **WHEN** a user clicks a route on the explore map
|
||||
- **THEN** a popup or sidebar shows the route name, distance, elevation, and a link to the detail page
|
||||
68
openspec/changes/archive/2026-03-28-route-features/tasks.md
Normal file
68
openspec/changes/archive/2026-03-28-route-features/tasks.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
## 1. Route Visibility & Sharing Schema
|
||||
|
||||
- [ ] 1.1 Add `visibility` enum column (private, public) to `journal.routes`, default private
|
||||
- [ ] 1.2 Add `visibility` enum column (private, public) to `journal.activities`, default private
|
||||
- [ ] 1.3 Create `journal.route_shares` table (routeId, userId, permission: view/edit)
|
||||
- [ ] 1.4 Add `contributors` text array column to `journal.route_versions`
|
||||
- [ ] 1.5 Add `forkedFromId` nullable column to `journal.routes`
|
||||
- [ ] 1.6 Push schema and verify locally
|
||||
|
||||
## 2. Route Permissions Logic
|
||||
|
||||
- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with canView(routeId, userId), canEdit(routeId, userId) functions
|
||||
- [ ] 2.2 Update route detail loader to check visibility/permissions (show 404 for unauthorized)
|
||||
- [ ] 2.3 Update route list to only show own + shared routes
|
||||
- [ ] 2.4 Add visibility toggle (private/public) to route edit page
|
||||
- [ ] 2.5 Add share dialog — search users, set view/edit permission
|
||||
|
||||
## 3. Route Forking
|
||||
|
||||
- [ ] 3.1 Add "Fork" button on public route detail pages (visible to logged-in users who aren't the owner)
|
||||
- [ ] 3.2 Create fork API route — copies route + latest GPX to user's collection, sets forkedFromId
|
||||
- [ ] 3.3 Show "Forked from [original]" link on forked routes
|
||||
|
||||
## 4. Spatial Search
|
||||
|
||||
- [ ] 4.1 Ensure PostGIS spatial index exists on routes.geometry column
|
||||
- [ ] 4.2 Create `/routes/explore` route with a full-page map
|
||||
- [ ] 4.3 Add API route to query public routes within bounding box (ST_Intersects)
|
||||
- [ ] 4.4 Render matching routes as polylines on the explore map
|
||||
- [ ] 4.5 Add route popup/sidebar with name, stats, link to detail
|
||||
|
||||
## 5. Multi-Day Routes
|
||||
|
||||
- [ ] 5.1 Add `isDayBreak` optional boolean support to Planner waypoint Y.Map
|
||||
- [ ] 5.2 Add day-break toggle in Planner waypoint sidebar (click to mark/unmark)
|
||||
- [ ] 5.3 Show day boundaries in elevation chart
|
||||
- [ ] 5.4 Compute per-day distance and elevation in sidebar
|
||||
- [ ] 5.5 Update GPX export to use one track segment per day
|
||||
- [ ] 5.6 Store dayBreaks array in route metadata on save
|
||||
|
||||
## 6. Activity Photos
|
||||
|
||||
- [ ] 6.1 Enable Garage in docker-compose.yml, create bucket
|
||||
- [ ] 6.2 Create S3 client utility (`apps/journal/app/lib/storage.server.ts`) with presigned URL generation
|
||||
- [ ] 6.3 Create `journal.activity_photos` table (id, activityId, s3Key, altText, createdAt)
|
||||
- [ ] 6.4 Add photo upload UI on activity detail/edit page
|
||||
- [ ] 6.5 Display photo gallery on activity detail page
|
||||
- [ ] 6.6 Add delete photo functionality
|
||||
|
||||
## 7. Contributor Tracking
|
||||
|
||||
- [ ] 7.1 Update Planner→Journal callback to include contributor info from JWT
|
||||
- [ ] 7.2 Store contributor in route_versions on save
|
||||
- [ ] 7.3 Display contributors on route detail page
|
||||
|
||||
## 8. Privacy & i18n
|
||||
|
||||
- [ ] 8.1 Update /privacy page: document photo storage, route visibility, sharing
|
||||
- [ ] 8.2 Add i18n keys for all new UI strings (en + de)
|
||||
|
||||
## 9. Verify
|
||||
|
||||
- [ ] 9.1 Test route visibility: create private and public routes, verify access control
|
||||
- [ ] 9.2 Test sharing: share route with another user, verify view/edit access
|
||||
- [ ] 9.3 Test forking: fork a public route, verify copy created
|
||||
- [ ] 9.4 Test spatial search: create public routes, verify they appear on explore map
|
||||
- [ ] 9.5 Test multi-day: mark day breaks, verify per-day stats and GPX export
|
||||
- [ ] 9.6 Test photos: upload, view, delete photos on activities
|
||||
2
openspec/changes/local-dev-stack/.openspec.yaml
Normal file
2
openspec/changes/local-dev-stack/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
151
openspec/changes/local-dev-stack/design.md
Normal file
151
openspec/changes/local-dev-stack/design.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
## Context
|
||||
|
||||
trails.cool runs `docker-compose.dev.yml` for local dev (PostgreSQL + BRouter)
|
||||
and `infrastructure/docker-compose.yml` for production (full stack with Caddy,
|
||||
Prometheus, Grafana, Loki, exporters). There is no staging environment. CI runs
|
||||
E2E tests with a manually started PostgreSQL container and a separately
|
||||
downloaded BRouter, not reusing the dev compose file. The gap between local dev
|
||||
and production causes issues:
|
||||
|
||||
1. Monitoring changes (alert rules, dashboards) are untestable before deploy
|
||||
2. CI's PostgreSQL setup diverges from both dev and prod (no PostGIS extensions
|
||||
preloaded, no pg_stat_statements, no init scripts)
|
||||
3. The dev PostgreSQL lacks `pg_stat_statements` which means queries that depend
|
||||
on it (like Grafana datasource queries) fail locally
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Local dev PostgreSQL matches production config (pg_stat_statements, init
|
||||
scripts)
|
||||
- Optional monitoring stack available locally via a compose profile
|
||||
- CI E2E tests use the same compose file as local dev
|
||||
- One-command reset for a clean dev environment
|
||||
- Seed data available for both local dev and CI
|
||||
|
||||
**Non-Goals:**
|
||||
- Replicating the production Caddy reverse proxy locally (apps run natively
|
||||
with Vite, no TLS needed for local dev)
|
||||
- Running S3/Garage locally (media storage is a future concern)
|
||||
- Federation testing (ActivityPub requires publicly reachable endpoints)
|
||||
- Matching exact production image versions (dev uses source builds, prod uses
|
||||
GHCR images)
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Extend docker-compose.dev.yml with profiles, don't create a new file
|
||||
|
||||
Add monitoring services to the existing `docker-compose.dev.yml` using Docker
|
||||
Compose profiles. The core services (postgres, brouter) have no profile
|
||||
assigned and always start. Monitoring services get the `monitoring` profile
|
||||
and only start when explicitly requested.
|
||||
|
||||
```bash
|
||||
pnpm dev:services # postgres + brouter (default)
|
||||
docker compose -f docker-compose.dev.yml --profile monitoring up -d # + monitoring
|
||||
```
|
||||
|
||||
**Alternative**: Separate `docker-compose.monitoring.yml` with `extends`.
|
||||
Rejected — profiles are the standard Docker Compose mechanism for this, and a
|
||||
single file is simpler to maintain.
|
||||
|
||||
### D2: Service profiles — core always runs, monitoring is opt-in
|
||||
|
||||
Two logical groups:
|
||||
|
||||
| Profile | Services | When |
|
||||
|---------|----------|------|
|
||||
| *(none)* | postgres, brouter | Always — required for app development |
|
||||
| `monitoring` | prometheus, grafana, loki | Opt-in — for testing observability changes |
|
||||
|
||||
Production-only services NOT included locally: Caddy (apps run natively),
|
||||
node-exporter (host metrics not useful in Docker Desktop), cadvisor (container
|
||||
metrics not useful locally), postgres-exporter (can add later if needed).
|
||||
|
||||
Grafana runs with anonymous auth locally (no GitHub OAuth), connecting to
|
||||
the local Prometheus and Loki instances.
|
||||
|
||||
### D3: Database initialization — auto-push schema, seed script for test data
|
||||
|
||||
On `pnpm dev:full`, the `scripts/dev.sh` script already runs `pnpm db:push`.
|
||||
Add a seed script (`scripts/seed.ts`) that inserts test data:
|
||||
|
||||
- A test user account in Journal
|
||||
- A sample route with waypoints (Berlin area, matching the BRouter segment)
|
||||
- A sample activity linked to the route
|
||||
|
||||
The seed script is idempotent (uses `ON CONFLICT DO NOTHING`). It runs
|
||||
automatically in `dev:full` but can be run standalone with `pnpm db:seed`.
|
||||
|
||||
For CI, the seed script runs after `db:push` and before E2E tests, ensuring
|
||||
tests have consistent data to work with.
|
||||
|
||||
### D4: CI uses compose file for services
|
||||
|
||||
Replace the manual `docker run` and BRouter download steps in `.github/
|
||||
workflows/ci.yml` with:
|
||||
|
||||
```yaml
|
||||
- name: Start services
|
||||
run: docker compose -f docker-compose.dev.yml up -d --wait
|
||||
```
|
||||
|
||||
The `--wait` flag blocks until health checks pass, replacing the manual
|
||||
`pg_isready` loops. BRouter still builds from the local Dockerfile in
|
||||
`docker/brouter/` and uses the same segment download mechanism.
|
||||
|
||||
Benefits:
|
||||
- CI and local dev use identical service configuration
|
||||
- Health check logic is defined once (in compose) not twice (compose + CI)
|
||||
- Simpler CI workflow with fewer steps
|
||||
|
||||
Trade-off: Docker Compose in CI adds ~5s overhead for compose parsing. The
|
||||
PostGIS image is already cached. BRouter segment download is already cached.
|
||||
Net time should be similar or faster due to parallel health checks.
|
||||
|
||||
### D5: dev.sh improvements — health checks, error messages, monitoring flag
|
||||
|
||||
Improve `scripts/dev.sh`:
|
||||
|
||||
1. **Health check with timeout**: Use `docker compose up -d --wait` instead
|
||||
of manual `pg_isready` loop. This respects the healthcheck config in the
|
||||
compose file and has a built-in timeout.
|
||||
2. **Error messages**: If Docker is not running, print a clear message instead
|
||||
of a cryptic error. Check for Docker before anything else.
|
||||
3. **Monitoring flag**: `pnpm dev:full -- --monitoring` starts the monitoring
|
||||
profile alongside core services.
|
||||
4. **Seed data**: Run seed script after schema push.
|
||||
|
||||
### D6: Environment — .env.development template with sensible defaults
|
||||
|
||||
Create `.env.development` (gitignored) from `.env.development.example`
|
||||
(committed). All values have working defaults so local dev works with zero
|
||||
configuration:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://trails:trails@localhost:5432/trails
|
||||
BROUTER_URL=http://localhost:17777
|
||||
JWT_SECRET=dev-secret-not-for-production
|
||||
SESSION_SECRET=dev-secret-not-for-production
|
||||
```
|
||||
|
||||
The apps already read `DATABASE_URL` from environment. The `.env.development`
|
||||
file is for documentation and convenience — `scripts/dev.sh` sets these
|
||||
values if not already present.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Monitoring profile adds image pulls]** → First `--profile monitoring` run
|
||||
downloads Prometheus, Grafana, Loki images (~500MB). Mitigation: one-time
|
||||
cost, cached by Docker.
|
||||
|
||||
**[Compose in CI needs Docker Compose v2]** → GitHub Actions ubuntu-latest
|
||||
includes Docker Compose v2. No action needed.
|
||||
|
||||
**[Seed data can drift from schema]** → If schema changes, seed script may
|
||||
break. Mitigation: seed script uses Drizzle ORM (not raw SQL), so TypeScript
|
||||
catches drift at compile time.
|
||||
|
||||
**[BRouter segment download in CI]** → The compose file builds BRouter from
|
||||
Dockerfile but doesn't include segments. CI still needs the segment download
|
||||
step (cached). The compose file mounts a local directory for segments.
|
||||
46
openspec/changes/local-dev-stack/proposal.md
Normal file
46
openspec/changes/local-dev-stack/proposal.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
## Why
|
||||
|
||||
There is no staging environment — production is the only deployed instance. Infra
|
||||
changes (Prometheus alerts, Caddy config, Grafana dashboards) go straight to prod
|
||||
with no way to validate them locally first. Meanwhile, CI E2E tests skip ~15 of
|
||||
20 tests because there is no PostgreSQL service in the workflow, and BRouter was
|
||||
only recently added to CI with manual setup instead of reusing the dev compose
|
||||
file. The existing `docker-compose.dev.yml` covers PostgreSQL and BRouter but
|
||||
nothing else from the production stack.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Extend `docker-compose.dev.yml` with an optional monitoring profile
|
||||
(Prometheus, Grafana, Loki) so developers can test observability changes
|
||||
locally before deploying to production
|
||||
- Add `pg_stat_statements` and initialization scripts to the dev PostgreSQL
|
||||
to match production config
|
||||
- Improve `scripts/dev.sh` with proper health checks and better error output
|
||||
- Simplify CI by using the same compose file instead of ad-hoc `docker run`
|
||||
commands
|
||||
- Add a database seed script for consistent test data
|
||||
- Add a `pnpm dev:reset` command to tear down and recreate the local stack
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `local-monitoring`: Optional local Prometheus + Grafana + Loki stack via
|
||||
`--profile monitoring`, matching production monitoring configuration
|
||||
- `dev-reset`: One command to wipe and recreate the local dev environment
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `local-dev-environment`: PostgreSQL config aligned with production
|
||||
(pg_stat_statements, init scripts), improved health checks, seed data
|
||||
|
||||
## Impact
|
||||
|
||||
- **docker-compose.dev.yml**: Add monitoring services behind a profile, update
|
||||
postgres config to match production
|
||||
- **.github/workflows/ci.yml**: Replace manual `docker run` with compose-based
|
||||
service startup
|
||||
- **scripts/dev.sh**: Better health checks, error messages, optional monitoring
|
||||
- **scripts/reset-dev.sh**: New script to wipe volumes and restart
|
||||
- **scripts/seed.ts**: Test data for local development and E2E tests
|
||||
- **Dependencies**: None new (all Docker images already used in production)
|
||||
34
openspec/changes/local-dev-stack/tasks.md
Normal file
34
openspec/changes/local-dev-stack/tasks.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
## 1. Docker Compose
|
||||
|
||||
- [ ] 1.1 Update dev PostgreSQL to match production: add `shared_preload_libraries=pg_stat_statements` command and mount `infrastructure/postgres/init-grafana-user.sql` as init script
|
||||
- [ ] 1.2 Add Prometheus service with `monitoring` profile, mounting `infrastructure/prometheus/prometheus.yml`
|
||||
- [ ] 1.3 Add Grafana service with `monitoring` profile, anonymous auth enabled, provisioned with local Prometheus and Loki datasources
|
||||
- [ ] 1.4 Add Loki service with `monitoring` profile, mounting `infrastructure/loki/loki-config.yml`
|
||||
|
||||
## 2. Database
|
||||
|
||||
- [ ] 2.1 Create `scripts/seed.ts` with idempotent test data: user account, sample route (Berlin area), sample activity. Use Drizzle ORM, `ON CONFLICT DO NOTHING`
|
||||
- [ ] 2.2 Add `pnpm db:seed` script to root package.json that runs `scripts/seed.ts` with `--experimental-strip-types`
|
||||
|
||||
## 3. CI Integration
|
||||
|
||||
- [ ] 3.1 Replace manual `docker run` PostgreSQL setup in `ci.yml` e2e job with `docker compose -f docker-compose.dev.yml up -d --wait`
|
||||
- [ ] 3.2 Replace manual BRouter download/start in `ci.yml` with the compose BRouter service plus cached segment mount
|
||||
- [ ] 3.3 Add `pnpm db:seed` step after `pnpm db:push` in CI e2e job
|
||||
- [ ] 3.4 Remove any E2E test skip workarounds that check for DB availability (the `withDb()` 503 skip pattern)
|
||||
|
||||
## 4. Scripts
|
||||
|
||||
- [ ] 4.1 Improve `scripts/dev.sh`: check Docker is running first, use `docker compose up -d --wait`, add `--monitoring` flag to start monitoring profile, run seed after schema push
|
||||
- [ ] 4.2 Create `scripts/reset-dev.sh`: stop containers, remove volumes, restart — add as `pnpm dev:reset` in package.json
|
||||
|
||||
## 5. Environment
|
||||
|
||||
- [ ] 5.1 Create `.env.development.example` with documented defaults (DATABASE_URL, BROUTER_URL, JWT_SECRET, SESSION_SECRET) and add `.env.development` to `.gitignore`
|
||||
|
||||
## 6. Verify
|
||||
|
||||
- [ ] 6.1 Test full local dev flow: `pnpm dev:full` starts services, pushes schema, seeds data, launches apps — create session, compute route, verify seeded data visible
|
||||
- [ ] 6.2 Test monitoring profile: `pnpm dev:full -- --monitoring` starts Prometheus + Grafana + Loki, verify Grafana dashboards load at localhost:3002
|
||||
- [ ] 6.3 Test CI flow: push branch, verify e2e job uses compose, all ~20 E2E tests run (none skipped)
|
||||
- [ ] 6.4 Test reset: `pnpm dev:reset` wipes volumes and restarts cleanly
|
||||
2
openspec/changes/multi-day-routes/.openspec.yaml
Normal file
2
openspec/changes/multi-day-routes/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
211
openspec/changes/multi-day-routes/design.md
Normal file
211
openspec/changes/multi-day-routes/design.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
## Context
|
||||
|
||||
The Planner stores waypoints as a Yjs `Y.Array<Y.Map<unknown>>` where each
|
||||
Y.Map holds `lat`, `lon`, and optionally `name`. Routes are computed
|
||||
segment-by-segment between consecutive waypoints via BRouter, producing an
|
||||
`EnrichedRoute` with `coordinates`, `segmentBoundaries`, `totalLength`,
|
||||
`totalAscend`, and `totalTime`. The visual-redesign change already defines the
|
||||
UI treatment for multi-day routes (sidebar day breakdown, elevation chart
|
||||
dividers, map day labels) but explicitly defers the data model and logic.
|
||||
|
||||
This design covers the data model, computation, and integration decisions.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Day-break waypoints via `overnight` flag
|
||||
|
||||
A waypoint becomes a day boundary by setting `overnight: true` on its Y.Map.
|
||||
The first waypoint of the route is the implicit start of Day 1, the last
|
||||
waypoint is the implicit end of the final day, and every waypoint with
|
||||
`overnight: true` marks the end of one day and start of the next.
|
||||
|
||||
```
|
||||
Waypoints: [Berlin, Zossen, Dessau(overnight), Halle, Erfurt]
|
||||
|------- Day 1 -------||------- Day 2 ------|
|
||||
```
|
||||
|
||||
This is the simplest possible model: a single boolean on existing data. No new
|
||||
Yjs types, no separate array, no ordering concerns. It composes naturally with
|
||||
waypoint reordering, insertion, and deletion -- if an overnight waypoint is
|
||||
removed, the two days merge automatically.
|
||||
|
||||
### D2: Day computation as a pure utility
|
||||
|
||||
A `computeDays()` function takes the waypoints array and the `EnrichedRoute`
|
||||
and returns an array of day objects:
|
||||
|
||||
```typescript
|
||||
interface DayStage {
|
||||
dayNumber: number;
|
||||
startWaypointIndex: number;
|
||||
endWaypointIndex: number;
|
||||
startName: string;
|
||||
endName: string;
|
||||
distance: number; // meters
|
||||
ascent: number; // meters
|
||||
descent: number; // meters
|
||||
estimatedTime: number; // seconds
|
||||
coordStartIndex: number;
|
||||
coordEndIndex: number;
|
||||
}
|
||||
|
||||
function computeDays(
|
||||
waypoints: Array<{ lat: number; lon: number; name?: string; overnight?: boolean }>,
|
||||
route: EnrichedRoute,
|
||||
): DayStage[];
|
||||
```
|
||||
|
||||
The function walks `segmentBoundaries` to map waypoint indices to coordinate
|
||||
ranges, then accumulates distance and elevation per day by iterating
|
||||
coordinates within each range. If no waypoints have `overnight: true`, it
|
||||
returns a single day covering the entire route.
|
||||
|
||||
This is a pure function with no Yjs dependency -- it takes plain data and
|
||||
returns plain data. This makes it easy to test and reuse.
|
||||
|
||||
### D3: Yjs state -- minimal addition
|
||||
|
||||
The only change to Yjs state is adding an `overnight` key to waypoint Y.Maps:
|
||||
|
||||
```typescript
|
||||
// Setting overnight on a waypoint
|
||||
const waypointMap = yjs.waypoints.get(index);
|
||||
waypointMap.set("overnight", true);
|
||||
|
||||
// Clearing overnight
|
||||
waypointMap.delete("overnight");
|
||||
```
|
||||
|
||||
No new Y.Array or Y.Map types are introduced. The `overnight` key is optional
|
||||
-- existing waypoints without it are treated as regular (non-overnight)
|
||||
waypoints. This is fully backwards-compatible: sessions created before this
|
||||
feature work identically, and clients that don't understand `overnight` simply
|
||||
ignore it.
|
||||
|
||||
The existing crash-recovery logic (periodic localStorage save of Y.Doc state)
|
||||
preserves overnight flags automatically since they are part of the Y.Doc.
|
||||
|
||||
### D4: Sidebar day breakdown
|
||||
|
||||
The `WaypointSidebar` component gains a day-grouped view when any waypoint has
|
||||
`overnight: true`. The layout follows visual-redesign D4:
|
||||
|
||||
```
|
||||
ACTIVE ROUTE
|
||||
Berlin -> Erfurt 343 km ^868m 2 days
|
||||
|
||||
DAY 1 - Berlin -> Dessau [v]
|
||||
^340m 120 km ~4h 30m
|
||||
1. Berlin Alexanderplatz
|
||||
2. Zossen
|
||||
3. Juterbog
|
||||
4. Dessau [OVERNIGHT]
|
||||
|
||||
> DAY 2 - Dessau -> Erfurt 223 km [>]
|
||||
|
||||
(collapsed days show summary only)
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Day 1 is expanded by default; other days are collapsed
|
||||
- Clicking a day header toggles expand/collapse
|
||||
- Per-day stats (distance, ascent, estimated time) shown in day header
|
||||
- Overnight waypoints display an amber badge
|
||||
- Waypoints within each day are numbered sequentially (1, 2, 3... restarting
|
||||
per day would be confusing -- use global numbering)
|
||||
- When no waypoints have `overnight: true`, the sidebar shows the flat list
|
||||
as it does today (no "Day 1" wrapper for single-day routes)
|
||||
|
||||
### D5: Elevation chart day dividers
|
||||
|
||||
The `ElevationChart` canvas drawing is extended to render day boundaries as
|
||||
vertical dashed lines. For each day boundary (overnight waypoint), the
|
||||
corresponding distance along the route is computed from `segmentBoundaries`
|
||||
and `coordinates`, and a dashed vertical line is drawn at that x-position.
|
||||
|
||||
```
|
||||
Day 1 Day 2 Day 3
|
||||
___/\__/\___ : __/\_____ : __/\___
|
||||
/ \ : / \ : / \
|
||||
/_______________\:/___________\:/________\
|
||||
0 km 120 km 120 km 250 km 250 km 343 km
|
||||
```
|
||||
|
||||
Each divider has a "Day N" label at the top of the chart area. The dashed line
|
||||
uses a muted color (`--text-lo` / `#9A9484`) to avoid competing with the
|
||||
elevation profile. This follows visual-redesign D6.
|
||||
|
||||
### D6: Map day labels
|
||||
|
||||
White pill-shaped labels are placed on the route at each day boundary, showing
|
||||
"Day 1 . 120 km". These use Leaflet DivIcon markers positioned at the
|
||||
coordinate of the overnight waypoint.
|
||||
|
||||
Styling follows visual-redesign D5:
|
||||
- White background with subtle shadow (`--shadow-sm`)
|
||||
- Text in `--text-hi` with distance in `--font-mono`
|
||||
- Positioned slightly offset from the route line to avoid overlap with the
|
||||
route itself
|
||||
|
||||
Day labels are only shown when there are 2+ days. They update reactively when
|
||||
overnight flags change (same Yjs observe pattern as existing markers).
|
||||
|
||||
### D7: GPX export with day-break metadata
|
||||
|
||||
Day-break waypoints are exported with a `<type>overnight</type>` element inside
|
||||
the `<wpt>` tag. This is valid GPX 1.1 (the `<type>` element is a standard
|
||||
child of `<wpt>`).
|
||||
|
||||
```xml
|
||||
<wpt lat="51.8365" lon="12.2428">
|
||||
<name>Dessau</name>
|
||||
<type>overnight</type>
|
||||
</wpt>
|
||||
```
|
||||
|
||||
Additionally, the track can optionally be split into multiple `<trk>` elements
|
||||
(one per day), each with a `<name>` like "Day 1: Berlin - Dessau". This gives
|
||||
GPS devices and other tools a natural per-day breakdown. The single-track export
|
||||
remains the default; multi-track is an option in the export dialog.
|
||||
|
||||
On GPX import (future), the parser should recognize `<type>overnight</type>`
|
||||
waypoints and restore the overnight flags. This is not in scope for this change
|
||||
but the format is designed to support it.
|
||||
|
||||
### D8: Overnight toggle UX
|
||||
|
||||
Two interaction paths to toggle a waypoint as overnight:
|
||||
|
||||
1. **Sidebar**: Each waypoint row in the sidebar gains an overnight toggle
|
||||
button (crescent moon icon). Clicking it sets/clears `overnight` on the
|
||||
waypoint's Y.Map. The button uses amber styling (`--stop`, `--stop-bg`)
|
||||
when active.
|
||||
|
||||
2. **Map context menu**: Right-clicking (long-press on mobile) a waypoint
|
||||
marker on the map shows a context menu with "Mark as overnight stop" /
|
||||
"Remove overnight stop". This reuses the same Y.Map mutation.
|
||||
|
||||
Visual feedback follows visual-redesign tokens:
|
||||
- Overnight waypoint markers on the map use amber-brown (`--stop`: `#8B6D3A`)
|
||||
instead of the default olive (`--accent`: `#4A6B40`)
|
||||
- Sidebar overnight waypoints have a subtle amber background (`--stop-bg`)
|
||||
- The "OVERNIGHT" badge uses `--stop` text on `--stop-bg` background with
|
||||
`--stop-border` border
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Segment boundary alignment**: The day computation relies on
|
||||
`segmentBoundaries` from `EnrichedRoute` to map waypoint indices to
|
||||
coordinate ranges. If the segment merge logic changes, day computation
|
||||
breaks. Mitigate with thorough unit tests on `computeDays`.
|
||||
- **Large routes**: A route with 50+ waypoints and many overnight stops could
|
||||
make the sidebar unwieldy. Collapsible sections mitigate this. We can add
|
||||
virtual scrolling later if needed.
|
||||
- **Yjs backwards compatibility**: Adding `overnight` to Y.Maps is safe, but
|
||||
older clients that don't understand it will silently ignore overnight flags.
|
||||
In a collaborative session, one user could see day breakdown while another
|
||||
does not. This is acceptable for now since all clients will be updated
|
||||
together.
|
||||
- **GPX round-trip**: The `<type>overnight</type>` convention is not a standard
|
||||
GPX extension namespace. Other tools will ignore it, which is fine. The data
|
||||
is not lost, just not interpreted.
|
||||
72
openspec/changes/multi-day-routes/proposal.md
Normal file
72
openspec/changes/multi-day-routes/proposal.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
## Why
|
||||
|
||||
Long routes -- multi-day bike tours, thru-hikes, extended backpacking trips --
|
||||
are flat lists of waypoints with no structure. Users planning a 5-day ride from
|
||||
Berlin to Prague have no way to mark where each day ends, see per-day distance
|
||||
and climbing, or reason about daily effort. They resort to external spreadsheets
|
||||
or mental arithmetic to divide the route into manageable stages.
|
||||
|
||||
The Planner already computes total distance and elevation across the full route.
|
||||
Adding day structure is a matter of marking overnight stops on existing waypoints
|
||||
and deriving per-day stats from the segment data we already have.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Day-break waypoints**: Any waypoint can be toggled as an overnight stop.
|
||||
This adds an `overnight: true` flag to the waypoint's Y.Map in the existing
|
||||
Yjs waypoints array. First and last waypoints are implicit day boundaries.
|
||||
- **Per-day stats**: Distance, total ascent, and estimated duration computed per
|
||||
day by splitting the route at overnight waypoints. Derived from the existing
|
||||
`segmentBoundaries` and `coordinates` in the enriched route data.
|
||||
- **Sidebar day breakdown**: Waypoints grouped by day with collapsible sections,
|
||||
per-day stats, and overnight toggle. Day 1 expanded by default.
|
||||
- **Elevation chart day dividers**: Dashed vertical lines at day boundaries with
|
||||
"Day N" labels.
|
||||
- **Map day labels**: White pill markers on the route at day boundaries showing
|
||||
"Day 1 . 120 km".
|
||||
- **GPX export**: Day-break waypoints exported with a `<type>overnight</type>`
|
||||
element so the structure survives round-trips.
|
||||
|
||||
All state lives in Yjs. No database changes are needed -- the Planner remains
|
||||
stateless. The visual design is already specified in the `visual-redesign`
|
||||
change (D4 sidebar, D5 map markers, D6 elevation chart); this spec covers
|
||||
the data model, computation logic, and integration wiring.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `multi-day-routes`: Overnight waypoint markers, per-day stats computation,
|
||||
day-aware sidebar/chart/map display, multi-day GPX export
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `planner-session`: Waypoints gain an `overnight` property in Yjs state
|
||||
- `map-display`: Day boundary labels on route, overnight marker styling
|
||||
- `gpx-export`: Day-break metadata in exported GPX waypoints
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Automatic day splitting**: No algorithm to suggest where to stop. Users
|
||||
decide manually. This avoids opinionated defaults and keeps the logic simple.
|
||||
- **Accommodation search**: No POI lookup for campsites or hotels. Out of scope.
|
||||
- **Per-day routing profiles**: All days use the same BRouter profile. Supporting
|
||||
different profiles per day would require rearchitecting the routing pipeline.
|
||||
- **Journal integration**: Saving multi-day routes to the Journal is a separate
|
||||
concern (route-features change). This spec is Planner-only.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Yjs state**: `overnight` boolean added to waypoint Y.Map entries (additive,
|
||||
backwards-compatible -- existing sessions without it behave as single-day)
|
||||
- **Shared types**: `Waypoint.isDayBreak` already exists in `@trails-cool/types`
|
||||
but is unused. This change activates it.
|
||||
- **New utility**: `compute-days.ts` -- pure function that splits route data at
|
||||
overnight waypoints and returns per-day stats
|
||||
- **Sidebar**: `WaypointSidebar.tsx` gains day-grouped view with collapsible
|
||||
sections and overnight toggle buttons
|
||||
- **ElevationChart**: Canvas drawing extended with vertical day dividers
|
||||
- **Map**: New day-label layer and overnight marker variant
|
||||
- **GPX**: `generateGpx` extended to emit `<type>overnight</type>` on day-break
|
||||
waypoints
|
||||
- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de)
|
||||
40
openspec/changes/multi-day-routes/tasks.md
Normal file
40
openspec/changes/multi-day-routes/tasks.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
## 1. Data Model & Computation
|
||||
|
||||
- [ ] 1.1 Add `overnight` property support to Yjs waypoint Y.Maps: helper functions `setOvernight(yjs, index, value)` and `isOvernight(yMap)` in a new `apps/planner/app/lib/overnight.ts`
|
||||
- [ ] 1.2 Create `apps/planner/app/lib/compute-days.ts` with `computeDays()` pure function: takes waypoints array + `EnrichedRoute`, returns `DayStage[]` (day number, waypoint range, coord range, distance, ascent, descent, estimated time)
|
||||
- [ ] 1.3 Create `useDays()` hook in `apps/planner/app/lib/use-days.ts`: observes Yjs waypoints + routeData, calls `computeDays()`, returns reactive `DayStage[]`
|
||||
|
||||
## 2. Sidebar Day Breakdown
|
||||
|
||||
- [ ] 2.1 Create `DayBreakdown` component: renders collapsible day sections with per-day stats (distance, ascent, estimated time), Day 1 expanded by default
|
||||
- [ ] 2.2 Add overnight toggle button (moon icon) to each waypoint row in `WaypointSidebar` — amber styling when active, calls `setOvernight()`
|
||||
- [ ] 2.3 Integrate `DayBreakdown` into `WaypointSidebar`: show day-grouped view when any waypoint has overnight, flat list otherwise
|
||||
- [ ] 2.4 Add route summary header to sidebar: total distance, ascent, number of days (e.g. "Berlin -> Erfurt 343 km ^868m 2 days")
|
||||
|
||||
## 3. Map Integration
|
||||
|
||||
- [ ] 3.1 Add overnight marker variant to `PlannerMap`: amber-brown circle (`--stop` token) for overnight waypoints, replacing default olive marker
|
||||
- [ ] 3.2 Add day label DivIcon markers on route at day boundaries: white pill with "Day N . X km" text, positioned at overnight waypoint coordinates
|
||||
- [ ] 3.3 Add right-click context menu on waypoint markers with "Mark as overnight stop" / "Remove overnight stop" option
|
||||
|
||||
## 4. Elevation Chart
|
||||
|
||||
- [ ] 4.1 Add day divider rendering to `ElevationChart`: dashed vertical lines at overnight waypoint distances with "Day N" labels at top
|
||||
- [ ] 4.2 Show per-day distance ranges on x-axis labels (e.g. "120 km" at each day boundary)
|
||||
|
||||
## 5. GPX Export
|
||||
|
||||
- [ ] 5.1 Extend `generateGpx` in `@trails-cool/gpx` to emit `<type>overnight</type>` for waypoints with `isDayBreak: true`
|
||||
- [ ] 5.2 Add multi-track export option: split track into one `<trk>` per day, each named "Day N: Start - End"
|
||||
|
||||
## 6. i18n
|
||||
|
||||
- [ ] 6.1 Add translation keys for en + de: day labels ("Day 1", "Tag 1"), overnight toggle ("Mark as overnight stop" / "Als Ubernachtung markieren"), per-day stats, route summary
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- [ ] 7.1 Unit tests for `computeDays()`: single day (no overnight), two days, three days, empty route, single waypoint, overnight on first/last waypoint edge cases
|
||||
- [ ] 7.2 Unit tests for `overnight.ts` helpers: set/clear/check overnight on Y.Map
|
||||
- [ ] 7.3 Unit tests for GPX export with overnight waypoints: verify `<type>overnight</type>` output, multi-track splitting
|
||||
- [ ] 7.4 E2E test: add waypoints, toggle overnight on one, verify sidebar shows day breakdown with correct stats
|
||||
- [ ] 7.5 E2E test: export GPX with day breaks, verify downloaded file contains overnight metadata
|
||||
2
openspec/changes/osm-overlays/.openspec.yaml
Normal file
2
openspec/changes/osm-overlays/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
186
openspec/changes/osm-overlays/design.md
Normal file
186
openspec/changes/osm-overlays/design.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
## Context
|
||||
|
||||
The Planner currently has three base tile layers (OSM, OpenTopoMap, CyclOSM) in
|
||||
`packages/map/src/layers.ts`, rendered via Leaflet's `LayersControl`. There are
|
||||
no overlay layers and no POI display. brouter-web offers hillshading, Waymarked
|
||||
Trails networks, and ~60 Overpass-powered POI categories — a model worth
|
||||
adopting selectively.
|
||||
|
||||
The Planner is stateless (Yjs CRDT), collaborative, and uses BRouter for
|
||||
routing. Overlay state should sync across participants.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Add tile-based overlays (hillshading, waymarked trails) to the layer switcher
|
||||
- Add viewport-scoped POI overlays from Overpass API with per-category toggling
|
||||
- Auto-suggest relevant overlays based on routing profile
|
||||
- Build a reusable Overpass client shared with waypoint-notes POI snap
|
||||
|
||||
**Non-Goals:**
|
||||
- Custom tile server or self-hosted overlays (use public tile services)
|
||||
- Full brouter-web layer catalog (50+ layers, most country-specific)
|
||||
- Offline/cached tile data
|
||||
- Vector tile overlays (MVT) — stick with raster for now
|
||||
- POI editing or contributing back to OSM
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Tile overlay definitions
|
||||
|
||||
Add an `overlayLayers` export to `packages/map/src/layers.ts` alongside
|
||||
existing `baseLayers`. Each overlay is a transparent tile layer rendered on top
|
||||
of the base layer.
|
||||
|
||||
Initial overlays:
|
||||
|
||||
| Id | Name | URL | Attribution |
|
||||
|----|------|-----|-------------|
|
||||
| `hillshading` | Hillshading | `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png` via [Leaflet.TileLayer.Terrarium](https://github.com/pka/leaflet-terrarium-hillshading) or pre-rendered from `https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png` | SRTM/Mapzen |
|
||||
| `waymarked-cycling` | Cycling Routes | `https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) |
|
||||
| `waymarked-hiking` | Hiking Routes | `https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) |
|
||||
| `waymarked-mtb` | MTB Routes | `https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png` | Waymarked Trails (CC-BY-SA) |
|
||||
|
||||
These are all free, public tile endpoints used by brouter-web and other OSM
|
||||
tools. No API keys needed.
|
||||
|
||||
**Alternative considered**: Thunderforest Outdoors or OpenCycleMap — requires
|
||||
API key, limited free tier. Not worth the complexity.
|
||||
|
||||
### D2: Overlay toggle in LayersControl
|
||||
|
||||
Leaflet's `LayersControl` already supports overlays natively via
|
||||
`LayersControl.Overlay` (react-leaflet). Add overlay tile layers as checkboxes
|
||||
alongside the existing base layer radio buttons. No custom UI needed for Phase 1.
|
||||
|
||||
### D3: POI category system
|
||||
|
||||
Define POI categories as a typed configuration mapping OSM tags to display
|
||||
properties. Inspired by brouter-web's `layers/overpass/` structure but
|
||||
simplified to the categories most relevant for route planning:
|
||||
|
||||
```typescript
|
||||
interface PoiCategory {
|
||||
id: string;
|
||||
name: string; // i18n key
|
||||
icon: string; // emoji or SVG icon id
|
||||
color: string; // marker color
|
||||
query: string; // Overpass QL fragment, e.g. "nwr[amenity=drinking_water]"
|
||||
profiles?: string[]; // routing profiles where this is auto-enabled
|
||||
}
|
||||
```
|
||||
|
||||
**Initial categories** (curated from brouter-web's full list):
|
||||
|
||||
| Category | POI types (OSM tags) | Icon | Auto-enable for |
|
||||
|----------|---------------------|------|-----------------|
|
||||
| Drinking water | `amenity=drinking_water`, `amenity=water_point` | 💧 | all |
|
||||
| Shelter | `amenity=shelter`, `tourism=wilderness_hut` | 🛖 | hiking |
|
||||
| Camping | `tourism=camp_site`, `tourism=caravan_site`, `tourism=picnic_site` | ⛺ | all |
|
||||
| Food & drink | `amenity=restaurant`, `amenity=cafe`, `amenity=fast_food`, `amenity=pub`, `amenity=biergarten` | 🍽️ | — |
|
||||
| Groceries | `shop=supermarket`, `shop=convenience`, `shop=bakery` | 🛒 | — |
|
||||
| Bike infrastructure | `amenity=bicycle_parking`, `amenity=bicycle_repair_station`, `amenity=bicycle_rental` | 🔧 | cycling |
|
||||
| Accommodation | `tourism=hotel`, `tourism=hostel`, `tourism=guest_house` | 🏨 | — |
|
||||
| Viewpoints | `tourism=viewpoint` | 👁️ | hiking |
|
||||
| Toilets | `amenity=toilets` | 🚻 | — |
|
||||
|
||||
**Not included** (from brouter-web but too niche): ATMs, banks, benches,
|
||||
telephones, kneipp water cures, car parking, railway stations, art galleries,
|
||||
museums, ice cream shops, BBQs. Can be added later by extending the config.
|
||||
|
||||
### D4: Overpass client
|
||||
|
||||
Create `apps/planner/app/lib/overpass.ts` with:
|
||||
|
||||
- `queryPois(bbox, categories): Promise<Poi[]>` — builds Overpass QL query
|
||||
combining all enabled categories into one request (union query), returns
|
||||
parsed GeoJSON features
|
||||
- **Bbox query**: `[bbox:south,west,north,east]` in Overpass QL, scoped to
|
||||
current Leaflet viewport
|
||||
- **Endpoint**: `https://overpass-api.de/api/interpreter` (public, no key)
|
||||
- **Response format**: Request `[out:json]` for easier parsing than XML
|
||||
- **Deduplication**: Overpass may return same node via multiple tags — dedup by
|
||||
OSM id
|
||||
|
||||
This client is also used by the waypoint-notes POI snap feature (smaller radius
|
||||
query around a single waypoint).
|
||||
|
||||
### D5: POI caching and rate limiting
|
||||
|
||||
Overpass API is public and rate-limited. Must be respectful:
|
||||
|
||||
- **Debounce**: 500ms after map `moveend` before querying
|
||||
- **Abort**: Cancel in-flight requests when viewport changes (AbortController)
|
||||
- **Tile-based caching**: Quantize viewport to grid tiles (e.g., 0.1° cells),
|
||||
cache results per tile. Reuse cached tiles that overlap new viewport.
|
||||
- **TTL**: 10 minutes for cached tiles (POI data changes slowly)
|
||||
- **Max concurrent**: 1 request at a time
|
||||
- **429 handling**: Exponential backoff, disable auto-refresh temporarily, show
|
||||
"POI data unavailable" message
|
||||
- **Zoom threshold**: Only query POIs at zoom >= 12 (avoids massive result sets
|
||||
at country-level zoom)
|
||||
|
||||
### D6: POI overlay panel
|
||||
|
||||
A collapsible panel (not the LayersControl — too many items) for toggling POI
|
||||
categories. Positioned below the layer switcher on the right side of the map.
|
||||
|
||||
- Toggle button with POI icon to open/close
|
||||
- Checkbox per category with icon and name
|
||||
- "Loading..." indicator while Overpass query is in flight
|
||||
- Category count badge showing number of visible POIs
|
||||
- Panel state (which categories are enabled) synced via Yjs so all participants
|
||||
see the same POIs
|
||||
|
||||
### D7: POI marker rendering
|
||||
|
||||
- Use Leaflet `L.Marker` with `L.DivIcon` for each POI (not CircleMarker —
|
||||
need icons)
|
||||
- Icon shows the category emoji/icon at 20×20px
|
||||
- Popup on click showing: name, category, opening hours (if available), website
|
||||
link (if available), OSM link
|
||||
- **Clustering**: Use `leaflet.markercluster` at low zoom levels to avoid
|
||||
thousands of markers. Cluster by category color.
|
||||
- **Z-index**: POI markers below route and waypoint markers
|
||||
|
||||
### D8: Profile-aware overlay defaults
|
||||
|
||||
When the routing profile changes (via Yjs `routeOptions.profile`), suggest
|
||||
relevant overlays:
|
||||
|
||||
- **Cycling profiles** (cycling-safe, cycling-fast, etc.): Auto-enable
|
||||
Waymarked Cycling overlay + Bike infrastructure POIs
|
||||
- **Hiking profiles**: Auto-enable Waymarked Hiking + Shelter + Viewpoints
|
||||
- **MTB profiles**: Auto-enable Waymarked MTB + Bike infrastructure
|
||||
|
||||
"Auto-enable" means toggling on when profile changes, not forcing — users can
|
||||
still disable. Only auto-enable on profile change, not on page load (respect
|
||||
user's previous choice stored in Yjs).
|
||||
|
||||
### D9: Yjs overlay state
|
||||
|
||||
Store enabled overlays in Yjs `routeOptions` Y.Map:
|
||||
|
||||
```
|
||||
routeOptions.overlays = ["hillshading", "waymarked-cycling"]
|
||||
routeOptions.poiCategories = ["drinking_water", "camping", "bike_infra"]
|
||||
```
|
||||
|
||||
Array of string IDs. Changes sync to all participants. Persisted in crash
|
||||
recovery localStorage snapshot.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Overpass API availability**: Public endpoint, no SLA. If down, POI overlays
|
||||
fail gracefully (show message, tile overlays still work). → Could add
|
||||
fallback endpoint (`overpass.kumi.systems`) later.
|
||||
- **Tile service availability**: Waymarked Trails and hillshading tiles are
|
||||
community-run. → Degrade gracefully if tiles 404. Consider self-hosting tiles
|
||||
if usage grows.
|
||||
- **Performance with many POIs**: Dense areas (cities) may return hundreds of
|
||||
POIs. → Marker clustering + zoom threshold mitigate this. Limit Overpass
|
||||
response to 200 elements per category.
|
||||
- **leaflet.markercluster dependency**: Adds ~40KB. → Only load when POI
|
||||
overlays are enabled (dynamic import).
|
||||
- **Overpass query cost**: Combining many categories into one query is efficient
|
||||
but returns large payloads. → Only query enabled categories, not all.
|
||||
28
openspec/changes/osm-overlays/proposal.md
Normal file
28
openspec/changes/osm-overlays/proposal.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
## Why
|
||||
|
||||
The Planner shows three base tile layers (OSM, OpenTopoMap, CyclOSM) but no overlays. Route planners like brouter-web offer hillshading, waymarked trail networks, and toggleable POI layers from OpenStreetMap — information that is essential for planning multi-day bike tours and hikes. Without overlays, users must cross-reference other tools to find water sources, campsites, bike repair stations, or see official trail routes.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Tile overlays**: Add hillshading and Waymarked Trails (cycling, hiking, MTB) as toggle-able overlay layers in the Leaflet LayersControl
|
||||
- **POI overlay panel**: Add a collapsible panel to toggle categories of OSM points of interest (water, shelter, camping, food, bike infrastructure, accommodation) queried from the Overpass API within the current viewport
|
||||
- **POI markers**: Render POI results as categorized markers with icons, name labels, and popups showing OSM tags (opening hours, website, etc.)
|
||||
- **Profile-aware defaults**: Auto-enable relevant overlays based on the active routing profile (cycling → Waymarked Cycling + bike POIs; hiking → Waymarked Hiking + water/shelter POIs)
|
||||
- **Overpass client**: Shared utility for querying the Overpass API with caching, debouncing, and rate limit handling — reused by the waypoint-notes POI snap feature
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `osm-tile-overlays`: Hillshading and Waymarked Trails tile overlays in the map layer switcher
|
||||
- `osm-poi-overlays`: Toggleable POI categories from Overpass API rendered as map markers within the viewport
|
||||
|
||||
### Modified Capabilities
|
||||
- `map-display`: Add overlay layers to the layer switcher alongside existing base layers
|
||||
- `planner-session`: Persist enabled overlay state in Yjs so all participants see the same overlays
|
||||
|
||||
## Impact
|
||||
|
||||
- **Files**: `packages/map/src/layers.ts` (overlay tile definitions), new POI overlay components in `apps/planner/`, new Overpass client in `packages/map/` or `apps/planner/app/lib/`
|
||||
- **APIs**: Overpass API (external, rate-limited — public endpoint at `overpass-api.de`)
|
||||
- **Dependencies**: None new — Leaflet handles tile overlays natively, Overpass is a REST API
|
||||
- **Performance**: Tile overlays are lightweight (transparent PNGs). POI overlays need viewport-scoped queries with caching to avoid excessive Overpass load.
|
||||
20
openspec/changes/osm-overlays/specs/map-display/spec.md
Normal file
20
openspec/changes/osm-overlays/specs/map-display/spec.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Base layer switching
|
||||
The map SHALL support switching between multiple base tile layers, and SHALL support toggling overlay tile layers independently.
|
||||
|
||||
#### Scenario: Switch to OpenTopoMap
|
||||
- **WHEN** a user selects "OpenTopoMap" from the layer switcher
|
||||
- **THEN** the map tiles change to topographic tiles from OpenTopoMap
|
||||
|
||||
#### Scenario: Available base layers
|
||||
- **WHEN** a user opens the layer switcher
|
||||
- **THEN** the options include OpenStreetMap, OpenTopoMap, and CyclOSM
|
||||
|
||||
#### Scenario: Available overlay layers
|
||||
- **WHEN** a user opens the layer switcher
|
||||
- **THEN** overlay checkboxes are shown for Hillshading, Cycling Routes, Hiking Routes, and MTB Routes
|
||||
|
||||
#### Scenario: Toggle overlay
|
||||
- **WHEN** a user checks an overlay checkbox in the layer switcher
|
||||
- **THEN** the overlay tiles are rendered on top of the current base layer
|
||||
83
openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md
Normal file
83
openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: POI overlay panel
|
||||
The Planner SHALL provide a collapsible panel for toggling POI categories on the map.
|
||||
|
||||
#### Scenario: Open POI panel
|
||||
- **WHEN** a user clicks the POI toggle button on the map
|
||||
- **THEN** a panel opens showing checkboxes for each POI category with icons and names
|
||||
|
||||
#### Scenario: Close POI panel
|
||||
- **WHEN** the POI panel is open and the user clicks the toggle button again
|
||||
- **THEN** the panel collapses and POI markers remain visible on the map
|
||||
|
||||
### Requirement: POI categories
|
||||
The Planner SHALL support the following POI categories queried from OpenStreetMap via Overpass API: drinking water, shelter, camping, food & drink, groceries, bike infrastructure, accommodation, viewpoints, and toilets.
|
||||
|
||||
#### Scenario: Enable a POI category
|
||||
- **WHEN** a user enables the "Drinking water" category in the POI panel
|
||||
- **THEN** drinking water POIs within the current map viewport are fetched from Overpass and rendered as markers
|
||||
|
||||
#### Scenario: Disable a POI category
|
||||
- **WHEN** a user disables a previously enabled POI category
|
||||
- **THEN** markers for that category are removed from the map
|
||||
|
||||
#### Scenario: Multiple categories enabled
|
||||
- **WHEN** a user enables "Camping" and "Drinking water" simultaneously
|
||||
- **THEN** both categories of markers are visible, each with distinct icons
|
||||
|
||||
### Requirement: POI markers
|
||||
Each POI SHALL be rendered as a map marker with a category-specific icon.
|
||||
|
||||
#### Scenario: POI marker display
|
||||
- **WHEN** POIs are loaded for an enabled category
|
||||
- **THEN** each POI appears as a small icon marker at its coordinates on the map
|
||||
|
||||
#### Scenario: POI marker popup
|
||||
- **WHEN** a user clicks a POI marker
|
||||
- **THEN** a popup shows the POI name, category, and available details (opening hours, website, OSM link)
|
||||
|
||||
#### Scenario: POI marker clustering
|
||||
- **WHEN** many POIs are visible in a small area
|
||||
- **THEN** markers are clustered with a count badge, and expand when the user zooms in
|
||||
|
||||
### Requirement: Viewport-scoped POI loading
|
||||
The Planner SHALL load POIs only within the current map viewport, refreshing when the viewport changes.
|
||||
|
||||
#### Scenario: Load POIs on viewport
|
||||
- **WHEN** POI categories are enabled and the user pans or zooms the map
|
||||
- **THEN** POIs are fetched for the new viewport after a 500ms debounce
|
||||
|
||||
#### Scenario: Zoom threshold
|
||||
- **WHEN** the map zoom level is below 12
|
||||
- **THEN** POI queries are not sent and a message indicates the user should zoom in to see POIs
|
||||
|
||||
#### Scenario: Cached results
|
||||
- **WHEN** the user pans back to a previously viewed area within 10 minutes
|
||||
- **THEN** cached POI results are displayed without a new Overpass query
|
||||
|
||||
### Requirement: Overpass rate limit handling
|
||||
The Planner SHALL handle Overpass API rate limits gracefully.
|
||||
|
||||
#### Scenario: Rate limited response
|
||||
- **WHEN** the Overpass API returns a 429 status
|
||||
- **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and retries with exponential backoff
|
||||
|
||||
#### Scenario: Overpass unavailable
|
||||
- **WHEN** the Overpass API is unreachable
|
||||
- **THEN** the Planner shows a message and tile overlays continue to function normally
|
||||
|
||||
### Requirement: Profile-aware POI defaults
|
||||
The Planner SHALL auto-enable relevant POI categories when the routing profile changes.
|
||||
|
||||
#### Scenario: Cycling profile POI defaults
|
||||
- **WHEN** the routing profile is changed to a cycling variant
|
||||
- **THEN** the "Bike infrastructure" POI category is automatically enabled
|
||||
|
||||
#### Scenario: Hiking profile POI defaults
|
||||
- **WHEN** the routing profile is changed to a hiking variant
|
||||
- **THEN** "Shelter" and "Viewpoints" POI categories are automatically enabled
|
||||
|
||||
#### Scenario: User override persists
|
||||
- **WHEN** a user manually disables an auto-enabled POI category
|
||||
- **THEN** it remains disabled until the next profile change
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Hillshading overlay
|
||||
The Planner map SHALL offer a hillshading tile overlay that visualizes terrain relief.
|
||||
|
||||
#### Scenario: Enable hillshading
|
||||
- **WHEN** a user toggles "Hillshading" in the layer switcher
|
||||
- **THEN** semi-transparent terrain shading tiles are rendered on top of the base layer
|
||||
|
||||
#### Scenario: Hillshading with any base layer
|
||||
- **WHEN** hillshading is enabled and the user switches base layers
|
||||
- **THEN** hillshading remains visible on top of the new base layer
|
||||
|
||||
### Requirement: Waymarked Trails cycling overlay
|
||||
The Planner map SHALL offer a Waymarked Trails cycling overlay showing official cycle route networks.
|
||||
|
||||
#### Scenario: Enable cycling routes overlay
|
||||
- **WHEN** a user toggles "Cycling Routes" in the layer switcher
|
||||
- **THEN** official cycling routes (EuroVelo, national networks) are rendered as colored lines on the map from Waymarked Trails tiles
|
||||
|
||||
#### Scenario: Cycling overlay at different zoom levels
|
||||
- **WHEN** cycling routes overlay is enabled
|
||||
- **THEN** international routes are visible at low zoom and local routes appear at higher zoom levels
|
||||
|
||||
### Requirement: Waymarked Trails hiking overlay
|
||||
The Planner map SHALL offer a Waymarked Trails hiking overlay showing official hiking trail networks.
|
||||
|
||||
#### Scenario: Enable hiking routes overlay
|
||||
- **WHEN** a user toggles "Hiking Routes" in the layer switcher
|
||||
- **THEN** official hiking trails (GR routes, national trails) are rendered as colored lines on the map
|
||||
|
||||
### Requirement: Waymarked Trails MTB overlay
|
||||
The Planner map SHALL offer a Waymarked Trails MTB overlay showing official mountain bike trail networks.
|
||||
|
||||
#### Scenario: Enable MTB routes overlay
|
||||
- **WHEN** a user toggles "MTB Routes" in the layer switcher
|
||||
- **THEN** official MTB trails are rendered as colored lines on the map
|
||||
|
||||
### Requirement: Multiple simultaneous overlays
|
||||
The Planner map SHALL support enabling multiple tile overlays at the same time.
|
||||
|
||||
#### Scenario: Hillshading plus cycling routes
|
||||
- **WHEN** a user enables both "Hillshading" and "Cycling Routes"
|
||||
- **THEN** both overlays are visible simultaneously, with cycling routes rendered above hillshading
|
||||
|
||||
### Requirement: Overlay tile attribution
|
||||
Each tile overlay SHALL display proper attribution when enabled.
|
||||
|
||||
#### Scenario: Attribution updates
|
||||
- **WHEN** an overlay is toggled on
|
||||
- **THEN** its attribution text is added to the map attribution control
|
||||
- **WHEN** the overlay is toggled off
|
||||
- **THEN** its attribution text is removed
|
||||
|
||||
### Requirement: Profile-aware overlay suggestions
|
||||
The Planner SHALL auto-enable relevant tile overlays when the routing profile changes.
|
||||
|
||||
#### Scenario: Switch to cycling profile
|
||||
- **WHEN** the routing profile is changed to a cycling variant
|
||||
- **THEN** the Waymarked Trails cycling overlay is automatically enabled
|
||||
|
||||
#### Scenario: Switch to hiking profile
|
||||
- **WHEN** the routing profile is changed to a hiking variant
|
||||
- **THEN** the Waymarked Trails hiking overlay is automatically enabled
|
||||
|
||||
#### Scenario: User can disable auto-enabled overlays
|
||||
- **WHEN** an overlay was auto-enabled by a profile change
|
||||
- **THEN** the user can manually disable it and it stays disabled until the next profile change
|
||||
24
openspec/changes/osm-overlays/specs/planner-session/spec.md
Normal file
24
openspec/changes/osm-overlays/specs/planner-session/spec.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Real-time collaborative editing
|
||||
The Planner SHALL synchronize waypoint edits, route options, and overlay preferences across all connected participants in real-time using Yjs CRDTs.
|
||||
|
||||
#### Scenario: Add waypoint
|
||||
- **WHEN** participant A adds a waypoint to the map
|
||||
- **THEN** participant B sees the waypoint appear within 500ms
|
||||
|
||||
#### Scenario: Reorder waypoints
|
||||
- **WHEN** participant A drags a waypoint to reorder it
|
||||
- **THEN** participant B sees the updated waypoint order within 500ms
|
||||
|
||||
#### Scenario: Concurrent edits
|
||||
- **WHEN** participant A and B both add waypoints simultaneously
|
||||
- **THEN** both waypoints appear for both participants without conflict
|
||||
|
||||
#### Scenario: Overlay sync
|
||||
- **WHEN** participant A enables the "Hillshading" tile overlay
|
||||
- **THEN** participant B sees hillshading appear on their map within 500ms
|
||||
|
||||
#### Scenario: POI category sync
|
||||
- **WHEN** participant A enables the "Drinking water" POI category
|
||||
- **THEN** participant B sees drinking water markers appear on their map
|
||||
64
openspec/changes/osm-overlays/tasks.md
Normal file
64
openspec/changes/osm-overlays/tasks.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
## 1. Tile Overlay Definitions
|
||||
|
||||
- [ ] 1.1 Add `overlayLayers` export to `packages/map/src/layers.ts` with hillshading, Waymarked Cycling, Waymarked Hiking, Waymarked MTB tile configs
|
||||
- [ ] 1.2 Add `LayersControl.Overlay` entries in `MapView.tsx` and `PlannerMap.tsx` for each overlay
|
||||
- [ ] 1.3 Verify overlay attribution updates correctly when toggling overlays on/off
|
||||
|
||||
## 2. Overlay State Sync
|
||||
|
||||
- [ ] 2.1 Add `overlays` string array to Yjs `routeOptions` Y.Map for enabled tile overlay IDs
|
||||
- [ ] 2.2 Add `poiCategories` string array to Yjs `routeOptions` Y.Map for enabled POI category IDs
|
||||
- [ ] 2.3 Sync LayersControl state with Yjs — toggling overlay updates Yjs, Yjs changes toggle layers
|
||||
- [ ] 2.4 Include overlay state in crash recovery localStorage snapshot
|
||||
|
||||
## 3. Overpass Client
|
||||
|
||||
- [ ] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function
|
||||
- [ ] 3.2 Build Overpass QL union queries from enabled POI category configs
|
||||
- [ ] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags)
|
||||
- [ ] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries)
|
||||
|
||||
## 4. POI Caching & Rate Limiting
|
||||
|
||||
- [ ] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL
|
||||
- [ ] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query
|
||||
- [ ] 4.3 Use AbortController to cancel in-flight requests when viewport changes
|
||||
- [ ] 4.4 Handle 429 responses with exponential backoff and user-visible message
|
||||
- [ ] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below
|
||||
|
||||
## 5. POI Category Configuration
|
||||
|
||||
- [ ] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets)
|
||||
- [ ] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles
|
||||
|
||||
## 6. POI Overlay Panel
|
||||
|
||||
- [ ] 6.1 Create collapsible POI panel component with toggle button (map right side, below layer switcher)
|
||||
- [ ] 6.2 Render checkbox per POI category with icon, name, and visible count badge
|
||||
- [ ] 6.3 Show loading indicator while Overpass query is in flight
|
||||
- [ ] 6.4 Show empty/error states (no results, Overpass unavailable, zoom too low)
|
||||
|
||||
## 7. POI Marker Rendering
|
||||
|
||||
- [ ] 7.1 Render POI markers using `L.Marker` with `L.DivIcon` showing category icon
|
||||
- [ ] 7.2 Add click popup with POI name, category, opening hours, website, and OSM link
|
||||
- [ ] 7.3 Add `leaflet.markercluster` for clustering dense POI areas (dynamic import to avoid bundle bloat)
|
||||
- [ ] 7.4 Set z-index so POI markers render below route polyline and waypoint markers
|
||||
|
||||
## 8. Profile-Aware Defaults
|
||||
|
||||
- [ ] 8.1 Define profile-to-overlay mapping (cycling → waymarked-cycling + bike POIs, hiking → waymarked-hiking + shelter + viewpoints, MTB → waymarked-mtb + bike POIs)
|
||||
- [ ] 8.2 Auto-enable mapped overlays on routing profile change (update Yjs arrays)
|
||||
- [ ] 8.3 Only auto-enable on explicit profile change, not on initial page load (respect existing Yjs state)
|
||||
|
||||
## 9. i18n
|
||||
|
||||
- [ ] 9.1 Add translation keys for all overlay names, POI category names, and UI strings (en + de)
|
||||
|
||||
## 10. Testing
|
||||
|
||||
- [ ] 10.1 Unit tests for Overpass client: query building, response parsing, deduplication
|
||||
- [ ] 10.2 Unit tests for POI cache: tile quantization, TTL expiry, cache hit/miss
|
||||
- [ ] 10.3 Unit tests for profile-to-overlay mapping
|
||||
- [ ] 10.4 E2E test: enable hillshading overlay, verify tile requests
|
||||
- [ ] 10.5 E2E test: enable POI category, verify markers appear (mock Overpass response)
|
||||
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)
|
||||
2
openspec/changes/route-sharing/.openspec.yaml
Normal file
2
openspec/changes/route-sharing/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
255
openspec/changes/route-sharing/design.md
Normal file
255
openspec/changes/route-sharing/design.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
## Context
|
||||
|
||||
The Journal stores routes in `journal.routes` with an `ownerId` foreign key to
|
||||
`journal.users`. Route versions live in `journal.route_versions` with a
|
||||
`routeId` reference. Activities live in `journal.activities` with an `ownerId`.
|
||||
Currently, all route access is implicitly owner-only: the route detail loader
|
||||
returns the route regardless of who requests it, but the route list only shows
|
||||
the current user's routes. There is no visibility column, no sharing table, and
|
||||
no permission checks beyond ownership.
|
||||
|
||||
The architecture doc defines a permission matrix with private/public/shared
|
||||
visibility, view/edit permission levels, and forking. The original
|
||||
`route-features` spec designed these alongside spatial search, multi-day routes,
|
||||
and photos. This design covers only the sharing, permissions, and forking scope.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Visibility enum -- private and public
|
||||
|
||||
Add a `visibility` column to `journal.routes` and `journal.activities` using a
|
||||
PostgreSQL enum type `journal.visibility_enum` with values `private` and
|
||||
`public`. Default is `private`.
|
||||
|
||||
```sql
|
||||
CREATE TYPE journal.visibility_enum AS ENUM ('private', 'public');
|
||||
ALTER TABLE journal.routes ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
|
||||
ALTER TABLE journal.activities ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
|
||||
```
|
||||
|
||||
In Drizzle:
|
||||
|
||||
```typescript
|
||||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
|
||||
export const visibilityEnum = journalSchema.enum("visibility_enum", ["private", "public"]);
|
||||
|
||||
// On routes table:
|
||||
visibility: visibilityEnum("visibility").notNull().default("private"),
|
||||
|
||||
// On activities table:
|
||||
visibility: visibilityEnum("visibility").notNull().default("private"),
|
||||
```
|
||||
|
||||
Only two values for now. A `followers_only` value can be appended to the enum
|
||||
later when the follow system exists. Starting minimal avoids unused code paths
|
||||
and UI states.
|
||||
|
||||
### D2: Route shares table
|
||||
|
||||
Create `journal.route_shares` to store per-user sharing permissions:
|
||||
|
||||
```typescript
|
||||
export const routeSharePermission = journalSchema.enum(
|
||||
"route_share_permission",
|
||||
["view", "edit"],
|
||||
);
|
||||
|
||||
export const routeShares = journalSchema.table("route_shares", {
|
||||
id: text("id").primaryKey(),
|
||||
routeId: text("route_id")
|
||||
.notNull()
|
||||
.references(() => routes.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: routeSharePermission("permission").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
```
|
||||
|
||||
A unique constraint on `(routeId, userId)` prevents duplicate shares. If the
|
||||
owner updates a share, the existing row is replaced (upsert). Deleting a share
|
||||
revokes access immediately.
|
||||
|
||||
The table is intentionally simple: one row per user per route. No expiry, no
|
||||
"pending" state. The owner shares, the recipient sees it. This matches the
|
||||
architecture doc's "simple permissions" decision.
|
||||
|
||||
### D3: Permission functions
|
||||
|
||||
Create `apps/journal/app/lib/permissions.server.ts` with two core functions:
|
||||
|
||||
```typescript
|
||||
async function canView(routeId: string, userId: string | null): Promise<boolean>;
|
||||
async function canEdit(routeId: string, userId: string | null): Promise<boolean>;
|
||||
```
|
||||
|
||||
Logic for `canView`:
|
||||
1. If user is the route owner: **yes**
|
||||
2. If route visibility is `public`: **yes** (even if userId is null)
|
||||
3. If user has a route_share with `view` or `edit` permission: **yes**
|
||||
4. Otherwise: **no**
|
||||
|
||||
Logic for `canEdit`:
|
||||
1. If user is the route owner: **yes**
|
||||
2. If user has a route_share with `edit` permission: **yes**
|
||||
3. Otherwise: **no**
|
||||
|
||||
Note: public visibility grants view access but never edit access. Edit access
|
||||
requires either ownership or an explicit `edit` share.
|
||||
|
||||
These functions are used in:
|
||||
- **Route detail loader**: Replace the current unconditional fetch with a
|
||||
`canView` check. Return 404 (not 403) for unauthorized access to avoid
|
||||
revealing route existence.
|
||||
- **Route list loader**: Query own routes + routes shared with the user.
|
||||
Public routes are not shown in the list (they are discoverable via the
|
||||
explore page in a future change).
|
||||
- **Route edit action**: Check `canEdit` before allowing updates.
|
||||
- **Edit-in-Planner API**: Check `canEdit` before generating a Planner session.
|
||||
- **GPX download API**: Check `canView` before serving GPX.
|
||||
|
||||
### D4: Forking
|
||||
|
||||
Forking copies a route and its latest GPX into the current user's collection.
|
||||
Only public routes can be forked (shared routes cannot -- the owner explicitly
|
||||
chose to share, not to allow copying).
|
||||
|
||||
Schema addition on `journal.routes`:
|
||||
|
||||
```typescript
|
||||
forkedFromId: text("forked_from_id").references(() => routes.id, { onDelete: "set null" }),
|
||||
```
|
||||
|
||||
`onDelete: "set null"` means if the original route is deleted, the fork remains
|
||||
but loses the provenance link. This is correct -- the fork is an independent
|
||||
copy.
|
||||
|
||||
Fork API route (`api.routes.$id.fork.ts`):
|
||||
1. Verify user is logged in
|
||||
2. Load the source route, check visibility is `public`
|
||||
3. Create a new route with:
|
||||
- `ownerId`: current user
|
||||
- `name`: same as original (user can rename later)
|
||||
- `description`: same as original
|
||||
- `gpx`: copy of current GPX
|
||||
- `geom`: copy of current geometry
|
||||
- `routingProfile`, `distance`, `elevationGain`, `elevationLoss`: copied
|
||||
- `forkedFromId`: source route ID
|
||||
- `visibility`: `private` (forks start private)
|
||||
4. Create a v1 route version with the copied GPX
|
||||
5. Redirect to the new route's detail page
|
||||
|
||||
UI: A "Fork" button appears on the route detail page when:
|
||||
- The route is public
|
||||
- The viewer is logged in
|
||||
- The viewer is not the owner
|
||||
|
||||
The route detail page shows "Forked from [Original Route Name]" with a link
|
||||
when `forkedFromId` is set and the original route still exists and is viewable.
|
||||
|
||||
### D5: Contributor tracking
|
||||
|
||||
Add a `contributors` text array column to `journal.route_versions`:
|
||||
|
||||
```typescript
|
||||
contributors: jsonb("contributors").$type<string[]>(),
|
||||
```
|
||||
|
||||
This stores user IDs (not usernames) of people who contributed to this version.
|
||||
For now this is populated from the JWT callback: the Planner session callback
|
||||
includes the JWT which identifies the route and the user who initiated the
|
||||
session. When the callback saves a new version, the contributor is the user
|
||||
associated with the JWT token.
|
||||
|
||||
In the callback handler (`api.routes.$id.callback.ts`):
|
||||
1. Decode the JWT to get the user identity (already done for auth)
|
||||
2. Pass the contributor user ID to the version creation function
|
||||
3. Store it in the `contributors` array on the new version row
|
||||
|
||||
For self-saves (owner editing via Planner), the contributor is the owner. For
|
||||
cross-instance edits (future federation), the contributor would be the remote
|
||||
user's ActivityPub URI.
|
||||
|
||||
Display on the route detail page: each version in the version history shows
|
||||
contributor names/usernames. The route header shows a combined list of all
|
||||
unique contributors across versions.
|
||||
|
||||
### D6: Share dialog
|
||||
|
||||
The share dialog is a modal accessible from the route detail page (owner only).
|
||||
It provides:
|
||||
|
||||
1. **User search**: A text input that searches users by username or display name.
|
||||
Uses an API endpoint (`api.users.search.ts`) that returns matching users
|
||||
(excluding the route owner). Search is debounced (300ms) and returns at most
|
||||
10 results.
|
||||
|
||||
2. **Permission selector**: For each user in the share list, a dropdown with
|
||||
`view` or `edit`. Defaults to `view`.
|
||||
|
||||
3. **Current shares**: Shows users the route is already shared with, their
|
||||
permission level, and a remove button.
|
||||
|
||||
4. **Actions**: "Share" button adds the selected user with the chosen permission.
|
||||
"Remove" button revokes a share.
|
||||
|
||||
The dialog uses form submissions (React Router actions) for mutations, keeping
|
||||
it server-rendered and progressive. The user search uses a client-side fetch to
|
||||
the search API for responsiveness.
|
||||
|
||||
### D7: Visibility toggle
|
||||
|
||||
A simple toggle on the route detail page (owner only) that switches between
|
||||
private and public. Implemented as a form with a hidden intent field:
|
||||
|
||||
```html
|
||||
<form method="post">
|
||||
<input type="hidden" name="intent" value="toggleVisibility" />
|
||||
<button type="submit">Make Public / Make Private</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The toggle shows the current state clearly: "This route is private" with a
|
||||
"Make public" button, or "This route is public" with a "Make private" button.
|
||||
Making a route private does not revoke existing shares -- shared users retain
|
||||
their access. This is intentional: visibility controls anonymous access, shares
|
||||
control named-user access.
|
||||
|
||||
The same pattern applies to activities: a visibility toggle on the activity
|
||||
detail page.
|
||||
|
||||
### D8: Privacy manifest update
|
||||
|
||||
The privacy page (`apps/journal/app/routes/privacy.tsx`) must document:
|
||||
|
||||
- **Route visibility**: Routes and activities have a visibility setting
|
||||
(private or public). Public routes are viewable by anyone with the link.
|
||||
- **Route sharing**: Owners can share routes with specific users. Shared users
|
||||
can see the route name, description, and GPX data according to their
|
||||
permission level.
|
||||
- **Forking**: Public routes can be forked (copied) by other users. The fork
|
||||
is an independent copy -- changes to the fork do not affect the original and
|
||||
vice versa.
|
||||
- **Contributor tracking**: When you edit someone's route via the Planner, your
|
||||
user ID is recorded as a contributor on that version.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **No followers-only visibility**: Starting with just private/public is
|
||||
simpler but means there is no middle ground. Users who want to share with
|
||||
followers but not the world must use per-user shares. This is acceptable
|
||||
until the follow system exists.
|
||||
- **Fork divergence**: Forks are fully independent copies. There is no mechanism
|
||||
to sync upstream changes or notify fork owners of updates. This is by design
|
||||
-- forks are for adaptation, not tracking.
|
||||
- **Share revocation is immediate**: Removing a share immediately revokes access.
|
||||
If a shared user has the route open in a Planner session, they can finish
|
||||
their current session (the JWT is still valid) but cannot start a new one.
|
||||
- **User search exposes usernames**: The user search API returns usernames and
|
||||
display names. This is necessary for sharing to work. Rate limiting on the
|
||||
search endpoint mitigates enumeration concerns.
|
||||
- **404 vs 403**: Returning 404 for unauthorized access prevents route existence
|
||||
leaks but makes debugging harder for shared users who lost access. This is the
|
||||
standard privacy-first approach.
|
||||
77
openspec/changes/route-sharing/proposal.md
Normal file
77
openspec/changes/route-sharing/proposal.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
## Why
|
||||
|
||||
Routes in the Journal are currently invisible to everyone except the owner. There
|
||||
is no way to make a route public, share it with a specific person, or fork
|
||||
someone else's route into your own collection. The architecture doc specifies
|
||||
visibility levels, a permission matrix, and forking for Phase 2, and the original
|
||||
`route-features` spec bundled these with spatial search, multi-day routes, and
|
||||
photos. This change breaks out the sharing and permissions scope into a focused,
|
||||
independent unit of work.
|
||||
|
||||
Without visibility and sharing, routes are just private GPX storage. Users cannot
|
||||
show a planned bikepacking route to a friend, let a co-planner view the latest
|
||||
version in the Journal, or build on someone else's published route. This is the
|
||||
minimum social layer needed before federation makes routes visible across
|
||||
instances.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Route visibility**: `private` (default) and `public` on routes and
|
||||
activities. Public routes are viewable by anyone with the link. Private routes
|
||||
are owner-only.
|
||||
- **Route sharing**: Owners can share a route with specific users at `view` or
|
||||
`edit` permission level. Shared users see the route in their collection and can
|
||||
act according to their permission.
|
||||
- **Route forking**: Logged-in users can fork any public route, copying the route
|
||||
metadata and latest GPX into their own collection with a `forkedFromId` link
|
||||
back to the original.
|
||||
- **Contributor tracking**: When the Planner saves back to the Journal via JWT
|
||||
callback, the contributor's identity is recorded on the route version. The
|
||||
route detail page shows who contributed to each version.
|
||||
|
||||
These are all Journal-side changes (database schema, server logic, and UI). No
|
||||
Planner changes are needed.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `route-sharing`: Visibility levels (private/public), per-user sharing with
|
||||
view/edit permissions, share dialog with user search
|
||||
- `route-forking`: Copy public routes into own collection with provenance link
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `route-management`: Visibility toggle on routes and activities, permission
|
||||
checks on all route access, contributor display
|
||||
- `planner-callback`: JWT callback records contributor identity on route versions
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Granular permissions beyond view/edit**: No "edit waypoints but not
|
||||
description" or "view but not export GPX". Two levels are enough.
|
||||
- **Team or organization sharing**: No group-level permissions. Share with
|
||||
individual users only.
|
||||
- **ActivityPub federation of shares**: Sharing is local to the instance for now.
|
||||
Cross-instance sharing comes with the federation change.
|
||||
- **Followers-only visibility**: Only private and public for now. A
|
||||
followers-only level can be added when the follow system exists.
|
||||
- **Spatial search / explore page**: Broken out as a separate change. Public
|
||||
routes are a prerequisite; discovery is not.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Database**: New `visibility` enum column on `journal.routes` and
|
||||
`journal.activities`. New `journal.route_shares` table. New `contributors`
|
||||
text array on `journal.route_versions`. New `forkedFromId` column on
|
||||
`journal.routes`.
|
||||
- **Server logic**: New `permissions.server.ts` module with `canView` and
|
||||
`canEdit` functions. Route loaders and list queries updated to respect
|
||||
permissions.
|
||||
- **UI**: Visibility toggle on route detail/edit page. Share dialog modal with
|
||||
user search. Fork button on public routes. "Forked from" link. Contributor
|
||||
list on route detail.
|
||||
- **Privacy**: Privacy manifest updated to document visibility and sharing
|
||||
behavior.
|
||||
- **i18n**: New translation keys for visibility, sharing, forking, contributors
|
||||
(en + de).
|
||||
45
openspec/changes/route-sharing/tasks.md
Normal file
45
openspec/changes/route-sharing/tasks.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
## 1. Schema
|
||||
|
||||
- [ ] 1.1 Add `visibility_enum` PostgreSQL enum (`private`, `public`) to `packages/db/src/schema/journal.ts` using `journalSchema.enum()`
|
||||
- [ ] 1.2 Add `visibility` column to `journal.routes` table, type `visibility_enum`, default `private`
|
||||
- [ ] 1.3 Add `visibility` column to `journal.activities` table, type `visibility_enum`, default `private`
|
||||
- [ ] 1.4 Create `journal.route_shares` table with columns: `id` (text PK), `routeId` (FK to routes, cascade delete), `userId` (FK to users, cascade delete), `permission` (enum: view/edit), `createdAt`. Add unique constraint on `(routeId, userId)`
|
||||
- [ ] 1.5 Add `forkedFromId` nullable text column to `journal.routes`, FK to `routes.id` with `onDelete: "set null"`
|
||||
- [ ] 1.6 Add `contributors` jsonb text array column to `journal.route_versions`
|
||||
- [ ] 1.7 Run `pnpm db:push` and verify schema locally
|
||||
|
||||
## 2. Permissions Logic
|
||||
|
||||
- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with `canView(routeId, userId)` and `canEdit(routeId, userId)` — owner always has full access, public routes viewable by anyone, shared routes respect permission level
|
||||
- [ ] 2.2 Update route detail loader (`routes.$id.tsx`) to check `canView` — return 404 for unauthorized. Pass `canEdit` result to the component for conditional UI (edit button, share button)
|
||||
- [ ] 2.3 Update route list loader (`routes._index.tsx`) to include routes shared with the current user alongside owned routes
|
||||
|
||||
## 3. Sharing UI
|
||||
|
||||
- [ ] 3.1 Add visibility toggle to route detail page (owner only): form with `intent=toggleVisibility`, shows current state and toggle button
|
||||
- [ ] 3.2 Create user search API route (`api.users.search.ts`): accepts `q` query param, returns matching users by username/display name (max 10, excludes requesting user), debounce-friendly
|
||||
- [ ] 3.3 Create share dialog component and integrate on route detail page (owner only): user search input, permission dropdown (view/edit), current shares list with remove button. Uses form actions for add/remove share
|
||||
|
||||
## 4. Forking
|
||||
|
||||
- [ ] 4.1 Create fork API route (`api.routes.$id.fork.ts`): verify user is logged in, source route is public, copy route + latest GPX + metadata to user's collection with `forkedFromId` set, create v1 version, redirect to new route
|
||||
- [ ] 4.2 Add "Fork" button on route detail page: visible when route is public, viewer is logged in, and viewer is not the owner
|
||||
- [ ] 4.3 Show "Forked from [route name]" link on route detail page when `forkedFromId` is set and original route is viewable
|
||||
|
||||
## 5. Contributor Tracking
|
||||
|
||||
- [ ] 5.1 Update Planner callback handler (`api.routes.$id.callback.ts`): extract contributor identity from JWT, pass to version creation
|
||||
- [ ] 5.2 Update `updateRoute` in `routes.server.ts` to accept and store `contributors` array on new route version rows
|
||||
- [ ] 5.3 Display contributors on route detail page: per-version contributor names in version history, combined unique contributors in route header
|
||||
|
||||
## 6. Privacy & i18n
|
||||
|
||||
- [ ] 6.1 Update privacy manifest page (`privacy.tsx`): document route visibility settings, sharing behavior, forking, and contributor tracking
|
||||
- [ ] 6.2 Add i18n keys (en + de) for: visibility labels (private/public), share dialog strings, fork button/label, contributor display, "forked from" text
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- [ ] 7.1 Unit tests for `permissions.server.ts`: owner access, public access, shared view/edit access, no access, null user on public route
|
||||
- [ ] 7.2 Unit tests for fork logic: successful fork of public route, reject fork of private route, forkedFromId set correctly, fork starts as private
|
||||
- [ ] 7.3 E2E test: create route, toggle visibility to public, verify accessible when logged out
|
||||
- [ ] 7.4 E2E test: share route with another user at view level, verify they can see it but not edit; share at edit level, verify edit access
|
||||
2
openspec/changes/undo-redo/.openspec.yaml
Normal file
2
openspec/changes/undo-redo/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
189
openspec/changes/undo-redo/design.md
Normal file
189
openspec/changes/undo-redo/design.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
## Context
|
||||
|
||||
The Planner uses Yjs for collaborative CRDT state with four shared types:
|
||||
`waypoints` (Y.Array), `noGoAreas` (Y.Array), `routeData` (Y.Map), and
|
||||
`notes` (Y.Text). All mutations happen through Yjs transactions. The editor
|
||||
supports multiple simultaneous users via y-websocket.
|
||||
|
||||
Yjs provides a built-in `UndoManager` class that tracks changes to specified
|
||||
shared types, scoped by transaction origin, with configurable capture
|
||||
timeouts for grouping related changes.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Undo/redo for waypoint operations (add, delete, reorder, drag-move)
|
||||
- Undo/redo for no-go area operations (add, delete)
|
||||
- Undo/redo for notes text edits
|
||||
- Keyboard shortcuts that work naturally (Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y)
|
||||
- Topbar buttons with visual disabled state
|
||||
- Collaborative-safe: only undo your own changes, never other users'
|
||||
- Drag operations grouped as a single undo step
|
||||
|
||||
**Non-Goals:**
|
||||
- Undo for routeData (derived data, not user input)
|
||||
- Undo for route options / color mode changes
|
||||
- Persistent undo history across page reloads
|
||||
- Customizable undo stack depth
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Single UndoManager tracking waypoints, noGoAreas, and notes
|
||||
|
||||
Create one `Y.UndoManager` instance in `use-yjs.ts`, tracking all three
|
||||
user-editable shared types: `waypoints`, `noGoAreas`, and `notes`.
|
||||
|
||||
```ts
|
||||
const undoManager = new Y.UndoManager(
|
||||
[waypoints, noGoAreas, notes],
|
||||
{
|
||||
captureTimeout: 500,
|
||||
trackedOrigins: new Set(["local"]),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
A single UndoManager means undo walks back through all user actions in order,
|
||||
regardless of which shared type was modified. This matches user expectations:
|
||||
"undo my last thing" not "undo my last waypoint thing."
|
||||
|
||||
The UndoManager is created inside the `useYjs` hook and exposed on the
|
||||
`YjsState` interface so all components can access it.
|
||||
|
||||
### D2: Transaction origins for local vs. remote scoping
|
||||
|
||||
All local mutations must use the origin `"local"` so UndoManager can
|
||||
distinguish them from remote changes arriving via y-websocket:
|
||||
|
||||
```ts
|
||||
doc.transact(() => {
|
||||
waypoints.push([yMap]);
|
||||
}, "local");
|
||||
```
|
||||
|
||||
Current code has some `doc.transact()` calls without an origin and some
|
||||
direct mutations (e.g., `yjs.waypoints.push([yMap])`) outside a transaction.
|
||||
All mutation sites need to be wrapped in `doc.transact(() => { ... }, "local")`.
|
||||
|
||||
Mutation sites to update:
|
||||
- `PlannerMap.tsx`: `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`,
|
||||
`deleteWaypoint`
|
||||
- `WaypointSidebar.tsx`: `deleteWaypoint`, `moveWaypoint`
|
||||
- `NoGoAreaLayer.tsx`: no-go area creation (`pm:create` handler), no-go area
|
||||
deletion (contextmenu handler)
|
||||
- `NotesPanel.tsx`: `handleInput` (already uses `doc.transact`, needs origin)
|
||||
- `use-yjs.ts`: initial waypoint seeding (use `"init"` origin, not `"local"`,
|
||||
so it's not undoable)
|
||||
|
||||
### D3: Keyboard shortcuts
|
||||
|
||||
Register a global `keydown` listener (in a `useUndoShortcuts` hook or
|
||||
similar) that handles:
|
||||
- **Ctrl+Z** (or Cmd+Z on macOS): `undoManager.undo()`
|
||||
- **Ctrl+Shift+Z** or **Ctrl+Y** (Cmd+Shift+Z on macOS): `undoManager.redo()`
|
||||
|
||||
The listener must suppress when the active element is an `<input>`,
|
||||
`<textarea>`, or `[contenteditable]` -- these elements have their own
|
||||
browser-native undo/redo behavior. The notes textarea is the main case.
|
||||
|
||||
However, since notes Y.Text is tracked by our UndoManager, we may want to
|
||||
**not** suppress in the notes textarea and instead use the Yjs undo there
|
||||
too. Decision: suppress for now and let the browser handle textarea undo
|
||||
natively. This avoids complexity around cursor position restoration.
|
||||
|
||||
### D4: Topbar undo/redo buttons
|
||||
|
||||
Add two icon buttons to the header in `SessionView.tsx`, positioned in the
|
||||
left group next to the participant list:
|
||||
|
||||
- Undo button: left-curved arrow icon, `title` with shortcut hint
|
||||
- Redo button: right-curved arrow icon, `title` with shortcut hint
|
||||
- Both disabled (grayed out) when their respective stack is empty
|
||||
|
||||
Use simple inline SVG or Unicode arrows (e.g., `\u21B6` / `\u21B7` or
|
||||
custom SVG) to avoid adding an icon library dependency.
|
||||
|
||||
### D5: Undo granularity and drag grouping
|
||||
|
||||
The `captureTimeout` of 500ms means rapid successive changes (within 500ms)
|
||||
are grouped into one undo step. This works well for:
|
||||
- Quick waypoint additions (each click is >500ms apart, so separate steps)
|
||||
- Text typing in notes (keystrokes within 500ms grouped together)
|
||||
|
||||
For **drag operations** (waypoint drag on map), the marker `dragstart` fires
|
||||
once, then multiple `drag` events update position, then `dragend` fires. The
|
||||
intermediate positions should not be separate undo steps.
|
||||
|
||||
Strategy: Call `undoManager.stopCapturing()` at `dragstart`. This forces the
|
||||
next change to start a new capture group. Then all position updates during
|
||||
the drag happen within the capture timeout window and merge into one step.
|
||||
After `dragend`, no special action needed -- the next user action will
|
||||
naturally start a new group after 500ms.
|
||||
|
||||
Note: Currently `moveWaypoint` in `PlannerMap.tsx` is only called on
|
||||
`dragend`, not during drag, so intermediate positions are not stored in Yjs.
|
||||
This means drag is already a single undo step. The `stopCapturing()` call is
|
||||
still useful as a safeguard in case drag behavior changes later, and to
|
||||
separate the drag from any preceding action within the capture window.
|
||||
|
||||
### D6: Stack state reactivity
|
||||
|
||||
`UndoManager` fires `stack-item-added`, `stack-item-popped`,
|
||||
`stack-item-updated` events. Use these to maintain reactive `canUndo` /
|
||||
`canRedo` booleans in a `useUndo` hook:
|
||||
|
||||
```ts
|
||||
export function useUndo(undoManager: Y.UndoManager | null) {
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!undoManager) return;
|
||||
const update = () => {
|
||||
setCanUndo(undoManager.undoStack.length > 0);
|
||||
setCanRedo(undoManager.redoStack.length > 0);
|
||||
};
|
||||
undoManager.on("stack-item-added", update);
|
||||
undoManager.on("stack-item-popped", update);
|
||||
undoManager.on("stack-item-updated", update);
|
||||
update();
|
||||
return () => {
|
||||
undoManager.off("stack-item-added", update);
|
||||
undoManager.off("stack-item-popped", update);
|
||||
undoManager.off("stack-item-updated", update);
|
||||
};
|
||||
}, [undoManager]);
|
||||
|
||||
return { canUndo, canRedo };
|
||||
}
|
||||
```
|
||||
|
||||
The buttons read `canUndo` / `canRedo` and set `disabled` accordingly.
|
||||
|
||||
### D7: Notes undo interaction
|
||||
|
||||
`notes` (Y.Text) is included in the UndoManager's tracked types. However,
|
||||
keyboard shortcuts are suppressed when the textarea is focused (D3), so:
|
||||
|
||||
- **In the textarea**: Browser-native undo (Ctrl+Z) handles text editing.
|
||||
This is familiar and handles cursor position correctly.
|
||||
- **Outside the textarea**: Yjs undo can revert notes changes as part of the
|
||||
global undo stack. This means if you edit notes, click on the map, then
|
||||
press Ctrl+Z, the notes edit will be undone via Yjs.
|
||||
|
||||
This is a pragmatic split. If it causes confusion (users expecting Ctrl+Z in
|
||||
textarea to use the Yjs stack), we can revisit.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Undo after remote changes can be surprising** -- If another user adds a
|
||||
waypoint between your two actions, undoing your second action still works
|
||||
correctly (Yjs handles this), but the route may look unexpected because the
|
||||
remote waypoint remains. This is inherent to collaborative undo and
|
||||
acceptable.
|
||||
- **No cursor restoration for notes** -- When undoing a notes change via the
|
||||
Yjs UndoManager (from outside the textarea), the textarea content updates
|
||||
but the cursor position is not restored. This is a minor UX gap.
|
||||
- **captureTimeout grouping edge cases** -- Two rapid distinct actions
|
||||
(e.g., delete waypoint then immediately add no-go area) within 500ms will
|
||||
be grouped as one undo step. Unlikely in practice and acceptable.
|
||||
61
openspec/changes/undo-redo/proposal.md
Normal file
61
openspec/changes/undo-redo/proposal.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
## Why
|
||||
|
||||
Route editing mistakes in the Planner have no recovery path. Accidentally
|
||||
deleting a waypoint, dragging to the wrong spot, or removing a no-go area
|
||||
requires manually recreating the change. This is especially frustrating during
|
||||
complex multi-waypoint edits where a single misclick can undo minutes of work.
|
||||
|
||||
Every desktop editing tool supports Ctrl+Z. Users expect it. Its absence is
|
||||
the most obvious missing affordance in the Planner.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add undo/redo support to the Planner's route editor using Yjs's built-in
|
||||
`UndoManager`
|
||||
- Keyboard shortcuts: Ctrl+Z (undo), Ctrl+Shift+Z / Ctrl+Y (redo)
|
||||
- Undo/redo buttons in the topbar with disabled state when the stack is empty
|
||||
- Only the current user's changes are undone -- collaborative-safe by design.
|
||||
Other participants' edits are never affected by your undo.
|
||||
|
||||
## Scope
|
||||
|
||||
Tracked types:
|
||||
- `waypoints` (Y.Array) -- add, remove, reorder, drag-move
|
||||
- `noGoAreas` (Y.Array) -- add, remove
|
||||
- `notes` (Y.Text) -- text edits
|
||||
|
||||
`routeData` (Y.Map) is **not** tracked. It contains derived data (route
|
||||
geometry, segment boundaries) computed by BRouter, not direct user input.
|
||||
Undoing a waypoint change will trigger a re-route automatically.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Infinite undo history -- UndoManager keeps a reasonable in-memory stack,
|
||||
no persistence needed
|
||||
- Undo across sessions -- the undo stack is in-memory and resets when
|
||||
the page is closed or the WebSocket reconnects
|
||||
- Undo for route options (profile selection, color mode) -- these are
|
||||
infrequent, low-risk changes that don't warrant undo tracking
|
||||
- Granular undo for individual keystrokes in notes -- Y.Text undo groups
|
||||
by capture timeout, which is good enough
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `undo-redo`: Undo/redo for route editing operations in the Planner via
|
||||
keyboard shortcuts and topbar buttons, scoped to the local user's changes
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
(None)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Files**: New hook (`use-undo.ts`), modified `use-yjs.ts` (transaction
|
||||
origins), modified `SessionView.tsx` (buttons + keyboard handler), modified
|
||||
mutation sites in `PlannerMap.tsx`, `WaypointSidebar.tsx`,
|
||||
`NoGoAreaLayer.tsx`, `NotesPanel.tsx`
|
||||
- **Dependencies**: None -- Yjs `UndoManager` is built into the `yjs` package
|
||||
already in use
|
||||
- **i18n**: Tooltip strings for undo/redo buttons
|
||||
34
openspec/changes/undo-redo/tasks.md
Normal file
34
openspec/changes/undo-redo/tasks.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
## 1. Core: UndoManager Setup
|
||||
|
||||
- [ ] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
|
||||
- [ ] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
|
||||
- [ ] 1.3 Add `"local"` origin to all mutation sites:
|
||||
- `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")`
|
||||
- `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` in `doc.transact(() => { ... }, "local")`
|
||||
- `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions
|
||||
- `NotesPanel.tsx`: add `"local"` origin to the existing `doc.transact()` call
|
||||
- `use-yjs.ts`: use `"init"` (not `"local"`) as origin for initial waypoint seeding so it is not undoable
|
||||
|
||||
## 2. Keyboard Shortcuts
|
||||
|
||||
- [ ] 2.1 Create a `useUndoShortcuts` hook (in `use-undo.ts` or separate file) that registers a global `keydown` listener for Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (redo). Calls `undoManager.undo()` / `undoManager.redo()` and calls `e.preventDefault()`.
|
||||
- [ ] 2.2 Suppress the shortcut when the active element is `<input>`, `<textarea>`, or `[contenteditable]` so browser-native undo works in text fields (especially the notes textarea).
|
||||
|
||||
## 3. UI: Topbar Buttons
|
||||
|
||||
- [ ] 3.1 Add undo and redo icon buttons to the `SessionView.tsx` header, in the left group near the participant list. Use inline SVG arrows. Wire to `undo()` / `redo()` from the `useUndo` hook.
|
||||
- [ ] 3.2 Disable buttons when `canUndo` / `canRedo` is false (gray out, set `disabled` attribute). Add `title` tooltips showing the keyboard shortcut (platform-aware: Cmd on macOS, Ctrl elsewhere).
|
||||
|
||||
## 4. Drag Operation Grouping
|
||||
|
||||
- [ ] 4.1 In `PlannerMap.tsx`, call `undoManager.stopCapturing()` before the drag transaction to ensure the drag is isolated as its own undo step (separated from any preceding action within the capture timeout window). Access `undoManager` from the `yjs` prop.
|
||||
|
||||
## 5. i18n
|
||||
|
||||
- [ ] 5.1 Add translation keys for undo/redo button tooltips (`undo.tooltip`, `redo.tooltip`) in both English and German translation files for the planner namespace.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [ ] 6.1 Unit tests for `use-undo.ts`: verify `canUndo`/`canRedo` reactivity, verify `undo()` and `redo()` call through to UndoManager, verify state updates on stack events. Use a real `Y.Doc` + `Y.UndoManager` (not mocks).
|
||||
- [ ] 6.2 Unit test for transaction origins: create a Y.Doc with UndoManager, perform mutations with `"local"` origin, verify they appear on the undo stack. Perform mutations with no origin or `"init"` origin, verify they do not appear on the undo stack.
|
||||
- [ ] 6.3 E2E test: add two waypoints, press Ctrl+Z, verify last waypoint is removed. Press Ctrl+Shift+Z, verify it reappears. Test undo/redo buttons in the topbar (click, verify disabled state).
|
||||
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