Merge branch 'main' into openspec-account-settings

This commit is contained in:
Ullrich Schäfer 2026-03-29 10:14:54 +02:00 committed by GitHub
commit 25b6e36fb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2968 additions and 0 deletions

View file

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

View file

@ -0,0 +1,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.

View 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

View 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

View file

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

View 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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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

View file

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

View 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.

View 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)

View 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

View file

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

View 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.

View 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)

View 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

View file

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

View 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.

View 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.

View 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

View 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

View file

@ -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

View 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

View 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View 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.

View 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).

View 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

View file

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

View 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.

View 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

View 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).

View file

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

View file

@ -0,0 +1,250 @@
## Context
The Planner stores waypoints as a `Y.Array<Y.Map<unknown>>` with `lat`, `lon`,
and optional `name` keys. The visual-redesign mockup (D4, D5) already shows
waypoint notes as italic text under the waypoint name in the sidebar, and a note
icon on map markers. This change implements the data model and interaction logic
behind those placeholders.
## Decisions
### D1: Note storage in Yjs
Each waypoint is a `Y.Map` in the `waypoints` Y.Array. Add an optional `note`
key of type `string`. Plain text only, no markup.
```typescript
// Existing keys
yMap.get("lat") // number
yMap.get("lon") // number
yMap.get("name") // string | undefined
// New key
yMap.get("note") // string | undefined
```
Soft limit of ~500 characters enforced in the UI (character counter, input
truncation) but not in the Yjs document itself. This keeps the data layer simple
and avoids breaking collaborative edits if two users type near the limit
simultaneously.
The `Waypoint` interface in `@trails-cool/types` gets a corresponding optional
field:
```typescript
export interface Waypoint {
lat: number;
lon: number;
name?: string;
note?: string; // new
isDayBreak?: boolean;
}
```
### D2: Editing UX in sidebar
The note appears as italic text below the waypoint name in the sidebar list item.
When empty, a muted placeholder reads "Add a note..." (i18n key:
`planner.waypoint.notePlaceholder`).
Clicking the note text (or placeholder) activates an inline `<textarea>` with:
- Auto-focus on click
- Auto-resize to content height
- Character counter showing remaining chars (e.g., "127 / 500")
- Save on blur (write to Y.Map)
- Cancel on Escape (revert to last saved value)
- No explicit save button - auto-save keeps the interaction lightweight
The textarea uses the same font styling as the display text (italic, --text-md
color) so the transition between view and edit is seamless.
### D3: Map marker note indicator
Waypoint markers that have a non-empty note display a small note icon (a
document/pencil glyph) as a CSS pseudo-element or secondary Leaflet DivIcon
element, positioned at the top-right of the marker.
Hover (desktop) or tap (mobile) on a marker with a note shows a Leaflet tooltip
containing the note text. The tooltip has a max-width of 250px and truncates
after 3 lines with ellipsis for long notes. Clicking "more" in the tooltip
scrolls the sidebar to that waypoint and opens the editor.
### D4: Collaborative editing
Notes are plain strings stored via `yMap.set("note", value)`. Yjs conflict
resolution for Y.Map string values is last-write-wins, which is acceptable for
short text notes. This is the same behavior as waypoint names.
If finer-grained collaborative editing were needed (two users editing the same
note simultaneously), we would use a nested `Y.Text`. That complexity is not
justified for short notes that rarely have concurrent edits. If usage patterns
prove otherwise, upgrading to `Y.Text` is a backward-compatible change.
### D5: GPX export
The `generateGpx` function in `@trails-cool/gpx` includes the note as a `<desc>`
element inside the `<wpt>` element, per GPX 1.1 spec:
```xml
<wpt lat="51.0504" lon="13.7373">
<name>Dresden Neustadt</name>
<desc>Water refill at train station</desc>
</wpt>
```
The `<desc>` element is only emitted when the note is non-empty. XML special
characters are escaped via the existing `escapeXml` helper.
GPX import (parsing) should also read `<desc>` into the `note` field when
present, so round-tripping preserves notes.
### D6: Note display styling
Per the visual-redesign D4 mockup, notes render as italic text in `--text-md`
color below the waypoint name in the sidebar. Styling:
```
font-style: italic
color: var(--text-md) /* #5C5847 */
font-size: 0.8125rem /* 13px, one step below body 14px */
line-height: 1.3
max-height: 2.6em /* ~2 lines in display mode */
overflow: hidden
text-overflow: ellipsis
```
When the sidebar is in its current pre-redesign state (before visual-redesign
lands), use equivalent Tailwind classes (`italic text-gray-500 text-xs`).
### D7: POI source - Overpass API
POI data comes from the Overpass API, which provides read access to OpenStreetMap
data. When a waypoint is selected in the sidebar, we query for amenity, tourism,
and natural nodes within a radius of the waypoint.
Query strategy:
- Endpoint: `https://overpass-api.de/api/interpreter` (public instance)
- Query type: `node` elements within a bounding box derived from the waypoint
coordinates +/- ~500m (~0.0045 degrees latitude, adjusted for longitude)
- Filter by relevant tags (see D8 for POI types)
- Return compact JSON format (`[out:json]`)
Example query skeleton:
```
[out:json][timeout:10];
(
node["amenity"~"drinking_water|water_point|shelter|restaurant|cafe|fast_food"]({{bbox}});
node["tourism"~"camp_site|camp_pitch|alpine_hut|wilderness_hut|viewpoint"]({{bbox}});
node["natural"="spring"]({{bbox}});
node["amenity"="bicycle_parking"]({{bbox}});
node["amenity"="bicycle_repair_station"]({{bbox}});
);
out body;
```
The query runs client-side from the browser (no server proxy needed). The
Overpass API supports CORS.
### D8: POI types
POIs are categorized by type, each with an icon and OSM tag mapping. The set of
displayed types depends on the active routing profile.
| Category | OSM Tags | Icon | Profiles |
|----------|----------|------|----------|
| Water | `amenity=drinking_water`, `amenity=water_point`, `natural=spring` | 💧 | all |
| Shelter | `tourism=alpine_hut`, `tourism=wilderness_hut`, `amenity=shelter` | 🏠 | all |
| Camping | `tourism=camp_site`, `tourism=camp_pitch` | ⛺ | all |
| Food | `amenity=restaurant`, `amenity=cafe`, `amenity=fast_food` | 🍴 | all |
| Viewpoint | `tourism=viewpoint` | 👁 | all |
| Bicycle | `amenity=bicycle_parking`, `amenity=bicycle_repair_station` | 🔧 | trekking, fastbike |
The type list is defined in a `POI_TYPES` constant, making it easy to extend.
Each entry maps OSM tags to a category, icon, and i18n label key.
### D9: POI display
When a waypoint is selected (clicked in sidebar or on map):
**Map**: Small circle markers appear around the selected waypoint showing nearby
POIs. Each marker uses the category icon as a tooltip and is colored by type
(blue for water, green for camping, orange for food, etc.). Markers are smaller
than waypoint markers to avoid visual confusion. They disappear when the waypoint
is deselected or a different waypoint is selected.
**Sidebar**: Below the waypoint's note area, a "Nearby" section lists POIs
grouped by category. Each item shows: icon, POI name (or "Unnamed" + type),
distance from waypoint (e.g., "120m"), and a snap button. The list is sorted by
distance, closest first. Maximum 15 POIs shown to avoid overwhelming the UI;
remaining are hidden behind "Show more".
Clicking a POI in the sidebar highlights it on the map. Clicking a POI marker on
the map scrolls the sidebar to that POI.
### D10: Snap behavior
Clicking the snap button (or clicking a POI marker on the map) performs:
1. **Move waypoint**: Set `lat`/`lon` on the waypoint Y.Map to the POI's
coordinates. This triggers the existing route recalculation.
2. **Set name**: If the POI has an OSM `name` tag and the waypoint has no name
(or the user confirms overwrite), set the waypoint name from the POI.
3. **Set note prefix**: Prepend the POI type icon and label to the waypoint's
note. For example, snapping to a campsite named "Waldcamp" sets the note to
"⛺ Campsite" (or "⛺ Campsite - Waldcamp" if named). If the waypoint already
has a note, the prefix is prepended on a new line.
4. **Update POI list**: Re-fetch nearby POIs for the new location (since the
waypoint moved).
All changes happen in a single Yjs transaction to keep the undo stack clean.
The user can always edit the name and note after snapping. Snapping is a
convenience, not a constraint.
### D11: POI caching
Overpass responses are cached in memory to avoid redundant network requests:
- **Cache key**: Quantized bounding box tile. Coordinates are rounded to a grid
(~500m tiles) so nearby waypoints share a cache entry.
- **TTL**: 1 hour. POI data changes infrequently; stale data is acceptable.
- **Storage**: Plain `Map<string, { data: POI[]; timestamp: number }>` in a
module-level variable. Not stored in Yjs (POI data is ephemeral, not shared
between collaborators).
- **Eviction**: On access, expired entries are removed. Maximum 50 cached tiles
to bound memory usage.
### D12: Overpass rate limiting
The public Overpass API has usage policies (no more than 2 concurrent requests,
10,000 requests/day). We respect these with:
- **Debounce**: Wait 500ms after a waypoint is selected before querying. If the
user clicks through waypoints quickly, only the last selection triggers a query.
- **Concurrency**: Maximum 1 in-flight Overpass request. If a new request is
needed while one is pending, the pending one is aborted (via `AbortController`).
- **Backoff on 429**: If Overpass returns HTTP 429 (rate limit), disable POI
queries for 60 seconds and show a subtle "POI lookup unavailable" message.
- **Timeout**: 10-second timeout on Overpass requests. On timeout, fail silently
(POIs are a nice-to-have, not critical).
## Risks / Trade-offs
- **Last-write-wins for notes**: If two users edit the same waypoint's note
simultaneously, one edit is lost. Acceptable for short notes; upgrade path to
Y.Text exists.
- **Soft character limit**: Not enforced at the data layer. A programmatic client
could write longer notes. UI handles gracefully with truncation.
- **Note icon clutter on map**: Many waypoints with notes could make the map
busy. Mitigate by keeping the indicator small and subtle. Consider hiding
indicators at low zoom levels in a future iteration.
- **Overpass API availability**: The public Overpass instance may be slow or
unavailable. POI features degrade gracefully - the Planner works fine without
them. No error modals; just empty POI lists.
- **POI data quality**: OSM data varies by region. Some areas have sparse POI
coverage. Users should not rely solely on POI snapping for critical waypoints
like water sources.
- **Snap overwrites**: Snapping moves the waypoint and can change its name/note.
Mitigated by keeping the action explicit (requires click) and making all fields
editable after snap. Undo via Yjs history restores the previous state.

View file

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

View file

@ -0,0 +1,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