diff --git a/openspec/changes/activity-photos/.openspec.yaml b/openspec/changes/activity-photos/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/activity-photos/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/activity-photos/design.md b/openspec/changes/activity-photos/design.md new file mode 100644 index 0000000..c59061e --- /dev/null +++ b/openspec/changes/activity-photos/design.md @@ -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 `` 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. diff --git a/openspec/changes/activity-photos/proposal.md b/openspec/changes/activity-photos/proposal.md new file mode 100644 index 0000000..5e9d482 --- /dev/null +++ b/openspec/changes/activity-photos/proposal.md @@ -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 diff --git a/openspec/changes/activity-photos/tasks.md b/openspec/changes/activity-photos/tasks.md new file mode 100644 index 0000000..c1f14e0 --- /dev/null +++ b/openspec/changes/activity-photos/tasks.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml b/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml new file mode 100644 index 0000000..40c5540 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-25 diff --git a/openspec/changes/archive/2026-03-28-route-features/design.md b/openspec/changes/archive/2026-03-28-route-features/design.md new file mode 100644 index 0000000..9d8adaa --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/design.md @@ -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. diff --git a/openspec/changes/archive/2026-03-28-route-features/proposal.md b/openspec/changes/archive/2026-03-28-route-features/proposal.md new file mode 100644 index 0000000..b8b74df --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/proposal.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md new file mode 100644 index 0000000..55ea432 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/activity-feed/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md new file mode 100644 index 0000000..6ba4a24 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/activity-photos/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md new file mode 100644 index 0000000..40f5f57 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/multi-day-routes/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md new file mode 100644 index 0000000..a54b15b --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/route-management/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md new file mode 100644 index 0000000..bfb69f8 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/route-sharing/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md b/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md new file mode 100644 index 0000000..7c35e6a --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/specs/spatial-search/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-28-route-features/tasks.md b/openspec/changes/archive/2026-03-28-route-features/tasks.md new file mode 100644 index 0000000..44950b0 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-route-features/tasks.md @@ -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 diff --git a/openspec/changes/local-dev-stack/.openspec.yaml b/openspec/changes/local-dev-stack/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/local-dev-stack/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/local-dev-stack/design.md b/openspec/changes/local-dev-stack/design.md new file mode 100644 index 0000000..4a6e61d --- /dev/null +++ b/openspec/changes/local-dev-stack/design.md @@ -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. diff --git a/openspec/changes/local-dev-stack/proposal.md b/openspec/changes/local-dev-stack/proposal.md new file mode 100644 index 0000000..14a66a0 --- /dev/null +++ b/openspec/changes/local-dev-stack/proposal.md @@ -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) diff --git a/openspec/changes/local-dev-stack/tasks.md b/openspec/changes/local-dev-stack/tasks.md new file mode 100644 index 0000000..c2a0973 --- /dev/null +++ b/openspec/changes/local-dev-stack/tasks.md @@ -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 diff --git a/openspec/changes/multi-day-routes/.openspec.yaml b/openspec/changes/multi-day-routes/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/multi-day-routes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/multi-day-routes/design.md b/openspec/changes/multi-day-routes/design.md new file mode 100644 index 0000000..2ad3e7b --- /dev/null +++ b/openspec/changes/multi-day-routes/design.md @@ -0,0 +1,211 @@ +## Context + +The Planner stores waypoints as a Yjs `Y.Array>` 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 `overnight` element inside +the `` tag. This is valid GPX 1.1 (the `` element is a standard +child of ``). + +```xml + + Dessau + overnight + +``` + +Additionally, the track can optionally be split into multiple `` elements +(one per day), each with a `` 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 `overnight` +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 `overnight` convention is not a standard + GPX extension namespace. Other tools will ignore it, which is fine. The data + is not lost, just not interpreted. diff --git a/openspec/changes/multi-day-routes/proposal.md b/openspec/changes/multi-day-routes/proposal.md new file mode 100644 index 0000000..bb8cbbe --- /dev/null +++ b/openspec/changes/multi-day-routes/proposal.md @@ -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 `overnight` + 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 `overnight` on day-break + waypoints +- **i18n**: New keys for day labels, overnight toggle, per-day stats (en + de) diff --git a/openspec/changes/multi-day-routes/tasks.md b/openspec/changes/multi-day-routes/tasks.md new file mode 100644 index 0000000..d6f984d --- /dev/null +++ b/openspec/changes/multi-day-routes/tasks.md @@ -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 `overnight` for waypoints with `isDayBreak: true` +- [ ] 5.2 Add multi-track export option: split track into one `` 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 `overnight` 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 diff --git a/openspec/changes/osm-overlays/.openspec.yaml b/openspec/changes/osm-overlays/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/osm-overlays/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/osm-overlays/design.md b/openspec/changes/osm-overlays/design.md new file mode 100644 index 0000000..86e02a9 --- /dev/null +++ b/openspec/changes/osm-overlays/design.md @@ -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` — 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. diff --git a/openspec/changes/osm-overlays/proposal.md b/openspec/changes/osm-overlays/proposal.md new file mode 100644 index 0000000..04574ec --- /dev/null +++ b/openspec/changes/osm-overlays/proposal.md @@ -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. diff --git a/openspec/changes/osm-overlays/specs/map-display/spec.md b/openspec/changes/osm-overlays/specs/map-display/spec.md new file mode 100644 index 0000000..50d474f --- /dev/null +++ b/openspec/changes/osm-overlays/specs/map-display/spec.md @@ -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 diff --git a/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md b/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md new file mode 100644 index 0000000..f05a44b --- /dev/null +++ b/openspec/changes/osm-overlays/specs/osm-poi-overlays/spec.md @@ -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 diff --git a/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md b/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md new file mode 100644 index 0000000..396fee5 --- /dev/null +++ b/openspec/changes/osm-overlays/specs/osm-tile-overlays/spec.md @@ -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 diff --git a/openspec/changes/osm-overlays/specs/planner-session/spec.md b/openspec/changes/osm-overlays/specs/planner-session/spec.md new file mode 100644 index 0000000..7233678 --- /dev/null +++ b/openspec/changes/osm-overlays/specs/planner-session/spec.md @@ -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 diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md new file mode 100644 index 0000000..28a5d99 --- /dev/null +++ b/openspec/changes/osm-overlays/tasks.md @@ -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) diff --git a/openspec/changes/route-discovery/.openspec.yaml b/openspec/changes/route-discovery/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/route-discovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/route-discovery/design.md b/openspec/changes/route-discovery/design.md new file mode 100644 index 0000000..392ced8 --- /dev/null +++ b/openspec/changes/route-discovery/design.md @@ -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. diff --git a/openspec/changes/route-discovery/proposal.md b/openspec/changes/route-discovery/proposal.md new file mode 100644 index 0000000..387ccf9 --- /dev/null +++ b/openspec/changes/route-discovery/proposal.md @@ -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) diff --git a/openspec/changes/route-discovery/tasks.md b/openspec/changes/route-discovery/tasks.md new file mode 100644 index 0000000..c060899 --- /dev/null +++ b/openspec/changes/route-discovery/tasks.md @@ -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) diff --git a/openspec/changes/route-sharing/.openspec.yaml b/openspec/changes/route-sharing/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/route-sharing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/route-sharing/design.md b/openspec/changes/route-sharing/design.md new file mode 100644 index 0000000..9a7becc --- /dev/null +++ b/openspec/changes/route-sharing/design.md @@ -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; +async function canEdit(routeId: string, userId: string | null): Promise; +``` + +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(), +``` + +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 +
+ + +
+``` + +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. diff --git a/openspec/changes/route-sharing/proposal.md b/openspec/changes/route-sharing/proposal.md new file mode 100644 index 0000000..09c57b9 --- /dev/null +++ b/openspec/changes/route-sharing/proposal.md @@ -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). diff --git a/openspec/changes/route-sharing/tasks.md b/openspec/changes/route-sharing/tasks.md new file mode 100644 index 0000000..09b3425 --- /dev/null +++ b/openspec/changes/route-sharing/tasks.md @@ -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 diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/undo-redo/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/undo-redo/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/undo-redo/design.md new file mode 100644 index 0000000..95fd812 --- /dev/null +++ b/openspec/changes/undo-redo/design.md @@ -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 ``, +`