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 ``,
+`