Add 8 proposed changes and old trails analysis

Proposed changes (all with proposal, design, specs, tasks):
- app-navigation (9 tasks) — nav bars for both apps
- planner-landing-page (7 tasks) — standalone landing page
- planner-multiplayer-awareness (13 tasks) — participants, cursors, names
- changelog (13 tasks) — public changelog with "what's new"
- transactional-emails (15 tasks) — magic link + welcome emails
- planner-features (18 tasks) — no-go areas, notes, recovery, rate limits
- komoot-import (23 tasks) — Komoot tour import
- route-features (37 tasks) — sharing, multi-day, spatial, photos

Also adds docs/old-trails-analysis.md with feature analysis from the
older trails project.

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

235
docs/old-trails-analysis.md Normal file
View file

@ -0,0 +1,235 @@
# Feature Analysis: /Users/ullrich/Projects/trails
Analysis of the older trails Remix app for features and patterns that could be
ported or adapted for trails-cool. Organized by category with implementation
references.
## Social Features
### Reactions/Kudos System
Allow users to react to activities with configurable reaction types (currently
"kudos"). Generic implementation supports any target type (profile, activity,
etc.). UI shows reaction counts on activity cards.
- `drizzle/models/social-interactions.ts`
- `app/routes/api+/activities.$activityId.reactions.ts`
### Comments/Replies
Thread-based comment system on activities. Comments stored as social
interactions with "reply" kind. Shows reply counts alongside reactions.
- `app/routes/api+/activities.$activityId.replies.ts`
- `app/routes/feed.tsx`
### User Following
Follow/unfollow user profiles with mutual follow tracking and uniqueness
constraints. Can be extended to follow specific routes or activities.
- `app/routes/api+/follows.ts`
- `app/utils/social.server.ts`
### Personalized Activity Feed
Feed showing activities from followed users, filtered by visibility (public,
followers-only, private). Shows interaction counts (reactions, replies).
- `app/routes/feed.tsx`
- `app/utils/social.server.ts`
## Import & Integration
### Activity Import from External Services
Komoot integration for importing tours. Batch import tracking with status
(queued, running, completed, failed). Automatic deduplication using dedupeKey
prevents duplicate imports. Detailed statistics (totalFound, importedCount,
duplicateCount).
- `app/inngest/importActivities.ts`
- `app/utils/integrations.server.ts`
### Third-Party Sync Jobs
Background Inngest jobs for syncing external platforms. Handles credential
validation and error recovery. Tracks lastSyncedAt for incremental syncing.
- `app/inngest/komootSync.ts`
### Integration Connections Management
Store encrypted credentials for multiple service integrations. Track connection
status and last sync time. Support for multiple users connecting the same
integration independently.
- `app/routes/integrations.tsx`
- `app/routes/api+/integrations.ts`
## Route Planning
### Ride Plan Scheduling
Schedule rides with timezone-aware start/end times. Privacy levels
(followers-only, private, public). Group ride flag for collaborative planning.
- `drizzle/models/ride-plans.ts`
### Route Geometry Encoding
Encoded polyline storage for efficient geometry. Route metrics: distance,
elevation gain, origin, destination. Multiple routes per ride plan.
- `drizzle/models/ride-routes.ts`
- `app/components/ride-plans/route-editor.tsx`
### Ride Plan Participants
Invite users to group rides. Track participation status (invited, accepted,
declined). Enables collaborative ride planning.
- `drizzle/models/ride-plan-participants.ts`
## Activity Tracking
### Live Activity Recording
Start/pause/finish live recordings. Records start time, end time, duration,
distance, elevation gain. Manual form completion for recorded activities.
Activity status tracking (recording, paused, completed).
- `app/components/activities/activity-recorder.tsx`
- `app/routes/api+/activities.recordings.start.ts`
### Activity Visibility Levels
Public, followers-only, and private visibility options. Source tracking (native
recording vs. import). Link activities to specific ride plans and routes.
- `drizzle/models/ride-activities.ts`
## User Profile & Content
### Profile Images
S3-compatible storage for profile pictures. Signed PUT requests for direct
client uploads. Image serving via openimg library for optimization.
- `app/utils/storage.server.ts`
- `drizzle/models/user-images.ts`
### User Notes/Blog
Rich note creation with title and markdown content. Image attachments to notes
(up to 5 images per note). Note listing and detail views. Image form validation
(3MB max per file).
- `drizzle/models/notes.ts`
- `app/routes/users+/$username_+/notes.tsx`
### Note Images
Upload and manage images within notes. Alt text support for accessibility. S3
storage with organized paths.
- `drizzle/models/note-images.ts`
- `app/routes/users+/$username_+/__note-editor.tsx`
## Search & Discovery
### User Search
Full-text search over username and display name. Pagination with limit of 50
results. Search results with profile images. Case-insensitive matching.
- `app/routes/users+/index.tsx`
### Search Bar Component
Debounced auto-submit search. Reusable search UI component. Query parameter
preservation.
- `app/components/search-bar.tsx`
## Authentication & Settings
### Two-Factor Authentication
TOTP-based 2FA on top of passkeys. Settings UI for enabling/disabling.
Verification flow.
- `app/routes/settings+/profile.two-factor.tsx`
### Email Verification
Email-based account verification. Verification token tracking. Email change
flow with verification.
- `drizzle/models/verifications.ts`
### Profile Management
Change email, password, name. Photo upload. Passkey management. Connected
OAuth providers (GitHub).
- `app/routes/settings+/profile.tsx`
## Federation
### ActivityPub Foundation
Inbox/outbox endpoints for federated social network. Background publishing via
Inngest. Federation record tracking for local/federated entity mapping.
- `app/routes/api+/activitypub.inbox.ts`
- `app/routes/api+/activitypub.outbox.ts`
## Data Management
### Activity Files Association
Attach files/media to activities. Track file metadata and relationships.
- `drizzle/models/activity-files.ts`
### Import Batch Tracking
Comprehensive import job tracking. Status lifecycle: queued, running,
completed, failed. Timing and statistics per batch. Progress monitoring in UI.
- `drizzle/models/import-batches.ts`
## Architectural Patterns
### Role-Based Access Control
User roles and permission system. Basis for admin controls. Permission groups
for feature access.
- `drizzle/models/roles.ts`
- `drizzle/models/permissions.ts`
### Background Job Queue (Inngest)
Event-driven async processing. Reliable job retry with exponential backoff.
Step-based workflow for complex operations. Progress monitoring and error
handling.
- `app/inngest/`
### Deduplication Strategy
Unique composite keys (ownerId + dedupeKey) for imports. onConflictDoNothing
pattern prevents duplicates. Useful for imports from multiple sources.
- `drizzle/models/ride-activities.ts`
## UX/Component Patterns
### Status Button
Button that displays async operation states (idle, pending, success, error).
Useful for form submissions and API calls.
- `app/components/ui/status-button.tsx`
### Form Validation (Conform + Zod)
Client/server validation with Conform.js and Zod. Constraint generation from
schemas. Field error display and form recovery.
- `app/components/forms.tsx`
## Priority Suggestions for trails-cool
**High (directly applicable):**
- Reactions/Kudos — simple engagement, minimal schema
- Comments — essential social feature
- Following — core for Phase 2 federation
- Live Activity Recording — core outdoor feature
- User Search — discovery
**Medium (enhances existing):**
- Profile images — user experience
- Activity visibility levels — privacy controls
- Import from Komoot/Strava — integration value
- Ride plan participants — collaborative planning
**Lower (future):**
- Notes/Blog — content creation
- 2FA — security hardening
- Role-based access — admin features
- Background jobs (Inngest pattern) — already have infra for it

View file

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

View file

@ -0,0 +1,40 @@
## Context
Navigation audit found: Journal's `/routes` and `/activities` pages are
orphaned (no links point to them). The Planner home page has no CTA. Logout
is only on the profile page. Users must know URLs to use the apps.
## Goals / Non-Goals
**Goals:**
- Every non-API route reachable via UI navigation
- Logged-in Journal users see: Routes, Activities, profile, logout
- Logged-out Journal users see: login, register
- Planner home has a clear "start planning" action
- Keep it minimal — a thin bar, not a complex sidebar
**Non-Goals:**
- Mobile hamburger menu (responsive PR already hides sidebar — nav bar wraps naturally)
- Breadcrumbs or nested navigation
- Settings page (future)
## Decisions
### D1: Journal nav bar in root Layout
Add a `<nav>` element inside `Layout` in `root.tsx`. The root loader already
returns the user (added for Sentry context), so the nav bar can conditionally
show authenticated vs. unauthenticated links. No new loader needed.
### D2: Planner gets a CTA on home, not a nav bar
The Planner is session-focused — users arrive, create a session, and work in
it. A full nav bar would be overhead. Instead: home page gets a prominent
"Start Planning" button linking to `/new`. Session view already has a header —
add a small logo/home link to it.
### D3: Use @trails-cool/ui for shared nav component
The nav bar itself is Journal-specific (routes, activities, auth). No need
to put it in the shared UI package — it goes directly in Journal's root.tsx
or a colocated component.

View file

@ -0,0 +1,33 @@
## Why
Both apps have pages that are only reachable by typing URLs. The Journal has no
navigation bar — logged-in users can't find their routes or activities without
knowing the URLs. The Planner home page is a dead end with no way to create a
session. This makes the apps unusable for anyone who doesn't know the URL
structure.
## What Changes
- **Journal**: Add a navigation bar (home, routes, activities, profile, logout)
that appears when logged in. Unauthenticated users see login/register links.
- **Planner**: Add a "New Session" CTA on the home page. Add a minimal header
in session view with a link back to home.
- Both apps get consistent, minimal navigation that makes all features
discoverable.
## Capabilities
### New Capabilities
(None — this adds UI chrome to existing features, no new behavioral capabilities.)
### Modified Capabilities
- `map-display`: Planner home page gains a CTA and session header gains a home link
## Impact
- **Files**: Journal `root.tsx` (nav bar), Planner `home.tsx` (CTA),
Planner `SessionView.tsx` (home link), new `NavBar` component
- **Dependencies**: None
- **i18n**: New translation keys for navigation labels

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Planner home page
The Planner home page SHALL provide a clear call-to-action to create a new planning session and a link back to the home page from within sessions.
#### Scenario: Home page CTA
- **WHEN** a user visits the Planner home page
- **THEN** a prominent "Start Planning" button is visible that links to `/new`
#### Scenario: Session home link
- **WHEN** a user is in a planning session
- **THEN** the header contains a link back to the Planner home page

View file

@ -0,0 +1,18 @@
## 1. Journal Navigation Bar
- [ ] 1.1 Add nav bar to Journal root Layout — show "Routes", "Activities" links when logged in; "Login", "Register" when logged out
- [ ] 1.2 Add user menu to nav bar — username/display name linking to profile, logout button
- [ ] 1.3 Highlight active nav item based on current route
- [ ] 1.4 Add i18n keys for nav labels (en + de): routes, activities, profile, logout
## 2. Planner Navigation
- [ ] 2.1 Add "Start Planning" CTA button on Planner home page linking to `/new`
- [ ] 2.2 Add home link (logo or text) to SessionView header
- [ ] 2.3 Add i18n keys for planner nav (en + de): startPlanning, home
## 3. Verify
- [ ] 3.1 Verify all Journal routes are reachable via navigation (routes, activities, profile, auth, privacy)
- [ ] 3.2 Verify Planner home → new session → back to home works
- [ ] 3.3 Update E2E tests if navigation changes affect existing selectors

View file

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

View file

@ -0,0 +1,65 @@
## Context
trails.cool ships features regularly but has no user-facing changelog. The
project philosophy values transparency and simplicity. We need a changelog
that's low-friction to write (just add a markdown file), works in-app, and
produces shareable social links.
## Goals / Non-Goals
**Goals:**
- Zero-friction authoring: drop a `.md` file, deploy, done
- Public page at `/changelog` listing all entries newest-first
- Individual entry pages at `/changelog/:slug` with OG meta for social sharing
- "What's New" indicator in nav for returning users
- Works without JavaScript (SSR)
**Non-Goals:**
- CMS or admin interface for writing entries
- RSS feed (future, not now)
- Email notifications for new entries
- Per-entry images or rich media (just markdown text)
- Bilingual entries (English only for now)
## Decisions
### D1: Markdown files in the repo, loaded at build time
Changelog entries live in `apps/journal/changelog/` as markdown files named
by date: `2026-03-25.md`. Vite's `import.meta.glob` loads them at build time.
No runtime file system access needed.
Each file has YAML frontmatter:
```yaml
---
title: "Collaborative route planning goes live"
date: 2026-03-25
---
```
### D2: Single route with dynamic slug
`/changelog` shows all entries. `/changelog/:date` shows a single entry.
Both are one route file using an optional param or two route files. The list
page shows titles + dates + first paragraph preview.
### D3: "What's New" via localStorage timestamp
Store `changelog:lastSeen` timestamp in localStorage. If the newest entry's
date is after this timestamp, show a dot on the nav "Changelog" link. Clicking
the changelog page updates the timestamp. No server state needed.
### D4: Open Graph meta for social sharing
Each `/changelog/:date` page sets `og:title`, `og:description` (first
paragraph), and `og:url`. No og:image for now — text previews are fine.
## Risks / Trade-offs
- **Build-time loading means deploy to publish** → Acceptable. We deploy on
every merge to main. Adding a changelog entry is: write file, PR, merge,
deployed.
- **No rich media** → Keeps it simple. Can add images later if needed.
- **localStorage "what's new" doesn't sync across devices** → Fine for now.
Server-side tracking would require auth and a DB table — overkill.

View file

@ -0,0 +1,38 @@
## Why
We ship features but nobody knows. There's no record of what changed, no way to
share progress, and no way for users to discover new features. A changelog
serves three audiences: existing users (what's new), potential users (the
project is alive and improving), and social followers (shareable updates).
## What Changes
- Add a `/changelog` page in the Journal showing dated entries with feature
descriptions
- Changelog entries stored as markdown files in the repo (not a database) —
low friction, version controlled, works with the PR workflow
- Each entry renders as a standalone shareable page (`/changelog/2026-03-25`)
with Open Graph meta tags for social previews
- A "What's New" badge/dot on the nav bar when there are entries the user
hasn't seen
- The changelog is public (no auth required) — it's part of the product's
public face
## Capabilities
### New Capabilities
- `changelog`: Public changelog with dated entries, shareable individual pages with OG tags, "what's new" indicator
### Modified Capabilities
(None)
## Impact
- **Files**: New routes (`/changelog`, `/changelog/:slug`), markdown entry files
in `docs/changelog/` or `apps/journal/changelog/`, nav bar update
- **Dependencies**: A markdown renderer (could reuse `react-markdown` or render
at build time)
- **i18n**: Changelog entries written in English (primary audience), German
later if needed

View file

@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Changelog listing page
The system SHALL provide a public `/changelog` page showing all entries newest-first.
#### Scenario: View changelog
- **WHEN** a user visits `/changelog`
- **THEN** all changelog entries are listed with date, title, and preview text
### Requirement: Individual entry pages
Each changelog entry SHALL have a shareable page at `/changelog/:date`.
#### Scenario: View single entry
- **WHEN** a user visits `/changelog/2026-03-25`
- **THEN** the full markdown content of that entry is rendered
#### Scenario: Social sharing meta
- **WHEN** a changelog entry page is loaded
- **THEN** Open Graph meta tags are set (og:title, og:description, og:url)
### Requirement: Markdown file authoring
Changelog entries SHALL be authored as markdown files with YAML frontmatter in the repository.
#### Scenario: Add new entry
- **WHEN** a developer adds a `.md` file to the changelog directory with date frontmatter
- **THEN** it appears on the changelog page after the next deploy
### Requirement: What's New indicator
The system SHALL show a visual indicator when there are changelog entries the user hasn't seen.
#### Scenario: New entry available
- **WHEN** a user visits the app and a changelog entry is newer than their last visit to /changelog
- **THEN** a dot or badge appears on the "Changelog" nav link
#### Scenario: Indicator dismissed
- **WHEN** a user visits the /changelog page
- **THEN** the indicator is dismissed (localStorage timestamp updated)

View file

@ -0,0 +1,29 @@
## 1. Changelog Data Layer
- [ ] 1.1 Create `apps/journal/changelog/` directory with a sample entry (`2026-03-25.md`) covering the initial launch
- [ ] 1.2 Set up Vite `import.meta.glob` to load all `.md` files from the changelog directory with frontmatter parsing
- [ ] 1.3 Create `apps/journal/app/lib/changelog.server.ts` with functions: getAllEntries(), getEntry(date) returning parsed markdown + frontmatter
## 2. Routes
- [ ] 2.1 Add `/changelog` route showing all entries (date, title, preview) newest-first
- [ ] 2.2 Add `/changelog/:date` route rendering full markdown entry
- [ ] 2.3 Add Open Graph meta tags on entry pages (og:title, og:description, og:url)
- [ ] 2.4 Add both routes to `routes.ts`
## 3. What's New Indicator
- [ ] 3.1 Add "Changelog" link to Journal nav bar
- [ ] 3.2 Track `changelog:lastSeen` in localStorage, show dot when newest entry is newer
- [ ] 3.3 Clear indicator when user visits /changelog
## 4. Content
- [ ] 4.1 Write initial changelog entry for the launch (features shipped in Phase 1)
- [ ] 4.2 Add markdown rendering (react-markdown or built-in) for entry content
## 5. Verify
- [ ] 5.1 Verify /changelog lists entries correctly
- [ ] 5.2 Verify /changelog/:date renders full entry with OG tags
- [ ] 5.3 Verify "What's New" dot appears and dismisses

View file

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

View file

@ -0,0 +1,72 @@
## Context
trails.cool Journal supports manual route creation and GPX import. Users coming
from Komoot have hundreds of tours they'd lose by switching. The old trails
project had a working Komoot integration using basic auth against Komoot's
undocumented API (`api.komoot.de`).
## Goals / Non-Goals
**Goals:**
- Connect Komoot account via email + password
- Import all tours (paginated) as activities + routes
- Track import progress with batch status
- Deduplicate on re-import (same tour never imported twice)
- Fetch GPX geometry per tour (not just metadata)
**Non-Goals:**
- OAuth flow (Komoot has no public OAuth — basic auth only)
- Real-time sync or webhook-based updates
- Strava/other providers (future iteration, but design the schema generically)
- Background job queue like Inngest (keep it simple — synchronous import with progress)
## Decisions
### D1: Generic integrations table with provider column
Store connections in a `journal.integrations` table with a `provider` enum
(`komoot`, and later `strava` etc). Credentials encrypted at rest. This avoids
a separate table per provider.
### D2: Import batches for progress tracking
Each import creates an `import_batches` row tracking: status (running,
completed, failed), total found, imported count, duplicate count, error message.
The UI polls this for progress.
### D3: Deduplication via composite key
Activities get a `dedupeKey` column. For Komoot: `komoot:{tourId}`. Combined
with `ownerId`, a unique constraint prevents duplicates. Insert uses
`onConflictDoNothing`.
### D4: Synchronous import with streaming progress
No background job queue. The import runs in an API route action that:
1. Fetches all tour pages from Komoot
2. For each tour, fetches GPX and creates activity + route
3. Updates the batch row with progress
4. Client polls `/api/integrations/komoot/import-status` for live updates
This keeps the architecture simple. If imports are too slow (>100 tours), we
can add a background worker later.
### D5: Encrypt credentials with AES-256-GCM
Use Node's `crypto.createCipheriv` with a server-side key derived from
`INTEGRATION_SECRET` env var. Decrypt on use, never log plaintext.
**Alternative considered**: Store only the API token (not email+password).
Rejected because the token may expire and re-auth requires the original
credentials.
## Risks / Trade-offs
- **Komoot API is undocumented** → Could break without notice. Mitigation: wrap
all API calls in error handling, mark integration as "needs reauth" on 401.
- **Storing user passwords for third-party service** → Security risk.
Mitigation: AES-256-GCM encryption, separate `INTEGRATION_SECRET`, document
in privacy manifest.
- **Synchronous import may timeout for large accounts** → Mitigation: paginate
and commit per-page. If a page fails, the batch is marked partial and can be
resumed.

View file

@ -0,0 +1,32 @@
## Why
Users switching to trails.cool have years of tours on Komoot. Without import,
they start with an empty Journal — no history, no motivation to switch. A
one-click Komoot import lets users bring their existing tours and start using
trails.cool immediately.
## What Changes
- Add Komoot account connection flow (email + password → API credentials)
- Import Komoot tours as Journal activities with route data
- Batch import with progress tracking, deduplication, and error handling
- Integration settings page to manage connected accounts and trigger imports
- Fetch full tour GPX (not just metadata) so imported routes have geometry
## Capabilities
### New Capabilities
- `komoot-import`: Connect a Komoot account, import tours as activities with routes, track import progress, deduplicate on re-import
### Modified Capabilities
- `route-management`: Routes can now be created via import (not just manual creation), with an external source tracking field
## Impact
- **Database**: New tables for integration connections and import batches in `journal` schema
- **Files**: New routes (`/integrations`, `/api/integrations/*`), new server utilities for Komoot API, new DB schema tables
- **Dependencies**: No new packages needed — uses fetch for Komoot API, existing Drizzle for DB
- **Privacy**: Komoot credentials stored encrypted. Must be documented in privacy manifest.
- **External**: Komoot API (api.komoot.de) — basic auth, no official developer program

View file

@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Connect Komoot account
Users SHALL be able to connect their Komoot account by providing email and password. Credentials are validated against the Komoot API and stored encrypted.
#### Scenario: Successful connection
- **WHEN** user enters valid Komoot email and password on the integrations page
- **THEN** the system validates credentials via `api.komoot.de`, stores them encrypted, and shows the connection as active
#### Scenario: Invalid credentials
- **WHEN** user enters incorrect Komoot credentials
- **THEN** the system shows an error message and does not store credentials
#### Scenario: Disconnect account
- **WHEN** user disconnects their Komoot integration
- **THEN** stored credentials are deleted and no further imports can occur
### Requirement: Import Komoot tours
Users SHALL be able to import all their Komoot tours as Journal activities with linked routes.
#### Scenario: Import all tours
- **WHEN** user clicks "Import" on the integrations page
- **THEN** the system fetches all tours (paginated), creates an activity and route for each, and shows progress
#### Scenario: Tour with GPX geometry
- **WHEN** a Komoot tour is imported
- **THEN** the system fetches the tour's GPX/geometry and creates a route with the full track data
#### Scenario: Import progress
- **WHEN** an import is running
- **THEN** the UI shows: total tours found, imported so far, duplicates skipped, and current status
### Requirement: Deduplication
The system SHALL NOT create duplicate activities when re-importing tours that were previously imported.
#### Scenario: Re-import skips existing tours
- **WHEN** user runs import and a tour was already imported previously
- **THEN** the tour is skipped and counted as a duplicate in the import summary
### Requirement: Import batch tracking
Each import run SHALL be tracked as a batch with status and statistics.
#### Scenario: Batch lifecycle
- **WHEN** an import starts
- **THEN** a batch record is created with status "running", and updated to "completed" or "failed" when done
#### Scenario: Batch statistics
- **WHEN** an import completes
- **THEN** the batch shows: totalFound, importedCount, duplicateCount, and duration
### Requirement: Credential encryption
Komoot credentials SHALL be encrypted at rest using AES-256-GCM.
#### Scenario: Credentials stored securely
- **WHEN** a user connects their Komoot account
- **THEN** the password is encrypted before storage and only decrypted when making API calls
### Requirement: Privacy disclosure
The Komoot integration SHALL be documented in the privacy manifest.
#### Scenario: Privacy manifest updated
- **WHEN** Komoot import is available
- **THEN** the /privacy page documents: what credentials are stored, what data is imported, and that credentials are encrypted

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Route creation
The system SHALL support creating routes via import from external services, in addition to manual creation and GPX upload.
#### Scenario: Route created from import
- **WHEN** a Komoot tour is imported
- **THEN** a route is created with name, distance, elevation, GPX geometry, and a source field indicating "komoot"
#### Scenario: Route links to activity
- **WHEN** a tour is imported as an activity
- **THEN** the activity is linked to the created route

View file

@ -0,0 +1,51 @@
## 1. Database Schema
- [ ] 1.1 Add `journal.integrations` table (id, userId, provider enum, encryptedCredentials, apiUsername, status, lastSyncedAt, createdAt)
- [ ] 1.2 Add `journal.import_batches` table (id, userId, integrationId, status enum, totalFound, importedCount, duplicateCount, errorMessage, startedAt, completedAt)
- [ ] 1.3 Add `dedupeKey` column to `journal.activities` table with unique constraint on (ownerId, dedupeKey)
- [ ] 1.4 Add `source` column to `journal.routes` table (nullable, e.g. "komoot", "manual", "gpx-upload")
- [ ] 1.5 Run `pnpm db:push` and verify schema locally
## 2. Credential Encryption
- [ ] 2.1 Create `apps/journal/app/lib/crypto.server.ts` with AES-256-GCM encrypt/decrypt using `INTEGRATION_SECRET` env var
- [ ] 2.2 Write unit tests for encrypt/decrypt roundtrip
## 3. Komoot API Client
- [ ] 3.1 Create `apps/journal/app/lib/komoot.server.ts` with login function (email + password → username + token)
- [ ] 3.2 Add fetchTours function (paginated, fetches all pages)
- [ ] 3.3 Add fetchTourGpx function (fetch GPX geometry for a single tour)
- [ ] 3.4 Write unit tests for API response parsing (mock fetch)
## 4. Import Logic
- [ ] 4.1 Create `apps/journal/app/lib/import.server.ts` with importKomootTours function that: creates batch, pages through tours, fetches GPX, creates activities + routes, deduplicates, updates batch
- [ ] 4.2 Write integration test for import with mock Komoot responses
## 5. API Routes
- [ ] 5.1 Create `POST /api/integrations/komoot/connect` — validate credentials, store encrypted
- [ ] 5.2 Create `POST /api/integrations/komoot/disconnect` — delete credentials
- [ ] 5.3 Create `POST /api/integrations/komoot/import` — trigger import, return batch ID
- [ ] 5.4 Create `GET /api/integrations/komoot/import-status` — return current batch progress
## 6. UI
- [ ] 6.1 Create `/integrations` route with Komoot connection form (email + password)
- [ ] 6.2 Show connected status, last sync time, and import button when connected
- [ ] 6.3 Import progress UI — poll import-status, show counts (found, imported, duplicated)
- [ ] 6.4 Add link to integrations page from Journal navigation
- [ ] 6.5 Add i18n keys for all integration strings (en + de)
## 7. Privacy & Config
- [ ] 7.1 Update /privacy page to document Komoot integration (credentials stored encrypted, what data is imported)
- [ ] 7.2 Add `INTEGRATION_SECRET` env var to docker-compose.yml and CI
- [ ] 7.3 Add `INTEGRATION_SECRET` to deploy secrets documentation
## 8. Verify
- [ ] 8.1 Test full flow locally: connect Komoot → import tours → verify activities + routes created
- [ ] 8.2 Verify deduplication: re-import and confirm no duplicates
- [ ] 8.3 Verify disconnect removes credentials

View file

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

View file

@ -0,0 +1,74 @@
## Context
The Planner architecture defines noGoAreas, notes, and crash recovery in the
Yjs data model but none are implemented. The public instance also lacks rate
limiting — anyone can create unlimited sessions or hammer BRouter.
## Goals / Non-Goals
**Goals:**
- Draw no-go polygons on the map, synced via Yjs, sent to BRouter
- Shared notes (Y.Text) visible in sidebar for planning discussion
- localStorage backup of Yjs state, merged on reconnect
- Rate limits: 10 sessions/IP/hour, 60 BRouter calls/session/hour
**Non-Goals:**
- Complex polygon editing (holes, multi-polygon) — just simple polygons
- Rich text in notes (just plain text)
- Cross-tab Yjs sync via localStorage (handled by WebSocket)
- Rate limiting dashboard or admin UI
## Decisions
### D1: No-go areas as Yjs Y.Array of polygon coordinates
```typescript
// Yjs doc structure addition
noGoAreas: Y.Array<Y.Map<{
points: Array<{lat: number, lon: number}>,
name?: string
}>>
```
Each polygon is a closed ring of lat/lon points. BRouter accepts no-go areas
as a `nogo` parameter: `lon,lat,radius` for circles or polygon coordinates
depending on the BRouter version. We'll convert our polygons to BRouter's
expected format in the routing request.
### D2: Leaflet.draw for polygon drawing
Use `leaflet-draw` (or `@geoman-io/leaflet-geoman-free`) for drawing polygons
on the map. When a polygon is completed, add it to the Y.Array. Existing
polygons render as semi-transparent red overlays.
### D3: Notes as Y.Text in sidebar tab
Add a "Notes" tab to the sidebar (alongside waypoints). Uses Y.Text for
collaborative editing — simple textarea bound to Y.Text. Changes sync in
real-time. No markdown rendering — plain text only.
### D4: localStorage crash recovery
Every 10 seconds, save `Y.encodeStateAsUpdate(doc)` to
`localStorage['trails:session:${id}']`. On page load, if localStorage has
state for the current session, apply it as an update before connecting to
the server. Yjs CRDTs merge automatically — no conflicts.
Clear localStorage entry when session is closed or after successful sync.
### D5: In-memory rate limiter
Simple Map-based rate limiter in `server.ts`:
- Session creation: Track IP → count + window. Reject with 429 after 10/hour.
- BRouter proxy: Track session ID → count + window. Reject after 60/hour.
- No Redis needed — single server, in-memory is fine. Resets on restart
(acceptable).
## Risks / Trade-offs
- **leaflet-draw adds bundle size** → ~50KB gzipped. Acceptable for polygon
drawing functionality. Can lazy-load.
- **localStorage size limit** → ~5MB per origin. Yjs state is typically
10-100KB. No risk.
- **In-memory rate limiter resets on deploy** → Acceptable for current scale.
Redis-backed when needed.

View file

@ -0,0 +1,40 @@
## Why
The Planner's core editing works but is missing features from the architecture
doc: no-go areas for route avoidance, session notes for collaborative planning
communication, localStorage crash recovery for unsaved work, and rate limiting
to protect the BRouter API from abuse on the public instance.
## What Changes
- **No-go areas**: Draw polygons on the map that BRouter avoids when computing
routes. Stored as Y.Array in the Yjs doc, synced across participants.
- **Session notes**: Shared text area (Y.Text) for participants to leave notes,
discuss the route, or plan logistics. Visible in the sidebar.
- **Crash recovery**: Periodically save Yjs state to localStorage. On
reconnect, merge local state with server state to recover unsaved changes.
- **Rate limiting**: Limit session creation per IP and BRouter API calls per
session to prevent abuse on the public instance.
## Capabilities
### New Capabilities
- `no-go-areas`: Draw avoidance polygons on the Planner map, passed to BRouter as routing constraints
- `session-notes`: Shared collaborative text in Planner sessions via Yjs Y.Text
- `crash-recovery`: localStorage backup of Yjs state for reconnection after browser crash
- `rate-limiting`: IP-based limits on session creation and route computation
### Modified Capabilities
- `planner-session`: Session data model gains noGoAreas and notes fields
- `brouter-integration`: Routing requests include no-go area polygons
- `map-display`: Map gains polygon drawing tools for no-go areas
## Impact
- **Planner Yjs doc**: New fields (noGoAreas: Y.Array, notes: Y.Text)
- **BRouter API**: No-go areas passed as `nogo` parameter
- **Map**: Leaflet.draw or custom polygon tool for no-go areas
- **Dependencies**: May need `leaflet-draw` for polygon editing
- **Server**: Rate limiting middleware (in-memory store)

View file

@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: BRouter routing with constraints
Route computation SHALL include no-go area polygons as avoidance constraints.
#### Scenario: Route with no-go areas
- **WHEN** the routing host computes a route and no-go areas exist
- **THEN** the BRouter request includes nogo parameters for each polygon

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: localStorage crash recovery
The Planner SHALL periodically save Yjs state to localStorage and recover it on reconnect.
#### Scenario: Browser crash recovery
- **WHEN** a user's browser crashes and they reopen the session
- **THEN** unsaved changes from localStorage are merged with the server state
#### Scenario: Clean exit
- **WHEN** a session syncs successfully
- **THEN** the localStorage backup is cleared

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Map polygon drawing
The Planner map SHALL support drawing and displaying no-go area polygons.
#### Scenario: Polygon tool
- **WHEN** a user activates the no-go area tool
- **THEN** they can draw a polygon by clicking points on the map
#### Scenario: Polygon display
- **WHEN** no-go areas exist in the session
- **THEN** they are rendered as semi-transparent red polygons on the map

View file

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Draw no-go areas
Users SHALL be able to draw polygons on the map that BRouter avoids when computing routes.
#### Scenario: Draw polygon
- **WHEN** a user activates the no-go area tool and draws a polygon on the map
- **THEN** the polygon is added to the Yjs doc and visible to all participants
#### Scenario: Route avoids no-go area
- **WHEN** a route is computed and a no-go area intersects the direct path
- **THEN** BRouter routes around the no-go area
#### Scenario: Delete no-go area
- **WHEN** a user deletes a no-go area polygon
- **THEN** it is removed from the Yjs doc and the route is recomputed

View file

@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: Planner session data model
The Yjs document SHALL include noGoAreas and notes fields alongside waypoints and routeData.
#### Scenario: Session with all fields
- **WHEN** a Planner session is active
- **THEN** the Yjs doc contains: waypoints (Y.Array), routeData (Y.Map), noGoAreas (Y.Array), notes (Y.Text)

View file

@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: Session creation rate limit
The Planner SHALL limit session creation to 10 per IP per hour.
#### Scenario: Rate limit exceeded
- **WHEN** an IP creates more than 10 sessions in one hour
- **THEN** the server responds with 429 Too Many Requests
### Requirement: BRouter call rate limit
The Planner SHALL limit route computations to 60 per session per hour.
#### Scenario: Routing rate limit exceeded
- **WHEN** a session exceeds 60 BRouter calls in one hour
- **THEN** the server responds with 429 and the client shows a "slow down" message

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Collaborative session notes
Planner sessions SHALL have a shared text area for participants to write notes.
#### Scenario: Write notes
- **WHEN** a user types in the notes area
- **THEN** the text syncs in real-time to all other participants via Yjs
#### Scenario: Notes persist
- **WHEN** a user leaves and rejoins a session
- **THEN** the notes are still there (stored in Yjs doc)

View file

@ -0,0 +1,36 @@
## 1. No-Go Areas
- [ ] 1.1 Add `noGoAreas` Y.Array to Yjs doc in use-yjs.ts
- [ ] 1.2 Add leaflet-draw (or geoman) dependency for polygon drawing
- [ ] 1.3 Create NoGoAreaLayer component — renders polygons as red overlays, handles draw/delete
- [ ] 1.4 Add no-go area toolbar button to PlannerMap
- [ ] 1.5 Pass no-go areas to BRouter API as `nogo` parameters in brouter.ts
- [ ] 1.6 Trigger route recomputation when no-go areas change
## 2. Session Notes
- [ ] 2.1 Add `notes` Y.Text to Yjs doc in use-yjs.ts
- [ ] 2.2 Create NotesPanel component — textarea bound to Y.Text with real-time sync
- [ ] 2.3 Add "Notes" tab to sidebar (alongside waypoints)
- [ ] 2.4 Add i18n keys for notes UI (en + de)
## 3. Crash Recovery
- [ ] 3.1 Add periodic localStorage save (every 10s) of Yjs state in use-yjs.ts
- [ ] 3.2 On session reconnect, check localStorage for saved state and apply as Yjs update
- [ ] 3.3 Clear localStorage entry after successful sync or session close
- [ ] 3.4 Write unit test for save/restore logic
## 4. Rate Limiting
- [ ] 4.1 Create rate limiter utility in apps/planner/app/lib/rate-limit.ts (already exists — extend for IP tracking)
- [ ] 4.2 Add session creation rate limit (10/IP/hour) in the /new route or API
- [ ] 4.3 Add BRouter proxy rate limit (60/session/hour) in the route computation handler
- [ ] 4.4 Return 429 with Retry-After header and show user-friendly message on client
## 5. Verify
- [ ] 5.1 Test no-go areas: draw polygon, verify route avoids it, delete polygon
- [ ] 5.2 Test notes: type in two windows, verify real-time sync
- [ ] 5.3 Test crash recovery: make changes, kill browser, reopen — verify recovery
- [ ] 5.4 Test rate limiting: exceed limits, verify 429 response

View file

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

View file

@ -0,0 +1,44 @@
## Context
The Planner home page is currently a centered heading ("trails.cool Planner")
and subtitle. It has no CTA, no explanation, and no way to start. Users must
know to visit `/new` to create a session.
## Goals / Non-Goals
**Goals:**
- Explain what the Planner does in 5 seconds of reading
- One-click session creation (no signup, no form)
- Feature highlights: collaborative, BRouter routing, elevation, GPX export
- Link to Journal for users who want accounts and saved routes
- i18n (English + German)
- Fast load, SSR-friendly, no heavy JS
**Non-Goals:**
- Marketing copy or elaborate graphics
- Pricing or plans
- User testimonials
- Interactive map demo on the landing page (too heavy)
## Decisions
### D1: Single-page component in home.tsx
No new route — rewrite `apps/planner/app/routes/home.tsx`. The page is static
content + a CTA link to `/new`. No loader needed.
### D2: Feature cards with icons
Show 4-5 feature highlights as simple cards: collaborative editing, route
profiles, elevation profile, GPX export, no account needed. Use Tailwind —
no icon library, just emoji or simple SVG.
### D3: Link to Journal as secondary CTA
"Want to save your routes? Create a free account on trails.cool" — links to
the Journal. This bridges the two apps without requiring Journal for basic use.
### D4: Footer with legal and links
Privacy link (points to Journal's /privacy), GitHub repo, "Built with
BRouter and OpenStreetMap" attribution.

View file

@ -0,0 +1,34 @@
## Why
The Planner at planner.trails.cool should work as a standalone product — anyone
can visit, understand what it does, and start planning a route without needing
the Journal. Currently the home page is a blank heading with no explanation and
no way to start. Visitors bounce immediately.
## What Changes
- Replace the Planner home page with a landing page that explains the tool:
collaborative route planning, BRouter routing, GPX export, no account needed
- Prominent "Start Planning" CTA that creates a new session
- Brief feature highlights (collaborative editing, routing profiles, elevation,
GPX export)
- Footer with links to trails.cool Journal, privacy page, GitHub repo
- The landing page should load fast — no heavy assets, SSR-friendly
## Capabilities
### New Capabilities
(None — this replaces an existing page with useful content.)
### Modified Capabilities
- `planner-session`: Home page becomes a landing page with session creation CTA
- `map-display`: Landing page may show a decorative map or screenshot
## Impact
- **Files**: `apps/planner/app/routes/home.tsx` (complete rewrite), i18n keys
- **Dependencies**: None
- **Design**: Needs to look good — this is the first impression for standalone
Planner users

View file

@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Planner home page
The Planner home page SHALL explain the tool's purpose and provide a one-click way to start planning.
#### Scenario: First-time visitor
- **WHEN** a user visits planner.trails.cool for the first time
- **THEN** they see a landing page explaining collaborative route planning, key features, and a prominent "Start Planning" button
#### Scenario: Start a session
- **WHEN** a user clicks "Start Planning"
- **THEN** a new anonymous session is created and the user is redirected to the session view
#### Scenario: Journal link
- **WHEN** a user wants to save routes permanently
- **THEN** a secondary CTA links to trails.cool for account creation

View file

@ -0,0 +1,16 @@
## 1. Landing Page Content
- [ ] 1.1 Rewrite Planner home.tsx with hero section: heading, subtitle, "Start Planning" CTA linking to /new
- [ ] 1.2 Add feature highlights section (4-5 cards): collaborative editing, routing profiles, elevation profile, GPX export, no account needed
- [ ] 1.3 Add secondary CTA: "Save your routes on trails.cool" linking to Journal
- [ ] 1.4 Add footer: privacy link, GitHub repo, BRouter/OSM attribution
## 2. i18n
- [ ] 2.1 Add all landing page strings to en.ts and de.ts (hero, features, CTAs, footer)
## 3. Verify
- [ ] 3.1 Verify landing page renders correctly at planner.trails.cool
- [ ] 3.2 Verify "Start Planning" creates a session and redirects
- [ ] 3.3 Update E2E planner test if home page assertions changed

View file

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

View file

@ -0,0 +1,46 @@
## Context
Yjs awareness already tracks users with `{ color, name }` state and renders
map cursors via `CursorTracker` in `PlannerMap.tsx`. The current implementation
has issues: random names can't be changed, cursor labels are unstyled, and
there's no participant list showing who's in the session.
## Goals / Non-Goals
**Goals:**
- See all participants in the session (name, color, host/participant role)
- Edit your own name (saved to localStorage, synced via awareness)
- Polished cursor rendering on the map
- Visual feedback when someone joins or leaves
**Non-Goals:**
- Chat or messaging between participants
- Cursor position sharing outside the map (e.g., in the sidebar)
- Participant permissions or kicking (admin-only debug feature already exists)
## Decisions
### D1: Participant list in session header
Show colored dots + names in the header bar (already has space between profile
selector and connection status). Clicking your own name opens an inline edit.
Keeps the sidebar free for waypoints.
### D2: Name persisted in localStorage, synced via awareness
Current approach: random name from a list, stored in localStorage as
`trails:user`. Enhancement: let users edit it. On change, update localStorage
AND call `awareness.setLocalStateField("user", { color, name })`. All other
clients see the update immediately.
### D3: Cursor as SVG pointer with name tag
Replace the current `divIcon` cursor label with: a small SVG arrow in the
user's color + a name tag with background, shadow, and rounded corners.
Position offset so the pointer tip is at the actual lat/lng.
### D4: Join/leave toasts
When awareness detects a new client or a client leaving, show a brief toast
("Alice joined" / "Bob left") that auto-dismisses after 3 seconds. Use a
simple absolute-positioned div, no toast library.

View file

@ -0,0 +1,35 @@
## Why
The Planner supports collaborative editing but the multiplayer experience is
bare. Users can't see who else is in the session. Cursor labels are small
floating divs that look broken (no proper styling, overlap with map controls).
Users get randomly assigned names like "Hiker" or "Explorer" with no way to
change them. This makes collaboration confusing — you can't tell who is who.
## What Changes
- **Participant list**: Show who's in the session (name, color, host badge)
in the sidebar or header
- **Name editing**: Let users set their name after joining (persisted in
localStorage, synced via Yjs awareness)
- **Cursor styling**: Fix cursor labels — proper pointer icon, name tag with
shadow, avoid overlap with map controls
- **Connection indicators**: Show when participants join/leave
## Capabilities
### New Capabilities
(None — this enhances existing Yjs awareness features.)
### Modified Capabilities
- `planner-session`: Session view gains participant list and name editing
- `map-display`: Map cursor rendering improved
## Impact
- **Files**: `PlannerMap.tsx` (cursor styling), `SessionView.tsx` (participant
list), `use-yjs.ts` (name editing), new `ParticipantList` component
- **Dependencies**: None
- **i18n**: New keys for participant labels and name editing

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Map cursor rendering
Other participants' cursors on the map SHALL be clearly visible with proper styling.
#### Scenario: Cursor appearance
- **WHEN** another participant moves their mouse on the map
- **THEN** a colored pointer icon with their name tag appears at the cursor position
#### Scenario: Cursor does not obscure map controls
- **WHEN** cursors are rendered
- **THEN** they appear below map controls (zoom, layer switcher) in z-index

View file

@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Session participant awareness
Users in a planning session SHALL see who else is present and be able to identify themselves.
#### Scenario: Participant list visible
- **WHEN** multiple users are in a session
- **THEN** the header shows each participant's name and color
#### Scenario: Host badge
- **WHEN** a participant is the routing host
- **THEN** their entry in the participant list shows a host indicator
#### Scenario: Edit own name
- **WHEN** a user clicks their own name in the participant list
- **THEN** an inline text input appears to change their display name
#### Scenario: Name persisted
- **WHEN** a user changes their name
- **THEN** the name is saved to localStorage and immediately visible to all other participants via awareness
#### Scenario: Join notification
- **WHEN** a new participant joins the session
- **THEN** a brief toast shows "[name] joined"
#### Scenario: Leave notification
- **WHEN** a participant leaves the session
- **THEN** a brief toast shows "[name] left"

View file

@ -0,0 +1,27 @@
## 1. Name Editing
- [ ] 1.1 Update use-yjs.ts: allow updating user name via a returned setter function
- [ ] 1.2 Save updated name to localStorage and sync via awareness.setLocalStateField
- [ ] 1.3 Add i18n keys for participant UI (en + de)
## 2. Participant List
- [ ] 2.1 Create ParticipantList component showing all awareness states (name, color dot, host badge)
- [ ] 2.2 Add inline name edit when clicking own name in the list
- [ ] 2.3 Integrate ParticipantList into SessionView header
## 3. Cursor Styling
- [ ] 3.1 Replace current divIcon cursor with SVG pointer arrow + styled name tag (background, shadow, rounded)
- [ ] 3.2 Set cursor z-index below map controls to avoid overlap
## 4. Join/Leave Toasts
- [ ] 4.1 Track awareness changes (added/removed clients) in SessionView
- [ ] 4.2 Show auto-dismissing toast when participant joins or leaves (3 second duration)
## 5. Verify
- [ ] 5.1 Test with two browser windows: verify participant list, name editing, cursor rendering
- [ ] 5.2 Verify join/leave toasts appear correctly
- [ ] 5.3 Verify name persists across page refresh (localStorage)

View file

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

View file

@ -0,0 +1,71 @@
## Context
The architecture doc defines a rich route model with permissions, multi-day
support, spatial queries, and photos. Currently routes are owner-only, flat
(no days), and have no photos or spatial search. This change adds the route
and activity features needed before federation makes them visible to others.
## Goals / Non-Goals
**Goals:**
- Route visibility (private/public) and sharing with specific users
- Fork public routes
- Multi-day route support with day-break waypoints
- Map-based route discovery via PostGIS
- Photo attachments on activities
- Contributor tracking on route versions
**Non-Goals:**
- Federation of routes (separate change)
- Real-time collaborative permission changes
- Photo editing or filters
- Route recommendations based on preferences
- Activity collections (multi-day trip grouping — future)
## Decisions
### D1: Visibility as enum column on routes and activities
Add `visibility` column (`public`, `private`, default `private`) to both
routes and activities. `followers_only` added later when following exists.
Public routes appear in spatial search. Private routes are owner-only.
### D2: Route shares table for explicit sharing
```
journal.route_shares (routeId, userId, permission: 'view' | 'edit')
```
Owner can share with specific users. Shared users see the route in their
"Shared with me" section. Edit permission allows starting Planner sessions.
### D3: Day breaks as waypoint property
The Planner's Yjs waypoint Y.Map gets an optional `isDayBreak: true` field.
The sidebar and elevation chart show day boundaries. GPX export uses one
track segment per day. The Journal stores day-break indices in route metadata.
### D4: PostGIS spatial search
Routes already store geometry as PostGIS LineString. Add a `/routes/explore`
page with a map — as the user pans/zooms, query public routes within the
bounding box using `ST_Intersects`. Show route previews on the map.
### D5: Photo storage via S3 (Garage)
Use the Garage S3-compatible storage already in the architecture. Upload flow:
server generates presigned PUT URL → client uploads directly to S3 → server
stores the S3 key in a `journal.activity_photos` table. Serve via presigned
GET URLs or a CDN path.
### D6: Contributor tracking via array column
Add `contributors` text array to `journal.route_versions`. When a Planner
session saves back via callback, the JWT identifies the actor. The version
records the contributor's user ID (or ActivityPub URI for future federation).
## Risks / Trade-offs
- **S3 setup required** → Garage needs to be enabled in docker-compose (currently commented out). Adds operational complexity.
- **Spatial search performance** → PostGIS bounding box queries are fast with spatial indexes. Already have the geometry column.
- **Multi-day in Planner requires Yjs changes** → Adding `isDayBreak` to waypoints is backward-compatible (optional field). Existing sessions unaffected.

View file

@ -0,0 +1,43 @@
## Why
Routes are currently flat — no permissions beyond owner, no way to share with
specific people, no forking, no multi-day support, no spatial discovery, and
no contributor tracking. The architecture doc specifies all of these for Phase
2. Without them, routes are just personal GPX storage with no social or
collaborative dimension.
## What Changes
- **Route sharing permissions**: Private/public/shared visibility levels with
a view/edit permission matrix
- **Route forking**: Copy someone else's public route to your own collection
- **Multi-day routes**: Day-break waypoints that split a route into stages with
per-day stats and GPX segments
- **Spatial search**: "Routes near me" or "routes in this area" using PostGIS
- **Contributor tracking**: Record who contributed to each route version
- **Activity visibility levels**: Public/followers-only/private on activities
- **Photo attachments**: Upload photos to activities (requires S3/Garage setup)
## Capabilities
### New Capabilities
- `route-sharing`: Permission model (private/public/shared), view/edit access, route forking
- `spatial-search`: Map-based route discovery using PostGIS bounding box and proximity queries
- `multi-day-routes`: Day-break markers on waypoints, per-day stats, multi-segment GPX export
- `activity-photos`: Photo upload and display on activities via S3-compatible storage
### Modified Capabilities
- `route-management`: Add visibility, contributor tracking, forking
- `activity-feed`: Add visibility levels (public/followers-only/private)
- `planner-session`: Support day-break waypoint markers
- `infrastructure`: S3/Garage setup for photo storage
## Impact
- **Database**: New columns (visibility, contributors) on routes and activities, new route_shares table, photo storage metadata
- **Storage**: S3-compatible object storage (Garage) for photos
- **Files**: Route detail page, activity pages, search page, Planner waypoint UI, sharing UI
- **Dependencies**: S3 client library for photo uploads
- **Privacy**: Photo storage, route visibility controls documented in manifest

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Activity visibility
Activities SHALL have visibility levels controlling who can see them.
#### Scenario: Private activity
- **WHEN** an activity's visibility is "private"
- **THEN** only the owner can view it
#### Scenario: Public activity
- **WHEN** an activity's visibility is "public"
- **THEN** anyone can view it on the owner's profile

View file

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Photo attachments on activities
Users SHALL be able to upload photos to their activities.
#### Scenario: Upload photo
- **WHEN** a user adds a photo to an activity
- **THEN** the photo is uploaded to S3 storage and linked to the activity
#### Scenario: View photos
- **WHEN** a user views an activity with photos
- **THEN** the photos are displayed in a gallery on the activity page
#### Scenario: Delete photo
- **WHEN** a user deletes a photo from their activity
- **THEN** the photo is removed from storage and unlinked

View file

@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Day-break waypoints
Waypoints in the Planner SHALL support a day-break marker that splits the route into stages.
#### Scenario: Mark day break
- **WHEN** a user marks a waypoint as a day break in the Planner
- **THEN** the route is visually split into days at that point
#### Scenario: Per-day statistics
- **WHEN** a route has day-break markers
- **THEN** the sidebar shows distance and elevation per day/stage
#### Scenario: GPX export with day segments
- **WHEN** a multi-day route is exported as GPX
- **THEN** each day is a separate track segment in the GPX file

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Route metadata
Routes SHALL track visibility, contributors, and support forking.
#### Scenario: Visibility on route creation
- **WHEN** a user creates a route
- **THEN** the route defaults to "private" visibility
#### Scenario: Contributor recorded on version
- **WHEN** a Planner session saves a new route version via callback
- **THEN** the version records the contributor who made the edit

View file

@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Route visibility levels
Routes SHALL have a visibility setting controlling who can see them.
#### Scenario: Private route
- **WHEN** a route's visibility is "private"
- **THEN** only the owner can view it
#### Scenario: Public route
- **WHEN** a route's visibility is "public"
- **THEN** anyone can view it and export its GPX
### Requirement: Share routes with specific users
Route owners SHALL be able to share routes with specific users at view or edit permission levels.
#### Scenario: Share with view access
- **WHEN** owner shares a route with another user as "view"
- **THEN** that user can see the route and export GPX but cannot edit
#### Scenario: Share with edit access
- **WHEN** owner shares a route with another user as "edit"
- **THEN** that user can start Planner sessions and create new versions
### Requirement: Fork routes
Users SHALL be able to fork (copy) public routes to their own collection.
#### Scenario: Fork a public route
- **WHEN** a user clicks "Fork" on a public route
- **THEN** a copy is created in their collection with the original credited

View file

@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Map-based route discovery
Users SHALL be able to discover public routes by browsing a map.
#### Scenario: Browse routes on map
- **WHEN** a user visits the route explore page and pans/zooms the map
- **THEN** public routes within the visible area are shown as polylines on the map
#### Scenario: Route preview
- **WHEN** a user clicks a route on the explore map
- **THEN** a popup or sidebar shows the route name, distance, elevation, and a link to the detail page

View file

@ -0,0 +1,68 @@
## 1. Route Visibility & Sharing Schema
- [ ] 1.1 Add `visibility` enum column (private, public) to `journal.routes`, default private
- [ ] 1.2 Add `visibility` enum column (private, public) to `journal.activities`, default private
- [ ] 1.3 Create `journal.route_shares` table (routeId, userId, permission: view/edit)
- [ ] 1.4 Add `contributors` text array column to `journal.route_versions`
- [ ] 1.5 Add `forkedFromId` nullable column to `journal.routes`
- [ ] 1.6 Push schema and verify locally
## 2. Route Permissions Logic
- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with canView(routeId, userId), canEdit(routeId, userId) functions
- [ ] 2.2 Update route detail loader to check visibility/permissions (show 404 for unauthorized)
- [ ] 2.3 Update route list to only show own + shared routes
- [ ] 2.4 Add visibility toggle (private/public) to route edit page
- [ ] 2.5 Add share dialog — search users, set view/edit permission
## 3. Route Forking
- [ ] 3.1 Add "Fork" button on public route detail pages (visible to logged-in users who aren't the owner)
- [ ] 3.2 Create fork API route — copies route + latest GPX to user's collection, sets forkedFromId
- [ ] 3.3 Show "Forked from [original]" link on forked routes
## 4. Spatial Search
- [ ] 4.1 Ensure PostGIS spatial index exists on routes.geometry column
- [ ] 4.2 Create `/routes/explore` route with a full-page map
- [ ] 4.3 Add API route to query public routes within bounding box (ST_Intersects)
- [ ] 4.4 Render matching routes as polylines on the explore map
- [ ] 4.5 Add route popup/sidebar with name, stats, link to detail
## 5. Multi-Day Routes
- [ ] 5.1 Add `isDayBreak` optional boolean support to Planner waypoint Y.Map
- [ ] 5.2 Add day-break toggle in Planner waypoint sidebar (click to mark/unmark)
- [ ] 5.3 Show day boundaries in elevation chart
- [ ] 5.4 Compute per-day distance and elevation in sidebar
- [ ] 5.5 Update GPX export to use one track segment per day
- [ ] 5.6 Store dayBreaks array in route metadata on save
## 6. Activity Photos
- [ ] 6.1 Enable Garage in docker-compose.yml, create bucket
- [ ] 6.2 Create S3 client utility (`apps/journal/app/lib/storage.server.ts`) with presigned URL generation
- [ ] 6.3 Create `journal.activity_photos` table (id, activityId, s3Key, altText, createdAt)
- [ ] 6.4 Add photo upload UI on activity detail/edit page
- [ ] 6.5 Display photo gallery on activity detail page
- [ ] 6.6 Add delete photo functionality
## 7. Contributor Tracking
- [ ] 7.1 Update Planner→Journal callback to include contributor info from JWT
- [ ] 7.2 Store contributor in route_versions on save
- [ ] 7.3 Display contributors on route detail page
## 8. Privacy & i18n
- [ ] 8.1 Update /privacy page: document photo storage, route visibility, sharing
- [ ] 8.2 Add i18n keys for all new UI strings (en + de)
## 9. Verify
- [ ] 9.1 Test route visibility: create private and public routes, verify access control
- [ ] 9.2 Test sharing: share route with another user, verify view/edit access
- [ ] 9.3 Test forking: fork a public route, verify copy created
- [ ] 9.4 Test spatial search: create public routes, verify they appear on explore map
- [ ] 9.5 Test multi-day: mark day breaks, verify per-day stats and GPX export
- [ ] 9.6 Test photos: upload, view, delete photos on activities

View file

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

View file

@ -0,0 +1,62 @@
## Context
The Journal has passkey auth (primary) and magic link login (fallback). Magic
links work in dev (auto-redirect) but in production the link is only logged
to console — users never receive it. No email infrastructure exists.
## Goals / Non-Goals
**Goals:**
- Actually deliver magic link emails in production
- Send a welcome email after registration
- Provider-agnostic email interface (easy to swap providers)
- HTML templates with plain-text fallback
- Easy to add new email types in the future (just add a template + call)
- Dev mode: still log to console / return devLink (no real emails)
**Non-Goals:**
- Marketing emails or newsletters
- Email preferences / unsubscribe (only transactional)
- Inline CSS / complex email layouts (keep it simple text-focused)
- Queuing or retry logic (provider handles delivery)
## Decisions
### D1: Resend as email provider
Use [Resend](https://resend.com) — simple API, generous free tier (3k
emails/month), good developer experience. Single dependency: `resend` npm
package.
**Alternative considered**: Nodemailer + SMTP. More complex setup, requires
SMTP server. Resend is simpler for a small project.
**Alternative considered**: Postmark. Similar to Resend but less ergonomic API.
### D2: Email module in apps/journal/app/lib/email.server.ts
Not a shared package — only the Journal sends emails. Keep it simple:
- `sendEmail(to, subject, html, text)` — low-level sender
- `sendMagicLink(email, link)` — template wrapper
- `sendWelcome(email, username)` — template wrapper
In dev mode, `sendEmail` logs to console instead of calling Resend.
### D3: Inline HTML templates
Simple functions returning HTML strings. No template engine. Each email type
is a function: `magicLinkTemplate(link)`, `welcomeTemplate(username)`. Plain
text generated by stripping tags.
### D4: Sender domain: noreply@trails.cool
Requires DNS verification (DKIM/SPF records) with Resend. Add TXT records via
Terraform or manually in Hetzner DNS.
## Risks / Trade-offs
- **Resend vendor lock-in** → Low risk. The `sendEmail` wrapper abstracts it.
Swapping to nodemailer later is a one-file change.
- **DNS verification required** → Must add DKIM/SPF records before emails work.
Can use Resend's test domain initially.
- **Free tier limits** → 3k/month is plenty for <100 users.

View file

@ -0,0 +1,32 @@
## Why
Magic link login doesn't work in production — the link is logged to console
but never emailed to the user. Registration works (passkey-based) but the
fallback auth flow is broken. We need actual email delivery.
## What Changes
- Add a reusable email sending layer (`@trails-cool/email` or in-app module)
with a provider-agnostic interface so future emails are trivial to add
- Send magic link emails in production
- Send a welcome email after registration
- Use a transactional email provider (Resend, Postmark, or SMTP)
- HTML email templates with plain-text fallback
- Document email sending in the privacy manifest
## Capabilities
### New Capabilities
- `transactional-emails`: Send templated emails via a provider-agnostic interface. Current emails: magic link login, welcome on registration. Designed for easy addition of future email types.
### Modified Capabilities
- `journal-auth`: Magic link flow now actually sends the email instead of logging to console
## Impact
- **Files**: New email module (lib or package), modified `api.auth.login.ts`, modified registration flow, email templates
- **Dependencies**: One email provider SDK (e.g., `resend` or `nodemailer`)
- **Infrastructure**: Email provider API key as env var, DNS records for sender domain verification
- **Privacy**: Email addresses sent to third-party email provider — must document

View file

@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Magic link login
The magic link login flow SHALL deliver the link via email in production instead of logging to console.
#### Scenario: Production email delivery
- **WHEN** a user requests a magic link in production
- **THEN** the link is emailed to the user (not just logged to console)
#### Scenario: Dev mode unchanged
- **WHEN** a user requests a magic link in development
- **THEN** the link is returned directly for auto-redirect (existing behavior preserved)

View file

@ -0,0 +1,44 @@
## ADDED Requirements
### Requirement: Email sending interface
The system SHALL provide a provider-agnostic email sending function that supports HTML and plain-text content.
#### Scenario: Send email in production
- **WHEN** the system sends a transactional email in production
- **THEN** the email is delivered via the configured provider (Resend) to the recipient
#### Scenario: Dev mode skips sending
- **WHEN** the system sends a transactional email in development
- **THEN** the email content is logged to console and no external API is called
### Requirement: Magic link email
The system SHALL send an email containing the magic link when a user requests passwordless login.
#### Scenario: Magic link delivered
- **WHEN** a user requests a magic link login
- **THEN** an email is sent with a clickable link that logs them in
#### Scenario: Email content
- **WHEN** a magic link email is sent
- **THEN** it includes: the link, expiry time (15 minutes), and plain-text fallback
### Requirement: Welcome email
The system SHALL send a welcome email after successful registration.
#### Scenario: Welcome on registration
- **WHEN** a user completes passkey registration
- **THEN** a welcome email is sent to their registered email address
### Requirement: Email templates
Each email type SHALL have an HTML template with a plain-text fallback.
#### Scenario: Extensible template pattern
- **WHEN** a new email type is needed in the future
- **THEN** it can be added by creating a template function and calling sendEmail
### Requirement: Privacy disclosure
Email sending SHALL be documented in the privacy manifest.
#### Scenario: Privacy manifest updated
- **WHEN** transactional emails are enabled
- **THEN** the /privacy page documents: what emails are sent, that email addresses are shared with the email provider, and which provider is used

View file

@ -0,0 +1,30 @@
## 1. Email Infrastructure
- [ ] 1.1 Add `resend` package to Journal dependencies
- [ ] 1.2 Create `apps/journal/app/lib/email.server.ts` with sendEmail(to, subject, html, text) — uses Resend in production, logs to console in dev
- [ ] 1.3 Add `RESEND_API_KEY` env var to docker-compose.yml
- [ ] 1.4 Write unit test for sendEmail (mock Resend, verify dev-mode logging)
## 2. Email Templates
- [ ] 2.1 Create magicLinkTemplate(link: string) returning { html, text } — includes link, 15-min expiry note, trails.cool branding
- [ ] 2.2 Create welcomeTemplate(username: string) returning { html, text } — greeting, what they can do, link to routes
- [ ] 2.3 Wire sendMagicLink(email, link) and sendWelcome(email, username) helper functions
## 3. Integration
- [ ] 3.1 Update api.auth.login.ts: call sendMagicLink in production instead of just logging
- [ ] 3.2 Update registration flow: call sendWelcome after successful passkey registration
- [ ] 3.3 Verify dev mode still works (devLink returned, no email sent)
## 4. Privacy & Config
- [ ] 4.1 Update /privacy page: document email sending, provider (Resend), what data is shared
- [ ] 4.2 Add `RESEND_API_KEY` to CD secrets and deploy documentation
- [ ] 4.3 Document sender domain DNS setup (DKIM/SPF for noreply@trails.cool)
## 5. Verify
- [ ] 5.1 Test magic link email delivery locally with Resend test API key
- [ ] 5.2 Test welcome email on registration
- [ ] 5.3 Verify existing E2E tests still pass (dev mode unchanged)