Archive phase-1-mvp, sync specs to main

All 85 tasks complete. Archive change to openspec/changes/archive/ and
sync 9 capability specs to openspec/specs/ for future reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-25 02:55:28 +01:00
parent f7288c88d1
commit e530492753
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
22 changed files with 1414 additions and 0 deletions

View file

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

View file

@ -0,0 +1,130 @@
## Context
trails.cool is a new platform with two apps: a collaborative route Planner and
a federated activity Journal. The full architecture is documented in
docs/architecture.md with 19 resolved design decisions. Phase 1 builds the
foundation — both apps with minimal features, shared packages, and deployment
infrastructure.
The Planner is stateless and ephemeral — it runs collaborative editing sessions
via Yjs and computes routes via BRouter. The Journal is stateful — it stores
user accounts, routes, and activities in PostgreSQL with PostGIS.
Both apps share a TypeScript/React stack (React Router 7, Tailwind) and are
deployed to a single Hetzner CX21 server via Docker Compose.
## Goals / Non-Goals
**Goals:**
- Working Planner with collaborative waypoint editing and BRouter route computation
- Working Journal with user accounts, route CRUD, and activity feed
- Seamless handoff between Journal and Planner (open route in Planner, save back)
- Shared packages for types, UI components, map rendering, GPX parsing, i18n
- Deployable to Hetzner via Terraform + Docker Compose
- Germany map coverage (~750 MB RD5 segments)
**Non-Goals:**
- ActivityPub federation (Phase 2)
- Following/followers, likes, comments (Phase 2)
- Photo attachments (Phase 2)
- Route sharing permissions beyond basic CRUD (Phase 2)
- Mobile app or offline support (Phase 3)
- Multi-day route planning UI (Phase 3)
- WASM compilation of BRouter (Phase 3)
- Monitoring stack (Grafana/Prometheus — add when needed)
## Decisions
### D1: React Router 7 for both apps
Both Planner and Journal use React Router 7 (Remix stack) as their full-stack
framework. This gives SSR for Journal (SEO, initial load), API routes, and
loader/action patterns.
**Alternative considered**: Separate frameworks (e.g., Next.js for Journal, Vite
SPA for Planner). Rejected because maintaining two frameworks doubles learning
curve and prevents sharing server-side code patterns.
### D2: BRouter wrapped as HTTP proxy in Planner backend
The Planner's React Router 7 server proxies BRouter API calls. Clients never
talk to BRouter directly. This allows rate limiting, request validation, and
later caching at the proxy layer.
**Alternative considered**: Expose BRouter directly. Rejected because BRouter
has no built-in auth, rate limiting, or CORS support.
### D3: Yjs with y-websocket for CRDT sync
Use y-websocket for the Planner's real-time sync. The WebSocket server runs
as part of the Planner's Node.js process (not a separate service). Yjs documents
are persisted to PostgreSQL for crash recovery.
**Alternative considered**: Separate y-websocket service. Rejected for Phase 1
simplicity — one process is easier to deploy and debug. Can extract later.
### D4: PostgreSQL shared instance, separate schemas
One PostgreSQL instance with two schemas:
- `planner` — Yjs session documents, session metadata
- `journal` — users, routes, activities, media references
**Alternative considered**: Separate PostgreSQL instances. Rejected — unnecessary
overhead for 100 users. Single instance is simpler to backup and manage.
### D5: Routing host election via Yjs awareness
Only one client per session talks to BRouter (the "routing host"). The host is
the session initiator; on disconnect, the longest-connected client takes over.
This avoids redundant BRouter API calls.
**Alternative considered**: Server-side route computation (Planner backend
watches Yjs changes and computes routes). Better long-term but more complex.
Client-side host election is simpler for Phase 1.
### D6: Scoped JWT for Planner-Journal callback
When the Journal opens a Planner session, it generates a scoped JWT token
embedded in the callback URL. The Planner sends this JWT when saving GPX back.
The Journal validates the JWT signature to authorize the write.
**Alternative considered**: Session cookies / OAuth flow. Rejected — the Planner
is stateless and doesn't have access to the Journal's session.
### D7: Leaflet with plugin-based layers
Leaflet (not Mapbox GL) for map rendering. Leaflet is lighter, has no API key
requirement, and has mature plugin ecosystem for OSM tiles.
**Alternative considered**: Mapbox GL JS. Rejected — requires API key, larger
bundle, and commercial license for heavy usage.
### D8: pnpm + Turborepo for monorepo
pnpm workspaces for dependency management, Turborepo for build orchestration
and caching. This is the standard monorepo toolchain for TypeScript projects.
**Alternative considered**: Nx. Rejected — heavier setup, more opinionated.
Turborepo is simpler and sufficient for our needs.
## Risks / Trade-offs
**[BRouter Java dependency]** → The Planner requires a JVM to run BRouter.
This adds Docker image size (~200 MB) and memory usage (~128 MB heap).
Mitigation: BRouter runs in its own container with constrained resources.
**[Yjs document size growth]** → Long editing sessions could grow Yjs documents.
Mitigation: Monitor document sizes in PostgreSQL. Add compaction if needed.
**[Single server SPOF]** → All services on one Hetzner CX21.
Mitigation: Acceptable for 100 users. Daily backups to Hetzner Storage Box.
Scale to multiple servers in Phase 2 if needed.
**[BRouter segment freshness]** → RD5 segments are updated weekly on brouter.de.
Stale data could cause routing on newly built roads to fail.
Mitigation: Weekly cron job to download updated segments.
**[Cross-origin Planner-Journal integration]** → Planner and Journal are on
different subdomains (planner.trails.cool vs trails.cool). Cookie sharing
won't work.
Mitigation: JWT-based callback (Decision D6). No cookies needed.

View file

@ -0,0 +1,43 @@
## Why
trails.cool needs its foundation: a working Planner for collaborative route
editing and a Journal for managing routes and activities. Without Phase 1,
nothing can be tested or shown to users. The architecture plan is finalized
(see docs/architecture.md) — now we need a working MVP to validate the
product-market fit with 100 European users, primarily in Germany.
## What Changes
- Set up the monorepo build toolchain (Turborepo, pnpm, TypeScript, React Router 7, Tailwind)
- Build the Planner app: real-time collaborative route editing via Yjs, BRouter integration for route computation, Leaflet map with OSM tiles, session sharing via link, GPX export
- Build the Journal app: user accounts, route CRUD, start Planner sessions from routes via callback, GPX import/export, basic profile page, activity feed
- Deploy BRouter as a Docker service with Germany RD5 segments (~750 MB)
- Set up infrastructure as code (Terraform + Docker Compose) for Hetzner Cloud
- Establish shared packages: types, UI components, map utilities, GPX parsing, i18n
## Capabilities
### New Capabilities
- `planner-session`: Collaborative route editing sessions with Yjs CRDTs, shareable links, guest access, session lifecycle (create, join, save, close, expire)
- `brouter-integration`: BRouter HTTP API wrapper, routing host election, route computation from waypoints, profile selection (bike/hike)
- `map-display`: Leaflet map with OSM/OpenTopoMap/CyclOSM base layers, waypoint editing, route visualization, elevation profile display
- `journal-auth`: User accounts with federated identity structure (@user@instance), registration, login, profile pages
- `route-management`: Route CRUD, GPX import/export, route metadata envelope, PostGIS spatial storage, route versioning (sequential)
- `planner-journal-handoff`: Callback-based integration between Journal and Planner — open Planner from route, save GPX back via scoped JWT token
- `activity-feed`: Activity CRUD, activity feed (own activities), link activities to routes (1:N blueprint model)
- `shared-packages`: Monorepo shared packages — @trails-cool/types, @trails-cool/ui, @trails-cool/map, @trails-cool/gpx, @trails-cool/i18n
- `infrastructure`: Terraform for Hetzner Cloud, Docker Compose for services, CI/CD via GitHub Actions
### Modified Capabilities
(None — this is the initial build, no existing capabilities to modify.)
## Impact
- **New apps**: `apps/planner` and `apps/journal` (React Router 7)
- **New packages**: `packages/types`, `packages/ui`, `packages/map`, `packages/gpx`, `packages/i18n`
- **Infrastructure**: Hetzner CX21 server, PostgreSQL + PostGIS, Garage (S3), BRouter Docker container
- **External dependencies**: React Router 7, Yjs, Leaflet, Fedify (stub for Phase 1), BRouter (Java), Tailwind CSS, react-i18next
- **Data**: Germany RD5 segments from brouter.de (~750 MB), PostgreSQL schemas (planner.*, activity.*)
- **Domains**: trails.cool (Journal), planner.trails.cool (Planner)

View file

@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Create activity
The Journal SHALL allow authenticated users to create an activity by uploading a GPX trace and adding a description.
#### Scenario: Create activity with GPX
- **WHEN** a user uploads a GPX file and enters a description
- **THEN** an activity is created with the GPS trace, computed distance and duration, and stored in the database
#### Scenario: Create activity linked to route
- **WHEN** a user creates an activity and selects an existing route
- **THEN** the activity is linked to that route (route_id foreign key)
### Requirement: Activity feed
The Journal SHALL display a chronological feed of the authenticated user's activities.
#### Scenario: View own activity feed
- **WHEN** a logged-in user navigates to their feed
- **THEN** they see their activities in reverse chronological order with name, date, distance, and duration
### Requirement: Activity detail page
The Journal SHALL display an activity detail page with map, stats, and description.
#### Scenario: View activity detail
- **WHEN** a user navigates to an activity URL
- **THEN** they see the activity name, description, a map with the GPS trace, distance, duration, and elevation stats
### Requirement: Link activity to route
Users SHALL be able to link an existing activity to a route, or create a route from an activity trace.
#### Scenario: Link activity to existing route
- **WHEN** a user selects "Link to Route" on an activity and chooses a route
- **THEN** the activity's route_id is set to the selected route
#### Scenario: Create route from activity
- **WHEN** a user selects "Create Route from Activity"
- **THEN** a new route is created using the activity's GPX trace
### Requirement: Activity without route
Activities SHALL be allowed to exist without a linked route.
#### Scenario: Standalone activity
- **WHEN** a user imports a GPX activity without linking to a route
- **THEN** the activity is stored with route_id as null

View file

@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Route computation from waypoints
The Planner SHALL compute a route between ordered waypoints by calling the BRouter HTTP API and returning the result as GeoJSON.
#### Scenario: Compute route with two waypoints
- **WHEN** the routing host submits two waypoints (start, end) with profile "trekking"
- **THEN** the BRouter API returns a GeoJSON route within 2 seconds
#### Scenario: Compute route with via points
- **WHEN** the routing host submits three or more waypoints
- **THEN** the BRouter API returns a route passing through all waypoints in order
### Requirement: Routing host election
The Planner SHALL elect one participant per session as the "routing host" who is responsible for sending waypoint changes to BRouter. Only the host SHALL make BRouter API calls.
#### Scenario: Initial host assignment
- **WHEN** a session is created
- **THEN** the session creator is assigned as the routing host via Yjs awareness state
#### Scenario: Host failover
- **WHEN** the current routing host disconnects
- **THEN** the longest-connected remaining participant becomes the new host within 5 seconds
### Requirement: Route broadcast
The routing host SHALL store computed route results in the Yjs document so that all participants receive route updates automatically.
#### Scenario: Route update propagation
- **WHEN** the routing host receives a new route from BRouter
- **THEN** the route GeoJSON is stored in a Y.Map field and all participants see the updated route on their maps
### Requirement: Profile selection
The Planner SHALL support selecting a routing profile that determines how BRouter computes the route.
#### Scenario: Switch routing profile
- **WHEN** a user changes the routing profile from "trekking" to "shortest"
- **THEN** the profile change syncs via Yjs and the routing host recomputes the route
### Requirement: BRouter API proxy
The Planner backend SHALL proxy all BRouter API calls. Clients SHALL NOT communicate with BRouter directly.
#### Scenario: Proxied route request
- **WHEN** the routing host client requests a route computation
- **THEN** the Planner backend forwards the request to BRouter, applies rate limiting, and returns the response
### Requirement: Rate limiting
The Planner backend SHALL rate limit BRouter API calls to prevent abuse.
#### Scenario: Rate limit exceeded
- **WHEN** a session exceeds 60 route computations per hour
- **THEN** subsequent requests receive a 429 response with a Retry-After header
### Requirement: BRouter Docker deployment
BRouter SHALL run as a separate Docker container with Germany RD5 segments mounted as a volume.
#### Scenario: BRouter container starts
- **WHEN** the Docker Compose stack starts
- **THEN** the BRouter container is reachable at its internal HTTP port and can compute routes using the mounted RD5 segments

View file

@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Terraform Hetzner provisioning
Infrastructure SHALL be provisioned on Hetzner Cloud using Terraform with the Hetzner provider.
#### Scenario: Provision server
- **WHEN** `terraform apply` is run
- **THEN** a Hetzner CX21 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed
### Requirement: Docker Compose deployment
All services SHALL be deployed via Docker Compose on the Hetzner server.
#### Scenario: Start all services
- **WHEN** `docker compose up -d` is run on the server
- **THEN** the Journal, Planner, BRouter, PostgreSQL, and Garage containers start and are reachable
### Requirement: Service configuration
Each service SHALL be configured via environment variables defined in Docker Compose.
#### Scenario: Journal configuration
- **WHEN** the Journal container starts
- **THEN** it reads DOMAIN, DATABASE_URL, PLANNER_URL, S3_ENDPOINT, and S3_BUCKET from environment variables
#### Scenario: Planner configuration
- **WHEN** the Planner container starts
- **THEN** it reads BROUTER_URL and DATABASE_URL from environment variables
### Requirement: PostgreSQL with PostGIS
The database SHALL be PostgreSQL with the PostGIS extension for spatial queries.
#### Scenario: PostGIS available
- **WHEN** the PostgreSQL container starts
- **THEN** the PostGIS extension is available and can be enabled with `CREATE EXTENSION postgis`
### Requirement: BRouter segment management
The infrastructure SHALL support downloading and updating Germany RD5 segments from brouter.de.
#### Scenario: Download segments
- **WHEN** the segment download script is run
- **THEN** Germany RD5 files (E5_N45, E5_N50, E10_N45, E10_N50) are downloaded to the segments volume
#### Scenario: Weekly segment update
- **WHEN** the weekly cron job runs
- **THEN** RD5 segments are updated from brouter.de and the BRouter container is restarted
### Requirement: CI/CD pipeline
GitHub Actions SHALL build and deploy both apps on push to main.
#### Scenario: Push triggers deployment
- **WHEN** code is pushed to the main branch
- **THEN** GitHub Actions builds Docker images, pushes to ghcr.io/trails-cool/, and deploys to the Hetzner server
### Requirement: Backup strategy
The infrastructure SHALL include daily backups of the PostgreSQL database.
#### Scenario: Daily backup
- **WHEN** the daily backup cron runs
- **THEN** a PostgreSQL dump is uploaded to the Hetzner Storage Box
### Requirement: Domain and TLS
The infrastructure SHALL configure DNS and TLS for trails.cool and planner.trails.cool.
#### Scenario: HTTPS access
- **WHEN** a user navigates to https://trails.cool
- **THEN** the connection is secured with a valid TLS certificate

View file

@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Passkey registration
The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required.
#### Scenario: Successful passkey registration
- **WHEN** a user enters an email and username and creates a passkey via the browser prompt
- **THEN** a new user account is created, the passkey credential is stored, and the user is logged in
#### Scenario: Duplicate email
- **WHEN** a user submits an email that is already registered
- **THEN** the system displays an error indicating the email is already in use
#### Scenario: Duplicate username
- **WHEN** a user submits a username that is already taken
- **THEN** the system displays an error indicating the username is not available
### Requirement: Passkey login
The Journal SHALL allow returning users to log in using a stored passkey.
#### Scenario: Successful passkey login
- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt
- **THEN** the user is authenticated and redirected to their activity feed
#### Scenario: No passkey available
- **WHEN** a user has no passkey on the current device
- **THEN** the system offers magic link login as a fallback
### Requirement: Magic link login (fallback)
The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device.
#### Scenario: Request magic link
- **WHEN** a user enters their email and clicks "Send magic link"
- **THEN** an email with a single-use login link is sent to their address
#### Scenario: Valid magic link
- **WHEN** a user clicks a valid, non-expired magic link
- **THEN** the user is logged in and the link is invalidated
#### Scenario: Expired magic link
- **WHEN** a user clicks a magic link older than 15 minutes
- **THEN** the system displays an error and prompts them to request a new link
#### Scenario: Rate limiting
- **WHEN** a user requests more than 5 magic links in 10 minutes
- **THEN** subsequent requests are rejected with a rate limit message
### Requirement: Add passkey from new device
The Journal SHALL allow logged-in users to register additional passkeys for new devices.
#### Scenario: Add passkey after magic link login
- **WHEN** a user logs in via magic link on a new device
- **THEN** the system prompts them to register a passkey for that device
### Requirement: User profile page
Each user SHALL have a public profile page displaying their username and routes.
#### Scenario: View own profile
- **WHEN** a logged-in user navigates to their profile
- **THEN** they see their username, bio, and a list of their routes
#### Scenario: View other user's profile
- **WHEN** a user navigates to another user's profile URL
- **THEN** they see that user's username, bio, and public routes
### Requirement: Federated identity structure
User accounts SHALL follow the federated identity pattern (`@user@instance`) to prepare for ActivityPub federation in Phase 2.
#### Scenario: Username format
- **WHEN** a user registers with username "alice" on trails.cool
- **THEN** their full identity is stored as `@alice@trails.cool`
### Requirement: Session management
The Journal SHALL maintain authenticated sessions using secure HTTP-only cookies.
#### Scenario: Session persistence
- **WHEN** a logged-in user closes and reopens their browser
- **THEN** they remain logged in if the session has not expired
#### Scenario: Logout
- **WHEN** a user clicks "Log out"
- **THEN** their session is invalidated and they are redirected to the login page
### Requirement: No passwords
The Journal SHALL NOT support password-based authentication. All authentication is via passkeys or magic links.
#### Scenario: No password field
- **WHEN** a user views the registration or login page
- **THEN** there is no password field

View file

@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Map rendering with OSM tiles
The Planner and Journal SHALL render interactive maps using Leaflet with OpenStreetMap tiles as the default base layer.
#### Scenario: Default map view
- **WHEN** a user opens the Planner or a route view in the Journal
- **THEN** an interactive map is displayed with OpenStreetMap tiles centered on the route or a default location (Germany)
### Requirement: Base layer switching
The map SHALL support switching between multiple base tile layers.
#### 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
### Requirement: Waypoint editing on map
The Planner map SHALL allow users to add, move, and delete waypoints by interacting with the map.
#### Scenario: Add waypoint by clicking
- **WHEN** a user clicks on the map
- **THEN** a new waypoint is added at the clicked location and synced via Yjs
#### Scenario: Move waypoint by dragging
- **WHEN** a user drags an existing waypoint marker
- **THEN** the waypoint coordinates update and sync via Yjs
#### Scenario: Delete waypoint
- **WHEN** a user right-clicks a waypoint and selects "Delete"
- **THEN** the waypoint is removed and the change syncs via Yjs
### Requirement: Route visualization
The map SHALL display the computed route as a polyline on the map.
#### Scenario: Display route
- **WHEN** BRouter returns a route GeoJSON
- **THEN** the route is rendered as a colored polyline on the map
#### Scenario: Route updates on waypoint change
- **WHEN** a waypoint is added, moved, or deleted
- **THEN** the route polyline updates after BRouter recomputes the route
### Requirement: Elevation profile display
The Planner SHALL display an elevation profile chart for the current route.
#### Scenario: Show elevation profile
- **WHEN** a route is computed
- **THEN** an elevation profile chart is displayed below the map showing distance vs elevation with total ascent/descent statistics

View file

@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Open Planner from Journal
The Journal SHALL allow a route owner to open a route in the Planner for collaborative editing.
#### Scenario: Start editing session
- **WHEN** a route owner clicks "Edit in Planner" on a route detail page
- **THEN** the Journal generates a scoped JWT token and redirects to `planner.trails.cool/new?callback=<url>&token=<jwt>` with the route's current GPX
### Requirement: Save from Planner to Journal
The Planner SHALL save route edits back to the Journal via the callback URL provided at session creation.
#### Scenario: Save route back
- **WHEN** a user clicks "Save" in the Planner and a callback URL exists
- **THEN** the Planner POSTs the current GPX and metadata to the callback URL with the JWT token
#### Scenario: Journal receives save callback
- **WHEN** the Journal receives a POST to the callback endpoint with a valid JWT
- **THEN** the Journal creates a new route version with the GPX and credits the contributor
### Requirement: Scoped JWT token
The Journal SHALL generate scoped JWT tokens for Planner callbacks containing the instance URL, route ID, permissions, and expiry.
#### Scenario: Valid token accepted
- **WHEN** the Planner sends a save request with a valid, non-expired JWT
- **THEN** the Journal accepts the request and saves the route
#### Scenario: Expired token rejected
- **WHEN** the Planner sends a save request with an expired JWT
- **THEN** the Journal returns a 401 error
#### Scenario: Invalid token rejected
- **WHEN** the Planner sends a save request with a tampered JWT
- **THEN** the Journal returns a 401 error
### Requirement: Return to Journal after save
After saving, the Planner SHALL provide a link back to the route in the Journal.
#### Scenario: Return link displayed
- **WHEN** a save to the Journal succeeds
- **THEN** the Planner displays a success message with a link to the route in the Journal

View file

@ -0,0 +1,81 @@
## ADDED Requirements
### Requirement: Create collaborative session
The Planner SHALL allow creating a new editing session that generates a unique shareable URL. Sessions SHALL be created either from the Planner directly (empty route) or via a Journal callback (with initial GPX data).
#### Scenario: Create empty session
- **WHEN** a user navigates to planner.trails.cool
- **THEN** a new Yjs session is created with an empty waypoint list and the user is redirected to `/session/<session-id>`
#### Scenario: Create session from Journal callback
- **WHEN** the Journal opens `planner.trails.cool/new?callback=<url>&token=<jwt>&gpx=<encoded-gpx>`
- **THEN** a new Yjs session is created with waypoints parsed from the GPX and the callback URL is stored for later save operations
### Requirement: Join session via link
The Planner SHALL allow any user (including guests without accounts) to join an existing session by navigating to its URL.
#### Scenario: Join active session
- **WHEN** a user navigates to `planner.trails.cool/session/<session-id>`
- **THEN** the user connects to the Yjs document and sees the current route state with all other participants' cursors
#### Scenario: Join expired session
- **WHEN** a user navigates to a session URL that has expired
- **THEN** the system displays an error message indicating the session no longer exists
### Requirement: Real-time collaborative editing
The Planner SHALL synchronize waypoint edits 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
### Requirement: Session persistence
The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survive server restarts.
#### Scenario: Server restart recovery
- **WHEN** the Planner server restarts while a session is active
- **THEN** reconnecting clients recover the full session state from PostgreSQL
### Requirement: Session expiry
The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, max: 30 days).
#### Scenario: Session expires
- **WHEN** no edits are made to a session for 7 days
- **THEN** the session is deleted from PostgreSQL and its URL returns a 404
### Requirement: Manual session close
The session owner (initiator) SHALL be able to manually close a session.
#### Scenario: Owner closes session
- **WHEN** the session owner clicks "Close Session"
- **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible
### Requirement: User presence
The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map.
#### Scenario: Show connected users
- **WHEN** multiple users are connected to a session
- **THEN** each user sees a list of other connected users with assigned colors
#### Scenario: Live map cursors
- **WHEN** a user moves their mouse over the map
- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color
#### Scenario: Cursor disappears on leave
- **WHEN** a user disconnects from the session
- **THEN** their cursor disappears from all other participants' maps within 5 seconds
### Requirement: No user data collection
The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default.
#### Scenario: Anonymous session participation
- **WHEN** a user joins a session without any account
- **THEN** the user is assigned a random color and temporary display name with no data persisted about their identity

View file

@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Create route
The Journal SHALL allow authenticated users to create a new route with a name and optional description.
#### Scenario: Create empty route
- **WHEN** a user clicks "New Route" and enters a name
- **THEN** a new route record is created in PostgreSQL and the user is redirected to the route detail page
### Requirement: View route
The Journal SHALL display route details including map, metadata, and elevation stats.
#### Scenario: View route detail
- **WHEN** a user navigates to a route's URL
- **THEN** they see the route name, description, a map with the route polyline, distance, and elevation gain/loss
### Requirement: Update route
The Journal SHALL allow the route owner to update the route name, description, and GPX.
#### Scenario: Update route metadata
- **WHEN** a route owner edits the route name or description and saves
- **THEN** the route record is updated and a new version is created
### Requirement: Delete route
The Journal SHALL allow the route owner to delete a route.
#### Scenario: Delete route with confirmation
- **WHEN** a route owner clicks "Delete" and confirms
- **THEN** the route and all its versions are permanently deleted
### Requirement: GPX import
The Journal SHALL allow users to create or update a route by uploading a GPX file.
#### Scenario: Import GPX as new route
- **WHEN** a user uploads a GPX file on the "Import" page
- **THEN** a new route is created with waypoints and track parsed from the GPX, and route geometry is stored in PostGIS
#### Scenario: Import GPX to existing route
- **WHEN** a route owner uploads a GPX file on an existing route's page
- **THEN** the route GPX is replaced and a new version is created
### Requirement: GPX export
The Journal SHALL allow users to download any route as a GPX file.
#### Scenario: Export route as GPX
- **WHEN** a user clicks "Export GPX" on a route detail page
- **THEN** a GPX file is downloaded containing the route track and waypoints
### Requirement: Route versioning
The Journal SHALL store sequential versions of each route. Each GPX update creates a new version.
#### Scenario: View version history
- **WHEN** a route owner views the route detail page
- **THEN** they see a list of versions with version number, date, and contributor
### Requirement: Route list
The Journal SHALL display a list of the authenticated user's routes.
#### Scenario: View my routes
- **WHEN** a logged-in user navigates to their route list
- **THEN** they see all their routes with name, distance, and last updated date
### Requirement: PostGIS spatial storage
Route geometries SHALL be stored as PostGIS LineString geometries extracted from the GPX.
#### Scenario: Spatial data stored on import
- **WHEN** a GPX file is imported or a route is saved from the Planner
- **THEN** the route geometry is extracted and stored as a PostGIS LineString for future spatial queries
### Requirement: Route metadata envelope
Routes SHALL be stored with a metadata envelope containing computed statistics (distance, elevation gain/loss), routing profile, contributor list, and tags.
#### Scenario: Metadata computed on save
- **WHEN** a route GPX is saved
- **THEN** distance and elevation statistics are computed from the GPX and stored in the metadata

View file

@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Shared types package
The `@trails-cool/types` package SHALL export TypeScript interfaces for Route, Activity, Waypoint, RouteVersion, and RouteMetadata used by both apps.
#### Scenario: Import types in Planner
- **WHEN** the Planner app imports `@trails-cool/types`
- **THEN** it has access to the Waypoint, Route, and RouteMetadata interfaces
#### Scenario: Import types in Journal
- **WHEN** the Journal app imports `@trails-cool/types`
- **THEN** it has access to Route, Activity, RouteVersion, and RouteMetadata interfaces
### Requirement: GPX parsing package
The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoints, tracks, elevation) and generate GPX XML from structured data.
#### Scenario: Parse GPX to waypoints
- **WHEN** the gpx package parses a valid GPX file
- **THEN** it returns an array of Waypoint objects with lat, lon, and optional name
#### Scenario: Generate GPX from waypoints
- **WHEN** the gpx package is given an array of waypoints and a track
- **THEN** it generates a valid GPX XML string
#### Scenario: Extract elevation data
- **WHEN** the gpx package parses a GPX file with elevation data
- **THEN** it returns elevation gain, loss, and a profile array of distance/elevation pairs
### Requirement: Map rendering package
The `@trails-cool/map` package SHALL provide React components for rendering Leaflet maps with configurable base layers and route overlays.
#### Scenario: Render map component
- **WHEN** the map package's MapView component is rendered with a center and zoom
- **THEN** a Leaflet map is displayed with the default OSM tile layer
#### Scenario: Display route on map
- **WHEN** the map package's RouteLayer component receives GeoJSON
- **THEN** it renders a polyline on the map
### Requirement: UI component package
The `@trails-cool/ui` package SHALL provide shared React components (buttons, layout, form elements) styled with Tailwind CSS.
#### Scenario: Use Button component
- **WHEN** an app renders the Button component from `@trails-cool/ui`
- **THEN** a styled button is displayed consistent with the trails.cool design
### Requirement: i18n package
The `@trails-cool/i18n` package SHALL provide react-i18next configuration and translation strings starting with English and German.
#### Scenario: Display German translation
- **WHEN** a user's browser locale is set to German
- **THEN** UI strings are displayed in German
#### Scenario: Fallback to English
- **WHEN** a user's browser locale is not supported
- **THEN** UI strings fall back to English

View file

@ -0,0 +1,117 @@
## 1. Monorepo Toolchain Setup
- [x] 1.1 Install pnpm and Turborepo, configure workspaces
- [x] 1.2 Set up TypeScript config (base tsconfig, per-package extends)
- [x] 1.3 Set up Tailwind CSS (shared config, content paths for monorepo)
- [x] 1.4 Set up ESLint and Prettier (shared config)
- [x] 1.5 Scaffold Planner app with React Router 7 (`apps/planner`)
- [x] 1.6 Scaffold Journal app with React Router 7 (`apps/journal`)
- [x] 1.7 Verify `turbo dev` starts both apps and `turbo build` succeeds
## 2. Shared Packages
- [x] 2.1 Implement `@trails-cool/types` — Route, Activity, Waypoint, RouteVersion, RouteMetadata interfaces
- [x] 2.2 Implement `@trails-cool/gpx` — GPX parser (XML → waypoints/tracks/elevation) and GPX generator (waypoints/tracks → XML)
- [x] 2.3 Implement `@trails-cool/map` — MapView React component (Leaflet + OSM), RouteLayer component (GeoJSON polyline), layer switcher (OSM/OpenTopoMap/CyclOSM)
- [x] 2.4 Implement `@trails-cool/ui` — Button, Input, Card, Layout components with Tailwind styling
- [x] 2.5 Implement `@trails-cool/i18n` — react-i18next config, English + German translation files, LanguageSwitcher component
- [x] 2.6 Verify all packages are importable from both apps
## 3. Infrastructure
- [x] 3.1 Create Terraform config for Hetzner CX21 with Docker installed
- [x] 3.2 Create Docker Compose for all services (Journal, Planner, BRouter, PostgreSQL+PostGIS, Garage)
- [x] 3.3 Create BRouter Dockerfile with segment volume mount
- [x] 3.4 Create segment download script (Germany: E5_N45, E5_N50, E10_N45, E10_N50)
- [x] 3.5 Configure DNS for trails.cool and planner.trails.cool with TLS (Caddy)
- [x] 3.6 Set up GitHub Actions CI pipeline (build, typecheck, lint, unit tests, e2e)
- [x] 3.7 Set up GitHub Actions CD pipeline (build Docker images, push to ghcr.io, deploy to Hetzner)
- [x] 3.8 Set up PostgreSQL backup cron (daily pg_dump to Hetzner Storage Box)
## 4. Planner — Session Management
- [x] 4.1 Set up Yjs with y-websocket in Planner backend (WebSocket endpoint at /sync)
- [x] 4.2 Implement Yjs persistence to PostgreSQL (planner schema, sessions table)
- [x] 4.3 Implement session creation endpoint (POST /api/sessions → returns session ID)
- [x] 4.4 Implement session creation with initial GPX (parse GPX → Yjs document with waypoints)
- [x] 4.5 Implement session join page (GET /session/:id → connect to Yjs document)
- [x] 4.6 Implement session expiry (garbage collection cron, configurable TTL)
- [x] 4.7 Implement manual session close (owner action, notify participants)
## 5. Planner — BRouter Integration
- [x] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter)
- [x] 5.2 Implement rate limiting middleware (60 requests/session/hour)
- [x] 5.3 Implement routing host election via Yjs awareness (host/participant roles)
- [x] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp)
- [x] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter)
- [x] 5.6 Implement route broadcast (host stores GeoJSON result in Y.Map, syncs to all)
- [x] 5.7 Implement profile selection (sync profile choice via Y.Map, trigger recompute)
## 6. Planner — Map UI
- [x] 6.1 Integrate MapView component in Planner with full-screen layout and client-side Yjs connection
- [x] 6.1a Implement user presence display (Yjs awareness, colors, names, live map cursors)
- [x] 6.2 Implement waypoint add (click map → add to Y.Array)
- [x] 6.3 Implement waypoint drag (move marker → update Y.Array)
- [x] 6.4 Implement waypoint delete (right-click → remove from Y.Array)
- [x] 6.5 Implement waypoint list sidebar (draggable reorder, synced with Y.Array)
- [x] 6.6 Implement route polyline display (render GeoJSON from Y.Map)
- [x] 6.7 Implement elevation profile chart (parse elevation from route GeoJSON, render chart)
- [x] 6.8 Implement profile selector UI (dropdown, synced via Y.Map)
- [x] 6.9 Implement GPX export button (generate GPX from current waypoints and route)
## 7. Journal — Auth
- [x] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table
- [x] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created)
- [x] 7.3 Implement passkey login flow (WebAuthn get → session created)
- [x] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token)
- [x] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created)
- [x] 7.6 Implement "Add passkey" prompt after magic link login on new device
- [x] 7.7 Implement session middleware (validate cookie, load user in loader context)
- [x] 7.8 Implement logout (POST /api/auth/logout, invalidate session)
- [x] 7.9 Implement user profile page (GET /users/:username)
- [x] 7.10 Store federated identity format (@user@domain) in user record
## 8. Journal — Route Management
- [x] 8.1 Set up PostgreSQL schema (journal.routes table with PostGIS geometry column, journal.route_versions table)
- [x] 8.2 Implement route creation page (form: name, description, optional GPX upload)
- [x] 8.3 Implement route detail page (map, metadata, version history)
- [x] 8.4 Implement route edit page (update name, description)
- [x] 8.5 Implement route deletion (with confirmation dialog)
- [x] 8.6 Implement GPX import (parse GPX, extract geometry for PostGIS, compute stats)
- [x] 8.7 Implement GPX export (generate GPX from stored data, download)
- [x] 8.8 Implement route versioning (create new version on each GPX update)
- [x] 8.9 Implement route list page (user's routes, sorted by last updated)
- [x] 8.10 Implement route metadata computation (distance, elevation gain/loss from GPX)
## 9. Planner-Journal Handoff
- [x] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry)
- [x] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX)
- [x] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata)
- [x] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL)
- [x] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version)
- [x] 9.6 Implement "Return to Journal" link after successful save
## 10. Journal — Activity Feed
- [x] 10.1 Set up PostgreSQL schema (journal.activities table with route_id FK, gpx, stats)
- [x] 10.2 Implement activity creation page (GPX upload, description, optional route link)
- [x] 10.3 Implement activity detail page (map with GPS trace, stats, description)
- [x] 10.4 Implement activity feed page (chronological list of own activities)
- [x] 10.5 Implement "Link to Route" action (select existing route to link)
- [x] 10.6 Implement "Create Route from Activity" action (create route from activity GPX)
## 11. Testing & Polish
- [x] 11.1 End-to-end test: Create route in Journal → Edit in Planner → Save back to Journal
- [x] 11.2 End-to-end test: Two users collaboratively edit waypoints in Planner
- [x] 11.3 End-to-end test: Import GPX → view route on map → export GPX
- [x] 11.4 Test BRouter routing with Germany segments (Berlin → Munich route)
- [x] 11.5 Test session expiry and manual close
- [x] 11.6 Verify i18n works (English and German)
- [x] 11.7 Basic responsive layout testing (desktop, tablet)
- [x] 11.8 Deploy to Hetzner and verify production setup

View file

@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Create activity
The Journal SHALL allow authenticated users to create an activity by uploading a GPX trace and adding a description.
#### Scenario: Create activity with GPX
- **WHEN** a user uploads a GPX file and enters a description
- **THEN** an activity is created with the GPS trace, computed distance and duration, and stored in the database
#### Scenario: Create activity linked to route
- **WHEN** a user creates an activity and selects an existing route
- **THEN** the activity is linked to that route (route_id foreign key)
### Requirement: Activity feed
The Journal SHALL display a chronological feed of the authenticated user's activities.
#### Scenario: View own activity feed
- **WHEN** a logged-in user navigates to their feed
- **THEN** they see their activities in reverse chronological order with name, date, distance, and duration
### Requirement: Activity detail page
The Journal SHALL display an activity detail page with map, stats, and description.
#### Scenario: View activity detail
- **WHEN** a user navigates to an activity URL
- **THEN** they see the activity name, description, a map with the GPS trace, distance, duration, and elevation stats
### Requirement: Link activity to route
Users SHALL be able to link an existing activity to a route, or create a route from an activity trace.
#### Scenario: Link activity to existing route
- **WHEN** a user selects "Link to Route" on an activity and chooses a route
- **THEN** the activity's route_id is set to the selected route
#### Scenario: Create route from activity
- **WHEN** a user selects "Create Route from Activity"
- **THEN** a new route is created using the activity's GPX trace
### Requirement: Activity without route
Activities SHALL be allowed to exist without a linked route.
#### Scenario: Standalone activity
- **WHEN** a user imports a GPX activity without linking to a route
- **THEN** the activity is stored with route_id as null

View file

@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Route computation from waypoints
The Planner SHALL compute a route between ordered waypoints by calling the BRouter HTTP API and returning the result as GeoJSON.
#### Scenario: Compute route with two waypoints
- **WHEN** the routing host submits two waypoints (start, end) with profile "trekking"
- **THEN** the BRouter API returns a GeoJSON route within 2 seconds
#### Scenario: Compute route with via points
- **WHEN** the routing host submits three or more waypoints
- **THEN** the BRouter API returns a route passing through all waypoints in order
### Requirement: Routing host election
The Planner SHALL elect one participant per session as the "routing host" who is responsible for sending waypoint changes to BRouter. Only the host SHALL make BRouter API calls.
#### Scenario: Initial host assignment
- **WHEN** a session is created
- **THEN** the session creator is assigned as the routing host via Yjs awareness state
#### Scenario: Host failover
- **WHEN** the current routing host disconnects
- **THEN** the longest-connected remaining participant becomes the new host within 5 seconds
### Requirement: Route broadcast
The routing host SHALL store computed route results in the Yjs document so that all participants receive route updates automatically.
#### Scenario: Route update propagation
- **WHEN** the routing host receives a new route from BRouter
- **THEN** the route GeoJSON is stored in a Y.Map field and all participants see the updated route on their maps
### Requirement: Profile selection
The Planner SHALL support selecting a routing profile that determines how BRouter computes the route.
#### Scenario: Switch routing profile
- **WHEN** a user changes the routing profile from "trekking" to "shortest"
- **THEN** the profile change syncs via Yjs and the routing host recomputes the route
### Requirement: BRouter API proxy
The Planner backend SHALL proxy all BRouter API calls. Clients SHALL NOT communicate with BRouter directly.
#### Scenario: Proxied route request
- **WHEN** the routing host client requests a route computation
- **THEN** the Planner backend forwards the request to BRouter, applies rate limiting, and returns the response
### Requirement: Rate limiting
The Planner backend SHALL rate limit BRouter API calls to prevent abuse.
#### Scenario: Rate limit exceeded
- **WHEN** a session exceeds 60 route computations per hour
- **THEN** subsequent requests receive a 429 response with a Retry-After header
### Requirement: BRouter Docker deployment
BRouter SHALL run as a separate Docker container with Germany RD5 segments mounted as a volume.
#### Scenario: BRouter container starts
- **WHEN** the Docker Compose stack starts
- **THEN** the BRouter container is reachable at its internal HTTP port and can compute routes using the mounted RD5 segments

View file

@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Terraform Hetzner provisioning
Infrastructure SHALL be provisioned on Hetzner Cloud using Terraform with the Hetzner provider.
#### Scenario: Provision server
- **WHEN** `terraform apply` is run
- **THEN** a Hetzner CX21 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed
### Requirement: Docker Compose deployment
All services SHALL be deployed via Docker Compose on the Hetzner server.
#### Scenario: Start all services
- **WHEN** `docker compose up -d` is run on the server
- **THEN** the Journal, Planner, BRouter, PostgreSQL, and Garage containers start and are reachable
### Requirement: Service configuration
Each service SHALL be configured via environment variables defined in Docker Compose.
#### Scenario: Journal configuration
- **WHEN** the Journal container starts
- **THEN** it reads DOMAIN, DATABASE_URL, PLANNER_URL, S3_ENDPOINT, and S3_BUCKET from environment variables
#### Scenario: Planner configuration
- **WHEN** the Planner container starts
- **THEN** it reads BROUTER_URL and DATABASE_URL from environment variables
### Requirement: PostgreSQL with PostGIS
The database SHALL be PostgreSQL with the PostGIS extension for spatial queries.
#### Scenario: PostGIS available
- **WHEN** the PostgreSQL container starts
- **THEN** the PostGIS extension is available and can be enabled with `CREATE EXTENSION postgis`
### Requirement: BRouter segment management
The infrastructure SHALL support downloading and updating Germany RD5 segments from brouter.de.
#### Scenario: Download segments
- **WHEN** the segment download script is run
- **THEN** Germany RD5 files (E5_N45, E5_N50, E10_N45, E10_N50) are downloaded to the segments volume
#### Scenario: Weekly segment update
- **WHEN** the weekly cron job runs
- **THEN** RD5 segments are updated from brouter.de and the BRouter container is restarted
### Requirement: CI/CD pipeline
GitHub Actions SHALL build and deploy both apps on push to main.
#### Scenario: Push triggers deployment
- **WHEN** code is pushed to the main branch
- **THEN** GitHub Actions builds Docker images, pushes to ghcr.io/trails-cool/, and deploys to the Hetzner server
### Requirement: Backup strategy
The infrastructure SHALL include daily backups of the PostgreSQL database.
#### Scenario: Daily backup
- **WHEN** the daily backup cron runs
- **THEN** a PostgreSQL dump is uploaded to the Hetzner Storage Box
### Requirement: Domain and TLS
The infrastructure SHALL configure DNS and TLS for trails.cool and planner.trails.cool.
#### Scenario: HTTPS access
- **WHEN** a user navigates to https://trails.cool
- **THEN** the connection is secured with a valid TLS certificate

View file

@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Passkey registration
The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required.
#### Scenario: Successful passkey registration
- **WHEN** a user enters an email and username and creates a passkey via the browser prompt
- **THEN** a new user account is created, the passkey credential is stored, and the user is logged in
#### Scenario: Duplicate email
- **WHEN** a user submits an email that is already registered
- **THEN** the system displays an error indicating the email is already in use
#### Scenario: Duplicate username
- **WHEN** a user submits a username that is already taken
- **THEN** the system displays an error indicating the username is not available
### Requirement: Passkey login
The Journal SHALL allow returning users to log in using a stored passkey.
#### Scenario: Successful passkey login
- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt
- **THEN** the user is authenticated and redirected to their activity feed
#### Scenario: No passkey available
- **WHEN** a user has no passkey on the current device
- **THEN** the system offers magic link login as a fallback
### Requirement: Magic link login (fallback)
The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device.
#### Scenario: Request magic link
- **WHEN** a user enters their email and clicks "Send magic link"
- **THEN** an email with a single-use login link is sent to their address
#### Scenario: Valid magic link
- **WHEN** a user clicks a valid, non-expired magic link
- **THEN** the user is logged in and the link is invalidated
#### Scenario: Expired magic link
- **WHEN** a user clicks a magic link older than 15 minutes
- **THEN** the system displays an error and prompts them to request a new link
#### Scenario: Rate limiting
- **WHEN** a user requests more than 5 magic links in 10 minutes
- **THEN** subsequent requests are rejected with a rate limit message
### Requirement: Add passkey from new device
The Journal SHALL allow logged-in users to register additional passkeys for new devices.
#### Scenario: Add passkey after magic link login
- **WHEN** a user logs in via magic link on a new device
- **THEN** the system prompts them to register a passkey for that device
### Requirement: User profile page
Each user SHALL have a public profile page displaying their username and routes.
#### Scenario: View own profile
- **WHEN** a logged-in user navigates to their profile
- **THEN** they see their username, bio, and a list of their routes
#### Scenario: View other user's profile
- **WHEN** a user navigates to another user's profile URL
- **THEN** they see that user's username, bio, and public routes
### Requirement: Federated identity structure
User accounts SHALL follow the federated identity pattern (`@user@instance`) to prepare for ActivityPub federation in Phase 2.
#### Scenario: Username format
- **WHEN** a user registers with username "alice" on trails.cool
- **THEN** their full identity is stored as `@alice@trails.cool`
### Requirement: Session management
The Journal SHALL maintain authenticated sessions using secure HTTP-only cookies.
#### Scenario: Session persistence
- **WHEN** a logged-in user closes and reopens their browser
- **THEN** they remain logged in if the session has not expired
#### Scenario: Logout
- **WHEN** a user clicks "Log out"
- **THEN** their session is invalidated and they are redirected to the login page
### Requirement: No passwords
The Journal SHALL NOT support password-based authentication. All authentication is via passkeys or magic links.
#### Scenario: No password field
- **WHEN** a user views the registration or login page
- **THEN** there is no password field

View file

@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Map rendering with OSM tiles
The Planner and Journal SHALL render interactive maps using Leaflet with OpenStreetMap tiles as the default base layer.
#### Scenario: Default map view
- **WHEN** a user opens the Planner or a route view in the Journal
- **THEN** an interactive map is displayed with OpenStreetMap tiles centered on the route or a default location (Germany)
### Requirement: Base layer switching
The map SHALL support switching between multiple base tile layers.
#### 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
### Requirement: Waypoint editing on map
The Planner map SHALL allow users to add, move, and delete waypoints by interacting with the map.
#### Scenario: Add waypoint by clicking
- **WHEN** a user clicks on the map
- **THEN** a new waypoint is added at the clicked location and synced via Yjs
#### Scenario: Move waypoint by dragging
- **WHEN** a user drags an existing waypoint marker
- **THEN** the waypoint coordinates update and sync via Yjs
#### Scenario: Delete waypoint
- **WHEN** a user right-clicks a waypoint and selects "Delete"
- **THEN** the waypoint is removed and the change syncs via Yjs
### Requirement: Route visualization
The map SHALL display the computed route as a polyline on the map.
#### Scenario: Display route
- **WHEN** BRouter returns a route GeoJSON
- **THEN** the route is rendered as a colored polyline on the map
#### Scenario: Route updates on waypoint change
- **WHEN** a waypoint is added, moved, or deleted
- **THEN** the route polyline updates after BRouter recomputes the route
### Requirement: Elevation profile display
The Planner SHALL display an elevation profile chart for the current route.
#### Scenario: Show elevation profile
- **WHEN** a route is computed
- **THEN** an elevation profile chart is displayed below the map showing distance vs elevation with total ascent/descent statistics

View file

@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Open Planner from Journal
The Journal SHALL allow a route owner to open a route in the Planner for collaborative editing.
#### Scenario: Start editing session
- **WHEN** a route owner clicks "Edit in Planner" on a route detail page
- **THEN** the Journal generates a scoped JWT token and redirects to `planner.trails.cool/new?callback=<url>&token=<jwt>` with the route's current GPX
### Requirement: Save from Planner to Journal
The Planner SHALL save route edits back to the Journal via the callback URL provided at session creation.
#### Scenario: Save route back
- **WHEN** a user clicks "Save" in the Planner and a callback URL exists
- **THEN** the Planner POSTs the current GPX and metadata to the callback URL with the JWT token
#### Scenario: Journal receives save callback
- **WHEN** the Journal receives a POST to the callback endpoint with a valid JWT
- **THEN** the Journal creates a new route version with the GPX and credits the contributor
### Requirement: Scoped JWT token
The Journal SHALL generate scoped JWT tokens for Planner callbacks containing the instance URL, route ID, permissions, and expiry.
#### Scenario: Valid token accepted
- **WHEN** the Planner sends a save request with a valid, non-expired JWT
- **THEN** the Journal accepts the request and saves the route
#### Scenario: Expired token rejected
- **WHEN** the Planner sends a save request with an expired JWT
- **THEN** the Journal returns a 401 error
#### Scenario: Invalid token rejected
- **WHEN** the Planner sends a save request with a tampered JWT
- **THEN** the Journal returns a 401 error
### Requirement: Return to Journal after save
After saving, the Planner SHALL provide a link back to the route in the Journal.
#### Scenario: Return link displayed
- **WHEN** a save to the Journal succeeds
- **THEN** the Planner displays a success message with a link to the route in the Journal

View file

@ -0,0 +1,81 @@
## ADDED Requirements
### Requirement: Create collaborative session
The Planner SHALL allow creating a new editing session that generates a unique shareable URL. Sessions SHALL be created either from the Planner directly (empty route) or via a Journal callback (with initial GPX data).
#### Scenario: Create empty session
- **WHEN** a user navigates to planner.trails.cool
- **THEN** a new Yjs session is created with an empty waypoint list and the user is redirected to `/session/<session-id>`
#### Scenario: Create session from Journal callback
- **WHEN** the Journal opens `planner.trails.cool/new?callback=<url>&token=<jwt>&gpx=<encoded-gpx>`
- **THEN** a new Yjs session is created with waypoints parsed from the GPX and the callback URL is stored for later save operations
### Requirement: Join session via link
The Planner SHALL allow any user (including guests without accounts) to join an existing session by navigating to its URL.
#### Scenario: Join active session
- **WHEN** a user navigates to `planner.trails.cool/session/<session-id>`
- **THEN** the user connects to the Yjs document and sees the current route state with all other participants' cursors
#### Scenario: Join expired session
- **WHEN** a user navigates to a session URL that has expired
- **THEN** the system displays an error message indicating the session no longer exists
### Requirement: Real-time collaborative editing
The Planner SHALL synchronize waypoint edits 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
### Requirement: Session persistence
The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survive server restarts.
#### Scenario: Server restart recovery
- **WHEN** the Planner server restarts while a session is active
- **THEN** reconnecting clients recover the full session state from PostgreSQL
### Requirement: Session expiry
The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, max: 30 days).
#### Scenario: Session expires
- **WHEN** no edits are made to a session for 7 days
- **THEN** the session is deleted from PostgreSQL and its URL returns a 404
### Requirement: Manual session close
The session owner (initiator) SHALL be able to manually close a session.
#### Scenario: Owner closes session
- **WHEN** the session owner clicks "Close Session"
- **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible
### Requirement: User presence
The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map.
#### Scenario: Show connected users
- **WHEN** multiple users are connected to a session
- **THEN** each user sees a list of other connected users with assigned colors
#### Scenario: Live map cursors
- **WHEN** a user moves their mouse over the map
- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color
#### Scenario: Cursor disappears on leave
- **WHEN** a user disconnects from the session
- **THEN** their cursor disappears from all other participants' maps within 5 seconds
### Requirement: No user data collection
The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default.
#### Scenario: Anonymous session participation
- **WHEN** a user joins a session without any account
- **THEN** the user is assigned a random color and temporary display name with no data persisted about their identity

View file

@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Create route
The Journal SHALL allow authenticated users to create a new route with a name and optional description.
#### Scenario: Create empty route
- **WHEN** a user clicks "New Route" and enters a name
- **THEN** a new route record is created in PostgreSQL and the user is redirected to the route detail page
### Requirement: View route
The Journal SHALL display route details including map, metadata, and elevation stats.
#### Scenario: View route detail
- **WHEN** a user navigates to a route's URL
- **THEN** they see the route name, description, a map with the route polyline, distance, and elevation gain/loss
### Requirement: Update route
The Journal SHALL allow the route owner to update the route name, description, and GPX.
#### Scenario: Update route metadata
- **WHEN** a route owner edits the route name or description and saves
- **THEN** the route record is updated and a new version is created
### Requirement: Delete route
The Journal SHALL allow the route owner to delete a route.
#### Scenario: Delete route with confirmation
- **WHEN** a route owner clicks "Delete" and confirms
- **THEN** the route and all its versions are permanently deleted
### Requirement: GPX import
The Journal SHALL allow users to create or update a route by uploading a GPX file.
#### Scenario: Import GPX as new route
- **WHEN** a user uploads a GPX file on the "Import" page
- **THEN** a new route is created with waypoints and track parsed from the GPX, and route geometry is stored in PostGIS
#### Scenario: Import GPX to existing route
- **WHEN** a route owner uploads a GPX file on an existing route's page
- **THEN** the route GPX is replaced and a new version is created
### Requirement: GPX export
The Journal SHALL allow users to download any route as a GPX file.
#### Scenario: Export route as GPX
- **WHEN** a user clicks "Export GPX" on a route detail page
- **THEN** a GPX file is downloaded containing the route track and waypoints
### Requirement: Route versioning
The Journal SHALL store sequential versions of each route. Each GPX update creates a new version.
#### Scenario: View version history
- **WHEN** a route owner views the route detail page
- **THEN** they see a list of versions with version number, date, and contributor
### Requirement: Route list
The Journal SHALL display a list of the authenticated user's routes.
#### Scenario: View my routes
- **WHEN** a logged-in user navigates to their route list
- **THEN** they see all their routes with name, distance, and last updated date
### Requirement: PostGIS spatial storage
Route geometries SHALL be stored as PostGIS LineString geometries extracted from the GPX.
#### Scenario: Spatial data stored on import
- **WHEN** a GPX file is imported or a route is saved from the Planner
- **THEN** the route geometry is extracted and stored as a PostGIS LineString for future spatial queries
### Requirement: Route metadata envelope
Routes SHALL be stored with a metadata envelope containing computed statistics (distance, elevation gain/loss), routing profile, contributor list, and tags.
#### Scenario: Metadata computed on save
- **WHEN** a route GPX is saved
- **THEN** distance and elevation statistics are computed from the GPX and stored in the metadata

View file

@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Shared types package
The `@trails-cool/types` package SHALL export TypeScript interfaces for Route, Activity, Waypoint, RouteVersion, and RouteMetadata used by both apps.
#### Scenario: Import types in Planner
- **WHEN** the Planner app imports `@trails-cool/types`
- **THEN** it has access to the Waypoint, Route, and RouteMetadata interfaces
#### Scenario: Import types in Journal
- **WHEN** the Journal app imports `@trails-cool/types`
- **THEN** it has access to Route, Activity, RouteVersion, and RouteMetadata interfaces
### Requirement: GPX parsing package
The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoints, tracks, elevation) and generate GPX XML from structured data.
#### Scenario: Parse GPX to waypoints
- **WHEN** the gpx package parses a valid GPX file
- **THEN** it returns an array of Waypoint objects with lat, lon, and optional name
#### Scenario: Generate GPX from waypoints
- **WHEN** the gpx package is given an array of waypoints and a track
- **THEN** it generates a valid GPX XML string
#### Scenario: Extract elevation data
- **WHEN** the gpx package parses a GPX file with elevation data
- **THEN** it returns elevation gain, loss, and a profile array of distance/elevation pairs
### Requirement: Map rendering package
The `@trails-cool/map` package SHALL provide React components for rendering Leaflet maps with configurable base layers and route overlays.
#### Scenario: Render map component
- **WHEN** the map package's MapView component is rendered with a center and zoom
- **THEN** a Leaflet map is displayed with the default OSM tile layer
#### Scenario: Display route on map
- **WHEN** the map package's RouteLayer component receives GeoJSON
- **THEN** it renders a polyline on the map
### Requirement: UI component package
The `@trails-cool/ui` package SHALL provide shared React components (buttons, layout, form elements) styled with Tailwind CSS.
#### Scenario: Use Button component
- **WHEN** an app renders the Button component from `@trails-cool/ui`
- **THEN** a styled button is displayed consistent with the trails.cool design
### Requirement: i18n package
The `@trails-cool/i18n` package SHALL provide react-i18next configuration and translation strings starting with English and German.
#### Scenario: Display German translation
- **WHEN** a user's browser locale is set to German
- **THEN** UI strings are displayed in German
#### Scenario: Fallback to English
- **WHEN** a user's browser locale is not supported
- **THEN** UI strings fall back to English