From 6d8e570afc5001d115c85c5b70b284a2ca8f10b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 00:44:28 +0100 Subject: [PATCH 001/835] Fix Playwright job summary: handle nested suite structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright JSON has file → describe → specs nesting. Use stats.expected/unexpected for counts, iterate nested suites. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1185839..19a57c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,24 +186,25 @@ jobs: if [ -f playwright-results.json ]; then node -e " const r = require('./playwright-results.json'); - const total = r.suites.reduce((n, s) => n + s.specs.length, 0); - const passed = r.suites.reduce((n, s) => n + s.specs.filter(t => t.ok).length, 0); - const failed = total - passed; - const dur = (r.stats.duration / 1000).toFixed(1); + const s = r.stats; + const dur = (s.duration / 1000).toFixed(1); let md = '## Playwright E2E Results\n\n'; md += '| Status | Count |\n|--------|-------|\n'; - md += '| :white_check_mark: Passed | ${passed} |\n'; - if (failed > 0) md += '| :x: Failed | ${failed} |\n'; - md += '| Total | ${total} |\n'; - md += '| Duration | ${dur}s |\n\n'; - for (const suite of r.suites) { - md += '### ' + suite.title + '\n\n'; - for (const spec of suite.specs) { - const icon = spec.ok ? ':white_check_mark:' : ':x:'; - const time = spec.tests?.[0]?.results?.[0]?.duration; - md += '- ' + icon + ' ' + spec.title + (time ? ' (' + time + 'ms)' : '') + '\n'; + md += '| :white_check_mark: Passed | ' + s.expected + ' |\n'; + if (s.unexpected > 0) md += '| :x: Failed | ' + s.unexpected + ' |\n'; + if (s.flaky > 0) md += '| :warning: Flaky | ' + s.flaky + ' |\n'; + if (s.skipped > 0) md += '| :fast_forward: Skipped | ' + s.skipped + ' |\n'; + md += '| :stopwatch: Duration | ' + dur + 's |\n\n'; + for (const file of r.suites) { + for (const describe of (file.suites || [])) { + md += '### ' + describe.title + '\n\n'; + for (const spec of (describe.specs || [])) { + const icon = spec.ok ? ':white_check_mark:' : ':x:'; + const t = spec.tests?.[0]?.results?.[0]?.duration; + md += '- ' + icon + ' ' + spec.title + (t ? ' (' + t + 'ms)' : '') + '\n'; + } + md += '\n'; } - md += '\n'; } require('fs').appendFileSync(process.env.GITHUB_STEP_SUMMARY, md); " From 8e2f183288f8dcc66fba10b069d6016ec9ace4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:00:00 +0100 Subject: [PATCH 002/835] Enable CD deployment to Hetzner (task 11.8) CD workflow: - Uncommented deploy step - Copy docker-compose.yml + Caddyfile to server via SCP - Login to ghcr.io with DEPLOY_GHCR_TOKEN (classic PAT, read:packages) - Download Germany BRouter segments on first deploy (~750MB) - Pull images, restart services, verify Infrastructure: - BRouter segments as bind mount (./segments) not Docker volume - Added ORIGIN, PLANNER_URL, SESSION_SECRET, NODE_ENV to Journal - Added NODE_ENV to Planner - Disabled Garage for now (no media storage yet) Required secrets: DEPLOY_HOST, DEPLOY_SSH_KEY, DEPLOY_GHCR_TOKEN Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd.yml | 63 ++++++++++++++++++++++--------- infrastructure/docker-compose.yml | 27 ++++++------- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index cefcdd3..13ce590 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -59,20 +59,49 @@ jobs: ghcr.io/trails-cool/brouter:latest ghcr.io/trails-cool/brouter:${{ github.sha }} - # Deploy step - uncomment when Hetzner server is provisioned - # deploy: - # name: Deploy to Hetzner - # needs: [build-images, build-brouter] - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v6 - # - name: Deploy via SSH - # uses: appleboy/ssh-action@v1 - # with: - # host: ${{ secrets.DEPLOY_HOST }} - # username: root - # key: ${{ secrets.DEPLOY_SSH_KEY }} - # script: | - # cd /opt/trails-cool - # docker compose pull - # docker compose up -d + deploy: + name: Deploy to Hetzner + needs: [build-images, build-brouter] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Copy files to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + target: /opt/trails-cool + strip_components: 1 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/trails-cool + + # Login to ghcr.io to pull private images + echo "${{ secrets.DEPLOY_GHCR_TOKEN }}" | docker login ghcr.io -u stigi --password-stdin + + # Download BRouter segments if missing + if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then + mkdir -p /opt/trails-cool/segments + echo "Downloading Germany BRouter segments..." + for tile in E5_N45 E5_N50 E10_N45 E10_N50; do + [ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \ + wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5" + done + fi + + # Pull latest images and restart + docker compose pull + docker compose up -d --remove-orphans + + # Verify services are running + sleep 10 + docker compose ps diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index b6228af..e18b887 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -19,12 +19,12 @@ services: restart: unless-stopped environment: DOMAIN: ${DOMAIN:-trails.cool} + ORIGIN: https://${DOMAIN:-trails.cool} + PLANNER_URL: https://planner.${DOMAIN:-trails.cool} DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails - S3_ENDPOINT: http://garage:3900 - S3_BUCKET: trails-media - S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} - S3_SECRET_KEY: ${S3_SECRET_KEY:-} JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production} + NODE_ENV: production PORT: 3000 depends_on: postgres: @@ -36,6 +36,7 @@ services: environment: BROUTER_URL: http://brouter:17777 DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails + NODE_ENV: production PORT: 3001 depends_on: postgres: @@ -47,8 +48,7 @@ services: image: ghcr.io/trails-cool/brouter:latest restart: unless-stopped volumes: - - brouter_segments:/data/segments - - brouter_profiles:/data/profiles + - ./segments:/data/segments # Segments can be pulled from: # - https://brouter.de/brouter/segments4/ (official, weekly updates) # - A trails.cool CDN mirror (later) @@ -68,17 +68,14 @@ services: timeout: 5s retries: 5 - garage: - image: dxflrs/garage:v1.0 - restart: unless-stopped - volumes: - - garage_data:/var/lib/garage - - ./garage.toml:/etc/garage.toml:ro + # garage: + # image: dxflrs/garage:v1.0 + # restart: unless-stopped + # volumes: + # - garage_data:/var/lib/garage + # - ./garage.toml:/etc/garage.toml:ro volumes: caddy_data: caddy_config: pgdata: - brouter_segments: - brouter_profiles: - garage_data: From 0a09df27f43a7e4519e68250cb3601e9894ba01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:10:01 +0100 Subject: [PATCH 003/835] Fix Docker builds: missing deps + bump actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfiles: - Add db package.json to deps stage - Copy full node_modules (including app-level) to runtime - Copy packages/ dir for workspace imports at runtime CD workflow: - docker/login-action v3 → v4 - docker/build-push-action v6 → v7 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd.yml | 8 ++++---- apps/journal/Dockerfile | 6 ++++-- apps/planner/Dockerfile | 6 ++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 13ce590..f6e1b3e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -21,13 +21,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: . file: apps/${{ matrix.app }}/Dockerfile @@ -45,13 +45,13 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: docker/login-action@v3 + - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@v7 with: context: docker/brouter push: true diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index b36fc39..ca65b39 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -10,18 +10,20 @@ COPY packages/ui/package.json packages/ui/ COPY packages/map/package.json packages/map/ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ +COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile FROM base AS build -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules +COPY --from=deps /app/ ./ COPY . . RUN pnpm --filter @trails-cool/journal build FROM base AS runtime COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json +COPY --from=build /app/packages ./packages WORKDIR /app/apps/journal EXPOSE 3000 diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 6ee2002..3167d82 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -10,20 +10,22 @@ COPY packages/ui/package.json packages/ui/ COPY packages/map/package.json packages/map/ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ +COPY packages/db/package.json packages/db/ RUN pnpm install --frozen-lockfile FROM base AS build -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/planner/node_modules ./apps/planner/node_modules +COPY --from=deps /app/ ./ COPY . . RUN pnpm --filter @trails-cool/planner build FROM base AS runtime COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/planner/node_modules ./apps/planner/node_modules COPY --from=build /app/apps/planner/build ./apps/planner/build COPY --from=build /app/apps/planner/server.ts ./apps/planner/server.ts COPY --from=build /app/apps/planner/app/lib ./apps/planner/app/lib COPY --from=build /app/apps/planner/package.json ./apps/planner/package.json +COPY --from=build /app/packages ./packages WORKDIR /app/apps/planner EXPOSE 3001 From 46c880973d8ed3d49bf0fcb2c98e13d031f62698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:18:12 +0100 Subject: [PATCH 004/835] Fix CD: trigger on automerge + manual dispatch GITHUB_TOKEN merges (from automerge action) don't trigger push events on other workflows. Added: - workflow_run trigger after Automerge completes - workflow_dispatch for manual gh workflow run cd.yml Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f6e1b3e..5c5f17a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,8 +1,17 @@ name: CD +# Triggers: +# - push: normal merges (manual gh pr merge) +# - workflow_run: after automerge completes (GITHUB_TOKEN merges don't trigger push) +# - workflow_dispatch: manual trigger via gh workflow run cd.yml on: push: branches: [main] + workflow_run: + workflows: ["Automerge"] + branches: [main] + types: [completed] + workflow_dispatch: {} concurrency: group: deploy @@ -12,6 +21,7 @@ jobs: build-images: name: Build & Push Docker Images runs-on: ubuntu-latest + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' permissions: contents: read packages: write From afeefbf19d5a6f48361646da51b2534c728fbae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:26:16 +0100 Subject: [PATCH 005/835] Fix production deploy: TypeScript support + DB schema push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planner crashed because Node.js couldn't handle .ts imports — add --experimental-strip-types to the CMD. Journal crashed because the database had no schemas — add drizzle-kit push to the deploy script. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cd.yml | 7 ++++++- apps/planner/Dockerfile | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 5c5f17a..2576d96 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -108,8 +108,13 @@ jobs: done fi - # Pull latest images and restart + # Pull latest images docker compose pull + + # Push database schema (starts postgres, waits for healthy) + docker compose run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force + + # Start all services docker compose up -d --remove-orphans # Verify services are running diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile index 3167d82..ee02008 100644 --- a/apps/planner/Dockerfile +++ b/apps/planner/Dockerfile @@ -30,4 +30,4 @@ COPY --from=build /app/packages ./packages WORKDIR /app/apps/planner EXPOSE 3001 ENV PORT=3001 -CMD ["node", "server.ts"] +CMD ["node", "--experimental-strip-types", "server.ts"] From 4d73de4e0d08d6bab26d8c4a132b8c57e27affe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:34:16 +0100 Subject: [PATCH 006/835] Use explicit .ts extensions for Node-compatible imports Node's --experimental-strip-types requires explicit .ts extensions on relative imports. Add allowImportingTsExtensions to both app tsconfigs and update server-side imports in planner and journal for consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 2 +- apps/journal/app/lib/auth.server.ts | 2 +- apps/journal/app/lib/routes.server.ts | 2 +- apps/journal/tsconfig.json | 1 + apps/planner/app/lib/sessions.ts | 4 ++-- apps/planner/app/lib/yjs-server.ts | 2 +- apps/planner/server.ts | 2 +- apps/planner/tsconfig.json | 1 + 8 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index eea778d..e51c7b4 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { eq, desc } from "drizzle-orm"; -import { getDb } from "./db"; +import { getDb } from "./db.ts"; import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 48951c7..8905332 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -11,7 +11,7 @@ import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, } from "@simplewebauthn/types"; -import { getDb } from "./db"; +import { getDb } from "./db.ts"; import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal"; const RP_NAME = "trails.cool"; diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 8ab80e8..1d36af3 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; -import { getDb } from "./db"; +import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; import { parseGpx } from "@trails-cool/gpx"; diff --git a/apps/journal/tsconfig.json b/apps/journal/tsconfig.json index fe098db..0aab76e 100644 --- a/apps/journal/tsconfig.json +++ b/apps/journal/tsconfig.json @@ -9,6 +9,7 @@ "compilerOptions": { "rootDirs": [".", "./.react-router/types"], "noEmit": true, + "allowImportingTsExtensions": true, "types": ["vite/client"], "paths": { "~/*": ["./app/*"] diff --git a/apps/planner/app/lib/sessions.ts b/apps/planner/app/lib/sessions.ts index 9f66764..728f380 100644 --- a/apps/planner/app/lib/sessions.ts +++ b/apps/planner/app/lib/sessions.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import * as Y from "yjs"; import { eq, and, desc, lt } from "drizzle-orm"; -import { getOrCreateDoc, deleteDoc } from "./yjs-server"; -import { getDb } from "./db"; +import { getOrCreateDoc, deleteDoc } from "./yjs-server.ts"; +import { getDb } from "./db.ts"; import { sessions } from "@trails-cool/db/schema/planner"; export type SessionMetadata = typeof sessions.$inferSelect; diff --git a/apps/planner/app/lib/yjs-server.ts b/apps/planner/app/lib/yjs-server.ts index 3cebb39..50bc9d2 100644 --- a/apps/planner/app/lib/yjs-server.ts +++ b/apps/planner/app/lib/yjs-server.ts @@ -1,7 +1,7 @@ import { WebSocketServer, type WebSocket } from "ws"; import * as Y from "yjs"; import type { IncomingMessage, Server } from "node:http"; -import { saveSessionState, loadSessionState, touchSession } from "./sessions"; +import { saveSessionState, loadSessionState, touchSession } from "./sessions.ts"; const docs = new Map(); const sessionClients = new Map>(); diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 5909187..4ef1758 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -1,6 +1,6 @@ import { createRequestHandler } from "@react-router/node"; import { createServer } from "node:http"; -import { setupYjsWebSocket } from "./app/lib/yjs-server"; +import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; const port = Number(process.env.PORT ?? 3001); diff --git a/apps/planner/tsconfig.json b/apps/planner/tsconfig.json index 708aa4d..5cd30e8 100644 --- a/apps/planner/tsconfig.json +++ b/apps/planner/tsconfig.json @@ -9,6 +9,7 @@ "compilerOptions": { "rootDirs": [".", "./.react-router/types"], "noEmit": true, + "allowImportingTsExtensions": true, "types": ["vite/client"], "paths": { "~/*": ["./app/*"] From 62b5fb998e187bb96def58182de7d0df0d36e2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:41:33 +0100 Subject: [PATCH 007/835] Fix planner production server + use .ts extensions everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix createRequestHandler → createRequestListener (@react-router/node removed the old API) - Move noEmit + allowImportingTsExtensions to tsconfig.base.json so all packages and apps inherit them consistently - Add explicit .ts extensions to all remaining relative imports across packages and apps Verified locally: node --experimental-strip-types server.ts starts and responds 200. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/tsconfig.json | 2 -- apps/planner/app/lib/host-election.test.ts | 2 +- apps/planner/app/lib/rate-limit.test.ts | 2 +- apps/planner/app/lib/use-routing.ts | 4 ++-- apps/planner/server.ts | 13 +++++-------- apps/planner/tsconfig.json | 2 -- apps/planner/vite.config.ts | 2 +- packages/db/src/index.ts | 4 ++-- packages/gpx/src/generate.test.ts | 4 ++-- packages/gpx/src/generate.ts | 2 +- packages/gpx/src/index.ts | 6 +++--- packages/gpx/src/parse.test.ts | 2 +- packages/gpx/src/parse.ts | 2 +- packages/i18n/src/index.ts | 4 ++-- packages/map/src/index.ts | 12 ++++++------ packages/types/src/index.test.ts | 2 +- packages/ui/src/index.ts | 12 ++++++------ tsconfig.base.json | 4 +++- 18 files changed, 38 insertions(+), 43 deletions(-) diff --git a/apps/journal/tsconfig.json b/apps/journal/tsconfig.json index 0aab76e..3972ec5 100644 --- a/apps/journal/tsconfig.json +++ b/apps/journal/tsconfig.json @@ -8,8 +8,6 @@ "exclude": ["build", "node_modules"], "compilerOptions": { "rootDirs": [".", "./.react-router/types"], - "noEmit": true, - "allowImportingTsExtensions": true, "types": ["vite/client"], "paths": { "~/*": ["./app/*"] diff --git a/apps/planner/app/lib/host-election.test.ts b/apps/planner/app/lib/host-election.test.ts index 3467f04..4e7ec32 100644 --- a/apps/planner/app/lib/host-election.test.ts +++ b/apps/planner/app/lib/host-election.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { electHost } from "./host-election"; +import { electHost } from "./host-election.ts"; function makeStates( entries: Array<{ id: number; user?: boolean; role?: string }>, diff --git a/apps/planner/app/lib/rate-limit.test.ts b/apps/planner/app/lib/rate-limit.test.ts index a4ec910..4dba7fa 100644 --- a/apps/planner/app/lib/rate-limit.test.ts +++ b/apps/planner/app/lib/rate-limit.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { checkRateLimit } from "./rate-limit"; +import { checkRateLimit } from "./rate-limit.ts"; describe("checkRateLimit", () => { it("allows requests within limit", () => { diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index 227c967..88c2efa 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; -import type { YjsState } from "./use-yjs"; -import { electHost } from "./host-election"; +import type { YjsState } from "./use-yjs.ts"; +import { electHost } from "./host-election.ts"; interface RouteStats { distance?: number; diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 4ef1758..097ddad 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -1,18 +1,15 @@ -import { createRequestHandler } from "@react-router/node"; +import { createRequestListener } from "@react-router/node"; import { createServer } from "node:http"; import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; const port = Number(process.env.PORT ?? 3001); -const handler = createRequestHandler( - // @ts-expect-error - build output types - await import("./build/server/index.js"), -); - -const server = createServer((req, res) => { - handler(req, res); +const listener = createRequestListener({ + build: () => import("./build/server/index.js") as never, }); +const server = createServer(listener); + setupYjsWebSocket(server); server.listen(port, () => { diff --git a/apps/planner/tsconfig.json b/apps/planner/tsconfig.json index 5cd30e8..09fed20 100644 --- a/apps/planner/tsconfig.json +++ b/apps/planner/tsconfig.json @@ -8,8 +8,6 @@ "exclude": ["build", "node_modules", "server.ts"], "compilerOptions": { "rootDirs": [".", "./.react-router/types"], - "noEmit": true, - "allowImportingTsExtensions": true, "types": ["vite/client"], "paths": { "~/*": ["./app/*"] diff --git a/apps/planner/vite.config.ts b/apps/planner/vite.config.ts index 311be5a..760df32 100644 --- a/apps/planner/vite.config.ts +++ b/apps/planner/vite.config.ts @@ -2,7 +2,7 @@ import { reactRouter } from "@react-router/dev/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import path from "node:path"; -import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin"; +import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin.ts"; export default defineConfig({ plugins: [tailwindcss(), reactRouter(), yjsDevPlugin()], diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5d5e6b8..8141386 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,7 +1,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; -import * as plannerSchema from "./schema/planner"; -import * as journalSchema from "./schema/journal"; +import * as plannerSchema from "./schema/planner.ts"; +import * as journalSchema from "./schema/journal.ts"; export function createDb(connectionString?: string) { const client = postgres( diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts index c4e1a5b..6b4fd34 100644 --- a/packages/gpx/src/generate.test.ts +++ b/packages/gpx/src/generate.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { generateGpx } from "./generate"; -import { parseGpx } from "./parse"; +import { generateGpx } from "./generate.ts"; +import { parseGpx } from "./parse.ts"; describe("generateGpx", () => { it("generates valid GPX with name", () => { diff --git a/packages/gpx/src/generate.ts b/packages/gpx/src/generate.ts index 1bfcd8c..d0f0268 100644 --- a/packages/gpx/src/generate.ts +++ b/packages/gpx/src/generate.ts @@ -1,5 +1,5 @@ import type { Waypoint } from "@trails-cool/types"; -import type { TrackPoint } from "./types"; +import type { TrackPoint } from "./types.ts"; /** * Generate a GPX XML string from waypoints and track points. diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index 46ec02a..edc7f3d 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,3 @@ -export { parseGpx, parseGpxAsync } from "./parse"; -export { generateGpx } from "./generate"; -export type { GpxData, TrackPoint, ElevationProfile } from "./types"; +export { parseGpx, parseGpxAsync } from "./parse.ts"; +export { generateGpx } from "./generate.ts"; +export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts index dd31b56..f38ac8b 100644 --- a/packages/gpx/src/parse.test.ts +++ b/packages/gpx/src/parse.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseGpx } from "./parse"; +import { parseGpx } from "./parse.ts"; const sampleGpx = ` diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index b9cb5dc..d1b118b 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -1,5 +1,5 @@ import type { Waypoint } from "@trails-cool/types"; -import type { GpxData, TrackPoint, ElevationProfile } from "./types"; +import type { GpxData, TrackPoint, ElevationProfile } from "./types.ts"; /** * Parse a GPX XML string into structured data. diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 8b069b6..807b0d9 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,8 +1,8 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import en from "./locales/en"; -import de from "./locales/de"; +import en from "./locales/en.ts"; +import de from "./locales/de.ts"; export const defaultNS = "common"; diff --git a/packages/map/src/index.ts b/packages/map/src/index.ts index a297f36..4e91e78 100644 --- a/packages/map/src/index.ts +++ b/packages/map/src/index.ts @@ -1,6 +1,6 @@ -export { MapView } from "./MapView"; -export type { MapViewProps } from "./MapView"; -export { RouteLayer } from "./RouteLayer"; -export type { RouteLayerProps } from "./RouteLayer"; -export { baseLayers } from "./layers"; -export type { TileLayerConfig } from "./layers"; +export { MapView } from "./MapView.tsx"; +export type { MapViewProps } from "./MapView.tsx"; +export { RouteLayer } from "./RouteLayer.tsx"; +export type { RouteLayerProps } from "./RouteLayer.tsx"; +export { baseLayers } from "./layers.ts"; +export type { TileLayerConfig } from "./layers.ts"; diff --git a/packages/types/src/index.test.ts b/packages/types/src/index.test.ts index 3578f0d..1a263db 100644 --- a/packages/types/src/index.test.ts +++ b/packages/types/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { Waypoint, Route } from "./index"; +import type { Waypoint, Route } from "./index.ts"; describe("types", () => { it("Waypoint type accepts valid data", () => { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e75bcca..b68472e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,6 +1,6 @@ -export { Button } from "./Button"; -export type { ButtonProps } from "./Button"; -export { Input } from "./Input"; -export type { InputProps } from "./Input"; -export { Card } from "./Card"; -export type { CardProps } from "./Card"; +export { Button } from "./Button.tsx"; +export type { ButtonProps } from "./Button.tsx"; +export { Input } from "./Input.tsx"; +export type { InputProps } from "./Input.tsx"; +export { Card } from "./Card.tsx"; +export type { CardProps } from "./Card.tsx"; diff --git a/tsconfig.base.json b/tsconfig.base.json index 59163d2..ece6172 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,7 +14,9 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "noEmit": true, + "allowImportingTsExtensions": true }, "exclude": ["node_modules", "build", "dist"] } From 3a4119e00e64bb1d5dbab1882a46b0ecb30ce495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:53:20 +0100 Subject: [PATCH 008/835] Restrict automerge to PRs targeting main Prevents stacked PRs from being auto-merged into feature branches. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/automerge.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index d4b88e1..cafc74b 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -48,3 +48,4 @@ jobs: MERGE_DELETE_BRANCH: "true" MERGE_READY_STATE: "clean" UPDATE_METHOD: "rebase" + BASE_BRANCHES: "main" From 394daca38e1bbd6aca20f063015dfa7b72907533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:01:27 +0100 Subject: [PATCH 009/835] Serve static assets in planner's custom server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom server.ts only had the React Router request handler — static assets in build/client/ were never served, causing 404s for all JS/CSS in production. Add a static file handler with path traversal protection and immutable caching for hashed assets. Verified locally: HTML 200, assets 200, Cache-Control: immutable on /assets/*. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/server.ts | 46 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/planner/server.ts b/apps/planner/server.ts index 097ddad..d876aef 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -1,14 +1,56 @@ import { createRequestListener } from "@react-router/node"; -import { createServer } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createReadStream, statSync } from "node:fs"; +import { join, extname, resolve } from "node:path"; import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; const port = Number(process.env.PORT ?? 3001); +const CLIENT_DIR = resolve(import.meta.dirname, "build", "client"); + +const MIME: Record = { + ".js": "application/javascript", + ".css": "text/css", + ".html": "text/html", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +function serveStatic(req: IncomingMessage, res: ServerResponse): boolean { + if (req.method !== "GET" && req.method !== "HEAD") return false; + + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + const filePath = resolve(join(CLIENT_DIR, url.pathname)); + + if (!filePath.startsWith(CLIENT_DIR)) return false; + + try { + if (!statSync(filePath).isFile()) return false; + } catch { + return false; + } + + res.setHeader("Content-Type", MIME[extname(filePath)] ?? "application/octet-stream"); + if (url.pathname.startsWith("/assets/")) { + res.setHeader("Cache-Control", "public, immutable, max-age=31536000"); + } + createReadStream(filePath).pipe(res); + return true; +} const listener = createRequestListener({ build: () => import("./build/server/index.js") as never, }); -const server = createServer(listener); +const server = createServer((req, res) => { + if (!serveStatic(req, res)) { + listener(req, res); + } +}); setupYjsWebSocket(server); From 537d515df900ee6fdc89fd8545ba515df53333da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 01:44:12 +0100 Subject: [PATCH 010/835] Run E2E tests against production build in CI In CI, Playwright now starts apps with production commands (react-router- serve, node --experimental-strip-types) instead of dev servers. This catches runtime issues like missing exports and import resolution errors before deploy. Locally, dev servers are still used for fast iteration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 3 +++ playwright.config.ts | 39 +++++++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19a57c7..7487db2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,9 @@ jobs: if: steps.playwright-cache.outputs.cache-hit == 'true' run: pnpm exec playwright install-deps chromium + - name: Build for production + run: pnpm build + - name: Run E2E tests run: pnpm test:e2e env: diff --git a/playwright.config.ts b/playwright.config.ts index 410e250..b8a79dc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,16 +39,31 @@ export default defineConfig({ }, }, ], - webServer: [ - { - command: "pnpm --filter @trails-cool/journal dev", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - }, - { - command: "pnpm --filter @trails-cool/planner dev", - url: "http://localhost:3001", - reuseExistingServer: !process.env.CI, - }, - ], + webServer: process.env.CI + ? [ + { + command: "npx react-router-serve ./build/server/index.js", + url: "http://localhost:3000", + cwd: "./apps/journal", + reuseExistingServer: false, + }, + { + command: "node --experimental-strip-types server.ts", + url: "http://localhost:3001", + cwd: "./apps/planner", + reuseExistingServer: false, + }, + ] + : [ + { + command: "pnpm --filter @trails-cool/journal dev", + url: "http://localhost:3000", + reuseExistingServer: true, + }, + { + command: "pnpm --filter @trails-cool/planner dev", + url: "http://localhost:3001", + reuseExistingServer: true, + }, + ], }); From 69f420e879f04e13e28e711030231b643a750356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:11:22 +0100 Subject: [PATCH 011/835] Add basic responsive layout for tablet and mobile Planner: hide title on small screens, wrap header items, hide waypoint sidebar below md breakpoint (map takes full width). Journal: stack route stats and action buttons on mobile, responsive header layout on route detail. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes/routes.$id.tsx | 6 +++--- apps/planner/app/components/SessionView.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 6e90df0..1533e95 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -90,7 +90,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { return (
-
+

{route.name}

{route.description && ( @@ -98,7 +98,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { )}
{isOwner && ( -
+
@@ -98,7 +100,7 @@ export default function LoginPage() { onClick={() => setMode("magic-link")} className="text-sm text-gray-500 hover:text-gray-700" > - No passkey on this device? Use a magic link instead + {t("auth.useMagicLink")}
@@ -107,11 +109,11 @@ export default function LoginPage() { {mode === "magic-link" && !magicLinkSent && (

- We'll send a login link to your email. + {t("auth.magicLinkHelp")}

- {loading ? "Sending..." : "Send Magic Link"} + {loading ? t("auth.sending") : t("auth.sendMagicLink")}
@@ -137,7 +139,7 @@ export default function LoginPage() { onClick={() => setMode("passkey")} className="text-sm text-gray-500 hover:text-gray-700" > - Back to passkey login + {t("auth.backToPasskey")}
@@ -146,10 +148,10 @@ export default function LoginPage() { {magicLinkSent && (

- Check your email! We sent a login link to {email}. + {t("auth.checkEmail")} {email}.

- The link expires in 15 minutes. + {t("auth.linkExpires")}

)} @@ -159,9 +161,9 @@ export default function LoginPage() { )}

- Don't have an account?{" "} + {t("auth.noAccount")}{" "} - Register + {t("auth.register")}

diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 8d1ca15..6db6cd3 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; export default function RegisterPage() { + const { t } = useTranslation("journal"); const [email, setEmail] = useState(""); const [username, setUsername] = useState(""); const [error, setError] = useState(null); @@ -59,15 +61,15 @@ export default function RegisterPage() { return (
-

Create Account

+

{t("auth.register")}

- Register with a passkey — no password needed. + {t("auth.registerDescription")}

- {loading ? "Creating passkey..." : "Register with Passkey"} + {loading ? t("auth.creatingPasskey") : t("auth.registerWithPasskey")}

Already have an account?{" "} - Sign in + {t("auth.login")}

diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index a110331..196ad10 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import { data } from "react-router"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/home"; import { getSessionUser } from "~/lib/auth.server"; @@ -22,6 +23,7 @@ export async function loader({ request }: Route.LoaderArgs) { export default function Home({ loaderData }: Route.ComponentProps) { const { user, showAddPasskey } = loaderData; + const { t } = useTranslation("journal"); const [addingPasskey, setAddingPasskey] = useState(false); const [passkeyDone, setPasskeyDone] = useState(false); const [error, setError] = useState(null); @@ -74,19 +76,19 @@ export default function Home({ loaderData }: Route.ComponentProps) { return (
-

trails.cool

-

Your outdoor activity journal

+

{t("title")}

+

{t("subtitle")}

{user ? (

- Welcome, {user.displayName ?? user.username} + {t("welcome")} {user.displayName ?? user.username}

{showAddPasskey && !passkeyDone && (

- Add a passkey for faster sign-in on this device. + {t("addPasskeyPrompt")}

{error &&

{error}

}
)} @@ -102,7 +104,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { {passkeyDone && (

- Passkey added! You can now sign in instantly on this device. + {t("passkeyAdded")}

)} @@ -113,13 +115,13 @@ export default function Home({ loaderData }: Route.ComponentProps) { href="/auth/register" className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700" > - Register + {t("auth.register")} - Sign in + {t("auth.login")}
)} diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 1533e95..5eb680b 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import { data, redirect } from "react-router"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; import { getSessionUser } from "~/lib/auth.server"; import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; @@ -73,6 +74,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) { export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { const { route, versions, isOwner } = loaderData; + const { t } = useTranslation("journal"); const [editLoading, setEditLoading] = useState(false); const handleEditInPlanner = useCallback(async () => { @@ -104,13 +106,13 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { disabled={editLoading} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50" > - {editLoading ? "Opening..." : "Edit in Planner"} + {editLoading ? t("routes.opening") : t("routes.editInPlanner")} - Edit + {t("routes.edit")} {route.hasGpx && ( - Export GPX + {t("routes.exportGpx")} )}
@@ -131,7 +133,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {

{(route.distance / 1000).toFixed(1)} km

-

Distance

+

{t("routes.distance")}

)} {route.elevationGain != null && ( @@ -181,7 +183,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { type="submit" className="rounded-md bg-red-50 px-4 py-2 text-sm text-red-700 hover:bg-red-100" > - Delete Route + {t("routes.delete")}
diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx index 506f07f..7fdf714 100644 --- a/apps/journal/app/routes/routes._index.tsx +++ b/apps/journal/app/routes/routes._index.tsx @@ -1,4 +1,5 @@ import { data, redirect } from "react-router"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes._index"; import { getSessionUser } from "~/lib/auth.server"; import { listRoutes } from "~/lib/routes.server"; @@ -26,22 +27,23 @@ export function meta(_args: Route.MetaArgs) { export default function RoutesListPage({ loaderData }: Route.ComponentProps) { const { routes } = loaderData; + const { t } = useTranslation("journal"); return (
-

My Routes

+

{t("routes.myRoutes")}

- New Route + {t("routes.new")}
{routes.length === 0 ? (

- No routes yet. Create your first route! + {t("routes.noRoutesYet")}

) : (
    diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 43b941b..b4dff4e 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -1,10 +1,13 @@ import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { generateGpx } from "@trails-cool/gpx"; import type { TrackPoint } from "@trails-cool/gpx"; export function ExportButton({ yjs }: { yjs: YjsState }) { + const { t } = useTranslation("planner"); + const handleExport = useCallback(() => { // Get waypoints from Yjs const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ @@ -51,7 +54,7 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { onClick={handleExport} className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200" > - Export GPX + {t("exportGpx")} ); } diff --git a/apps/planner/app/components/ProfileSelector.tsx b/apps/planner/app/components/ProfileSelector.tsx index 0f8b629..91d6ac5 100644 --- a/apps/planner/app/components/ProfileSelector.tsx +++ b/apps/planner/app/components/ProfileSelector.tsx @@ -1,19 +1,15 @@ import { useEffect, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; -const PROFILES = [ - { id: "trekking", label: "Hiking" }, - { id: "fastbike", label: "Cycling (fast)" }, - { id: "safety", label: "Cycling (safe)" }, - { id: "shortest", label: "Shortest" }, - { id: "car-eco", label: "Car" }, -]; +const PROFILE_IDS = ["trekking", "fastbike", "safety", "shortest", "car"] as const; interface ProfileSelectorProps { yjs: YjsState; } export function ProfileSelector({ yjs }: ProfileSelectorProps) { + const { t } = useTranslation("planner"); const [profile, setProfile] = useState("trekking"); useEffect(() => { @@ -38,7 +34,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) { return (
    diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 113bb4e..af0389d 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -1,4 +1,5 @@ import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { generateGpx } from "@trails-cool/gpx"; @@ -12,6 +13,7 @@ interface SaveToJournalButtonProps { } export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) { + const { t } = useTranslation("planner"); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); @@ -72,13 +74,13 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl disabled={saving} className="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50" > - {saving ? "Saving..." : "Save to Journal"} + {saving ? t("saving") : t("saveToJournal")} - {saved && Saved!} + {saved && {t("saved")}} {error && {error}} {saved && returnUrl && ( - Return to Journal + {t("returnToJournal")} )}
    diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index cf02c8c..b7b6251 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,4 +1,5 @@ import { Suspense, lazy, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; @@ -25,6 +26,7 @@ interface SessionViewProps { } export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) { + const { t } = useTranslation("planner"); const yjs = useYjs(sessionId, initialWaypoints); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); @@ -36,7 +38,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, if (!yjs) { return (
    -

    Connecting...

    +

    {t("connecting")}

    ); } @@ -45,7 +47,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, <>
    -

    trails.cool Planner

    +

    {t("title")}

    @@ -59,15 +61,15 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, )} {computing && ( - Computing route... + {t("computingRoute")} )} {isHost && ( - Host + {t("host")} )} - {yjs.connected ? "Connected" : "Connecting..."} · {sessionId.slice(0, 8)} + {yjs.connected ? t("connected") : t("connecting")} · {sessionId.slice(0, 8)}
    @@ -84,7 +86,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, - Loading map... + {t("loadingMap")}
} > diff --git a/apps/planner/app/root.tsx b/apps/planner/app/root.tsx index 2b7acf5..ff708ce 100644 --- a/apps/planner/app/root.tsx +++ b/apps/planner/app/root.tsx @@ -2,8 +2,12 @@ import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; +import { useTranslation } from "react-i18next"; +import { initI18n } from "@trails-cool/i18n"; import stylesheet from "@trails-cool/ui/styles.css?url"; +initI18n(); + export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { @@ -30,6 +34,7 @@ export default function App() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { Sentry.captureException(error); + const { t } = useTranslation(); if (isRouteErrorResponse(error)) { return ( @@ -37,9 +42,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {

{error.status}

- {error.status === 404 && "Page not found"} - {error.status === 503 && "Service temporarily unavailable"} - {error.status !== 404 && error.status !== 503 && (error.statusText || "Something went wrong")} + {error.status === 404 && t("pageNotFound")} + {error.status === 503 && t("serviceUnavailable")} + {error.status !== 404 && error.status !== 503 && (error.statusText || t("error"))}

@@ -49,9 +54,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { return (
-

Error

+

{t("error")}

- {error instanceof Error ? error.message : "An unexpected error occurred"} + {error instanceof Error ? error.message : t("error")}

diff --git a/apps/planner/app/routes/home.tsx b/apps/planner/app/routes/home.tsx index 8aa3380..de5d4e9 100644 --- a/apps/planner/app/routes/home.tsx +++ b/apps/planner/app/routes/home.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/home"; export function meta(_args: Route.MetaArgs) { @@ -8,11 +9,13 @@ export function meta(_args: Route.MetaArgs) { } export default function Home() { + const { t } = useTranslation("planner"); + return (
-

trails.cool Planner

-

Collaborative route planning

+

{t("title")}

+

{t("subtitle")}

); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 63d6037..9e86754 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -7,6 +7,9 @@ export default { close: "Schließen", loading: "Wird geladen...", error: "Etwas ist schiefgelaufen", + goHome: "Zur Startseite", + pageNotFound: "Seite nicht gefunden", + serviceUnavailable: "Dienst vorübergehend nicht verfügbar. Bitte versuche es später erneut.", }, planner: { title: "trails.cool Planer", @@ -14,11 +17,22 @@ export default { newSession: "Neue Sitzung", saveRoute: "Route speichern", exportGpx: "GPX exportieren", - profile: "Routing-Profil", + profile: "Profil", + connecting: "Verbinde...", + loadingMap: "Karte wird geladen...", + computingRoute: "Route wird berechnet...", + host: "Host", + connected: "Verbunden", + saveToJournal: "Im Journal speichern", + saving: "Speichert...", + saved: "Gespeichert!", + returnToJournal: "Zurück zum Journal", profiles: { trekking: "Wandern", - bike: "Radfahren", + fastbike: "Radfahren (schnell)", + safety: "Radfahren (sicher)", shortest: "Kürzeste", + car: "Auto", }, elevation: { gain: "Höhenmeter aufwärts", @@ -29,16 +43,25 @@ export default { journal: { title: "trails.cool", subtitle: "Dein Outdoor-Aktivitäten-Tagebuch", + welcome: "Willkommen,", + addPasskeyPrompt: "Füge einen Passkey hinzu, um dich schneller auf diesem Gerät anzumelden.", + addPasskey: "Passkey hinzufügen", + settingUp: "Wird eingerichtet...", + passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.", routes: { title: "Routen", + myRoutes: "Meine Routen", new: "Neue Route", edit: "Route bearbeiten", editInPlanner: "Im Planer bearbeiten", + opening: "Öffne...", importGpx: "GPX importieren", exportGpx: "GPX exportieren", delete: "Route löschen", distance: "Strecke", elevationGain: "Höhenmeter", + noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", + saveChanges: "Änderungen speichern", }, activities: { title: "Aktivitäten", @@ -52,8 +75,20 @@ export default { register: "Registrieren", logout: "Abmelden", email: "E-Mail", - password: "Passwort", username: "Benutzername", + signInWithPasskey: "Mit Passkey anmelden", + authenticating: "Authentifiziere...", + useMagicLink: "Kein Passkey auf diesem Gerät? Nutze stattdessen einen Magic Link", + magicLinkHelp: "Wir senden dir einen Anmeldelink per E-Mail.", + sendMagicLink: "Magic Link senden", + sending: "Wird gesendet...", + backToPasskey: "Zurück zur Passkey-Anmeldung", + checkEmail: "Prüfe deine E-Mails! Wir haben einen Anmeldelink gesendet an", + linkExpires: "Der Link ist 15 Minuten gültig.", + noAccount: "Noch kein Konto?", + registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", + registerWithPasskey: "Mit Passkey registrieren", + creatingPasskey: "Erstelle Passkey...", }, }, } as const; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bffca1f..c5b3837 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -7,6 +7,9 @@ export default { close: "Close", loading: "Loading...", error: "Something went wrong", + goHome: "Go home", + pageNotFound: "Page not found", + serviceUnavailable: "Service temporarily unavailable. Please try again later.", }, planner: { title: "trails.cool Planner", @@ -14,11 +17,22 @@ export default { newSession: "New Session", saveRoute: "Save Route", exportGpx: "Export GPX", - profile: "Routing Profile", + profile: "Profile", + connecting: "Connecting...", + loadingMap: "Loading map...", + computingRoute: "Computing route...", + host: "Host", + connected: "Connected", + saveToJournal: "Save to Journal", + saving: "Saving...", + saved: "Saved!", + returnToJournal: "Return to Journal", profiles: { trekking: "Hiking", - bike: "Cycling", + fastbike: "Cycling (fast)", + safety: "Cycling (safe)", shortest: "Shortest", + car: "Car", }, elevation: { gain: "Elevation Gain", @@ -29,16 +43,25 @@ export default { journal: { title: "trails.cool", subtitle: "Your outdoor activity journal", + welcome: "Welcome,", + addPasskeyPrompt: "Add a passkey for faster sign-in on this device.", + addPasskey: "Add Passkey", + settingUp: "Setting up...", + passkeyAdded: "Passkey added! You can now sign in instantly on this device.", routes: { title: "Routes", + myRoutes: "My Routes", new: "New Route", edit: "Edit Route", editInPlanner: "Edit in Planner", + opening: "Opening...", importGpx: "Import GPX", exportGpx: "Export GPX", delete: "Delete Route", distance: "Distance", elevationGain: "Elevation Gain", + noRoutesYet: "No routes yet. Create your first route!", + saveChanges: "Save Changes", }, activities: { title: "Activities", @@ -48,12 +71,24 @@ export default { createRouteFromActivity: "Create Route from Activity", }, auth: { - login: "Log In", - register: "Sign Up", + login: "Sign In", + register: "Register", logout: "Log Out", email: "Email", - password: "Password", username: "Username", + signInWithPasskey: "Sign in with Passkey", + authenticating: "Authenticating...", + useMagicLink: "No passkey on this device? Use a magic link instead", + magicLinkHelp: "We'll send a login link to your email.", + sendMagicLink: "Send Magic Link", + sending: "Sending...", + backToPasskey: "Back to passkey login", + checkEmail: "Check your email! We sent a login link to", + linkExpires: "The link expires in 15 minutes.", + noAccount: "Don't have an account?", + registerDescription: "Register with a passkey — no password needed.", + registerWithPasskey: "Register with Passkey", + creatingPasskey: "Creating passkey...", }, }, } as const; From df845ec61062da001c4dc1d1852fa356797a9a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:17:13 +0100 Subject: [PATCH 018/835] Fix E2E test for renamed register heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The i18n change renamed "Create Account" to "Register" — update the E2E test to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/journal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/journal.test.ts b/e2e/journal.test.ts index e7699a6..08f99b3 100644 --- a/e2e/journal.test.ts +++ b/e2e/journal.test.ts @@ -15,7 +15,7 @@ test.describe("Journal", () => { test("registration page renders correctly", async ({ page }) => { await page.goto("/auth/register"); - await expect(page.getByText("Create Account")).toBeVisible(); + await expect(page.getByText("Register")).toBeVisible(); await expect(page.getByLabel("Email")).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByRole("button", { name: /Register with Passkey/ })).toBeVisible(); From 4ecb7a93008d50a5a618206857ec12548ba1f41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:26:12 +0100 Subject: [PATCH 019/835] Fix E2E: use heading role for register page check "Register" appears 3 times on the page (heading, button text, link). Use getByRole("heading") to target the h1 specifically. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/journal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/journal.test.ts b/e2e/journal.test.ts index 08f99b3..b2863fa 100644 --- a/e2e/journal.test.ts +++ b/e2e/journal.test.ts @@ -15,7 +15,7 @@ test.describe("Journal", () => { test("registration page renders correctly", async ({ page }) => { await page.goto("/auth/register"); - await expect(page.getByText("Register")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Register" })).toBeVisible(); await expect(page.getByLabel("Email")).toBeVisible(); await expect(page.getByLabel("Username")).toBeVisible(); await expect(page.getByRole("button", { name: /Register with Passkey/ })).toBeVisible(); From 119dc25f5a06f2c25455ee4151651e272be82bd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:48:16 +0100 Subject: [PATCH 020/835] Disable Sentry in CI, tag environment correctly Client: VITE_SENTRY_ENVIRONMENT=ci baked in during CI build, disables Sentry and sets 0 sample rates. Server: checks process.env.CI to disable and tag as "ci" environment. Production remains enabled with "production" environment tag. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 ++ apps/journal/app/entry.client.tsx | 11 +++++++---- apps/journal/app/entry.server.tsx | 6 ++++-- apps/planner/app/entry.client.tsx | 11 +++++++---- apps/planner/server.ts | 6 ++++-- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7487db2..e6a6846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,8 @@ jobs: - name: Build for production run: pnpm build + env: + VITE_SENTRY_ENVIRONMENT: ci - name: Run E2E tests run: pnpm test:e2e diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 4536727..656a377 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -3,14 +3,17 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; +const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? + (import.meta.env.PROD ? "production" : "development"); + Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], - environment: import.meta.env.PROD ? "production" : "development", - tracesSampleRate: 0.1, + environment: sentryEnvironment, + tracesSampleRate: sentryEnvironment === "ci" ? 0 : 0.1, replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: 1.0, - enabled: import.meta.env.PROD, + replaysOnErrorSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); startTransition(() => { diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index b40a03d..8883803 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -7,12 +7,14 @@ import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; +const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); + Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", release: process.env.SENTRY_RELEASE, - environment: process.env.NODE_ENV ?? "development", + environment: sentryEnvironment, tracesSampleRate: 0.1, - enabled: process.env.NODE_ENV === "production", + enabled: process.env.NODE_ENV === "production" && !process.env.CI, }); export const streamTimeout = 5_000; diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index 8f1d055..d06f0f5 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -3,14 +3,17 @@ import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; +const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? + (import.meta.env.PROD ? "production" : "development"); + Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], - environment: import.meta.env.PROD ? "production" : "development", - tracesSampleRate: 0.1, + environment: sentryEnvironment, + tracesSampleRate: sentryEnvironment === "ci" ? 0 : 0.1, replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: 1.0, - enabled: import.meta.env.PROD, + replaysOnErrorSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); startTransition(() => { diff --git a/apps/planner/server.ts b/apps/planner/server.ts index a8e1d59..e61a801 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -5,12 +5,14 @@ import { createReadStream, statSync } from "node:fs"; import { join, extname, resolve } from "node:path"; import { setupYjsWebSocket } from "./app/lib/yjs-server.ts"; +const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); + Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", release: process.env.SENTRY_RELEASE, - environment: process.env.NODE_ENV ?? "development", + environment: sentryEnvironment, tracesSampleRate: 0.1, - enabled: process.env.NODE_ENV === "production", + enabled: process.env.NODE_ENV === "production" && !process.env.CI, }); const port = Number(process.env.PORT ?? 3001); From 4291fa6c3ff26f859fb7ad50e9ccf8fcb4fdba1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:51:17 +0100 Subject: [PATCH 021/835] Set all Sentry sample rates to 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No users yet — capture everything. Traces, session replays, and error replays all at 100%. Easy to dial back when traffic grows. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/entry.client.tsx | 6 +++--- apps/journal/app/entry.server.tsx | 2 +- apps/planner/app/entry.client.tsx | 6 +++--- apps/planner/server.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 656a377..449a4a7 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -10,9 +10,9 @@ Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], environment: sentryEnvironment, - tracesSampleRate: sentryEnvironment === "ci" ? 0 : 0.1, - replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + replaysOnErrorSampleRate: 1.0, enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 8883803..e7df39c 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -13,7 +13,7 @@ Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", release: process.env.SENTRY_RELEASE, environment: sentryEnvironment, - tracesSampleRate: 0.1, + tracesSampleRate: 1.0, enabled: process.env.NODE_ENV === "production" && !process.env.CI, }); diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index d06f0f5..b266770 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -10,9 +10,9 @@ Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], environment: sentryEnvironment, - tracesSampleRate: sentryEnvironment === "ci" ? 0 : 0.1, - replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, + replaysOnErrorSampleRate: 1.0, enabled: import.meta.env.PROD && sentryEnvironment !== "ci", }); diff --git a/apps/planner/server.ts b/apps/planner/server.ts index e61a801..d4cfc93 100644 --- a/apps/planner/server.ts +++ b/apps/planner/server.ts @@ -11,7 +11,7 @@ Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", release: process.env.SENTRY_RELEASE, environment: sentryEnvironment, - tracesSampleRate: 0.1, + tracesSampleRate: 1.0, enabled: process.env.NODE_ENV === "production" && !process.env.CI, }); From e5304927538d53e62f88f0fac2380fae688ea613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:55:28 +0100 Subject: [PATCH 022/835] 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) --- .../2026-03-25-phase-1-mvp/.openspec.yaml | 2 + .../archive/2026-03-25-phase-1-mvp/design.md | 130 ++++++++++++++++++ .../2026-03-25-phase-1-mvp/proposal.md | 43 ++++++ .../specs/activity-feed/spec.md | 44 ++++++ .../specs/brouter-integration/spec.md | 58 ++++++++ .../specs/infrastructure/spec.md | 65 +++++++++ .../specs/journal-auth/spec.md | 89 ++++++++++++ .../specs/map-display/spec.md | 52 +++++++ .../specs/planner-journal-handoff/spec.md | 41 ++++++ .../specs/planner-session/spec.md | 81 +++++++++++ .../specs/route-management/spec.md | 75 ++++++++++ .../specs/shared-packages/spec.md | 56 ++++++++ .../archive/2026-03-25-phase-1-mvp/tasks.md | 117 ++++++++++++++++ openspec/specs/activity-feed/spec.md | 44 ++++++ openspec/specs/brouter-integration/spec.md | 58 ++++++++ openspec/specs/infrastructure/spec.md | 65 +++++++++ openspec/specs/journal-auth/spec.md | 89 ++++++++++++ openspec/specs/map-display/spec.md | 52 +++++++ .../specs/planner-journal-handoff/spec.md | 41 ++++++ openspec/specs/planner-session/spec.md | 81 +++++++++++ openspec/specs/route-management/spec.md | 75 ++++++++++ openspec/specs/shared-packages/spec.md | 56 ++++++++ 22 files changed, 1414 insertions(+) create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/design.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/proposal.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/activity-feed/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/brouter-integration/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/infrastructure/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/journal-auth/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/map-display/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-journal-handoff/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-session/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/route-management/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/specs/shared-packages/spec.md create mode 100644 openspec/changes/archive/2026-03-25-phase-1-mvp/tasks.md create mode 100644 openspec/specs/activity-feed/spec.md create mode 100644 openspec/specs/brouter-integration/spec.md create mode 100644 openspec/specs/infrastructure/spec.md create mode 100644 openspec/specs/journal-auth/spec.md create mode 100644 openspec/specs/map-display/spec.md create mode 100644 openspec/specs/planner-journal-handoff/spec.md create mode 100644 openspec/specs/planner-session/spec.md create mode 100644 openspec/specs/route-management/spec.md create mode 100644 openspec/specs/shared-packages/spec.md diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/.openspec.yaml b/openspec/changes/archive/2026-03-25-phase-1-mvp/.openspec.yaml new file mode 100644 index 0000000..caac517 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-22 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/design.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/design.md new file mode 100644 index 0000000..126b53b --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/design.md @@ -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. diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/proposal.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/proposal.md new file mode 100644 index 0000000..bf686d7 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/proposal.md @@ -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) diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/activity-feed/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/activity-feed/spec.md new file mode 100644 index 0000000..aed90e4 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/activity-feed/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/brouter-integration/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/brouter-integration/spec.md new file mode 100644 index 0000000..4c477ac --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/brouter-integration/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/infrastructure/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/infrastructure/spec.md new file mode 100644 index 0000000..69342ba --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/infrastructure/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/journal-auth/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/journal-auth/spec.md new file mode 100644 index 0000000..2a6d055 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/journal-auth/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/map-display/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/map-display/spec.md new file mode 100644 index 0000000..7975af0 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/map-display/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-journal-handoff/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-journal-handoff/spec.md new file mode 100644 index 0000000..960df69 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-journal-handoff/spec.md @@ -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=&token=` 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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-session/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-session/spec.md new file mode 100644 index 0000000..9e20ba9 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/planner-session/spec.md @@ -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/` + +#### Scenario: Create session from Journal callback +- **WHEN** the Journal opens `planner.trails.cool/new?callback=&token=&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/` +- **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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/route-management/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/route-management/spec.md new file mode 100644 index 0000000..c224ae5 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/route-management/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/shared-packages/spec.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/shared-packages/spec.md new file mode 100644 index 0000000..e40a8e1 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/specs/shared-packages/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-03-25-phase-1-mvp/tasks.md b/openspec/changes/archive/2026-03-25-phase-1-mvp/tasks.md new file mode 100644 index 0000000..9f9e882 --- /dev/null +++ b/openspec/changes/archive/2026-03-25-phase-1-mvp/tasks.md @@ -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 diff --git a/openspec/specs/activity-feed/spec.md b/openspec/specs/activity-feed/spec.md new file mode 100644 index 0000000..aed90e4 --- /dev/null +++ b/openspec/specs/activity-feed/spec.md @@ -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 diff --git a/openspec/specs/brouter-integration/spec.md b/openspec/specs/brouter-integration/spec.md new file mode 100644 index 0000000..4c477ac --- /dev/null +++ b/openspec/specs/brouter-integration/spec.md @@ -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 diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md new file mode 100644 index 0000000..69342ba --- /dev/null +++ b/openspec/specs/infrastructure/spec.md @@ -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 diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md new file mode 100644 index 0000000..2a6d055 --- /dev/null +++ b/openspec/specs/journal-auth/spec.md @@ -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 diff --git a/openspec/specs/map-display/spec.md b/openspec/specs/map-display/spec.md new file mode 100644 index 0000000..7975af0 --- /dev/null +++ b/openspec/specs/map-display/spec.md @@ -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 diff --git a/openspec/specs/planner-journal-handoff/spec.md b/openspec/specs/planner-journal-handoff/spec.md new file mode 100644 index 0000000..960df69 --- /dev/null +++ b/openspec/specs/planner-journal-handoff/spec.md @@ -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=&token=` 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 diff --git a/openspec/specs/planner-session/spec.md b/openspec/specs/planner-session/spec.md new file mode 100644 index 0000000..9e20ba9 --- /dev/null +++ b/openspec/specs/planner-session/spec.md @@ -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/` + +#### Scenario: Create session from Journal callback +- **WHEN** the Journal opens `planner.trails.cool/new?callback=&token=&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/` +- **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 diff --git a/openspec/specs/route-management/spec.md b/openspec/specs/route-management/spec.md new file mode 100644 index 0000000..c224ae5 --- /dev/null +++ b/openspec/specs/route-management/spec.md @@ -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 diff --git a/openspec/specs/shared-packages/spec.md b/openspec/specs/shared-packages/spec.md new file mode 100644 index 0000000..e40a8e1 --- /dev/null +++ b/openspec/specs/shared-packages/spec.md @@ -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 From 3a519ea05e121f492760c658611241efda04e2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 02:55:43 +0100 Subject: [PATCH 023/835] Remove old phase-1-mvp location (moved to archive) Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/changes/phase-1-mvp/.openspec.yaml | 2 - openspec/changes/phase-1-mvp/design.md | 130 ------------------ openspec/changes/phase-1-mvp/proposal.md | 43 ------ .../phase-1-mvp/specs/activity-feed/spec.md | 44 ------ .../specs/brouter-integration/spec.md | 58 -------- .../phase-1-mvp/specs/infrastructure/spec.md | 65 --------- .../phase-1-mvp/specs/journal-auth/spec.md | 89 ------------ .../phase-1-mvp/specs/map-display/spec.md | 52 ------- .../specs/planner-journal-handoff/spec.md | 41 ------ .../phase-1-mvp/specs/planner-session/spec.md | 81 ----------- .../specs/route-management/spec.md | 75 ---------- .../phase-1-mvp/specs/shared-packages/spec.md | 56 -------- openspec/changes/phase-1-mvp/tasks.md | 117 ---------------- 13 files changed, 853 deletions(-) delete mode 100644 openspec/changes/phase-1-mvp/.openspec.yaml delete mode 100644 openspec/changes/phase-1-mvp/design.md delete mode 100644 openspec/changes/phase-1-mvp/proposal.md delete mode 100644 openspec/changes/phase-1-mvp/specs/activity-feed/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/brouter-integration/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/infrastructure/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/journal-auth/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/map-display/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/planner-journal-handoff/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/planner-session/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/route-management/spec.md delete mode 100644 openspec/changes/phase-1-mvp/specs/shared-packages/spec.md delete mode 100644 openspec/changes/phase-1-mvp/tasks.md diff --git a/openspec/changes/phase-1-mvp/.openspec.yaml b/openspec/changes/phase-1-mvp/.openspec.yaml deleted file mode 100644 index caac517..0000000 --- a/openspec/changes/phase-1-mvp/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-03-22 diff --git a/openspec/changes/phase-1-mvp/design.md b/openspec/changes/phase-1-mvp/design.md deleted file mode 100644 index 126b53b..0000000 --- a/openspec/changes/phase-1-mvp/design.md +++ /dev/null @@ -1,130 +0,0 @@ -## 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. diff --git a/openspec/changes/phase-1-mvp/proposal.md b/openspec/changes/phase-1-mvp/proposal.md deleted file mode 100644 index bf686d7..0000000 --- a/openspec/changes/phase-1-mvp/proposal.md +++ /dev/null @@ -1,43 +0,0 @@ -## 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) diff --git a/openspec/changes/phase-1-mvp/specs/activity-feed/spec.md b/openspec/changes/phase-1-mvp/specs/activity-feed/spec.md deleted file mode 100644 index aed90e4..0000000 --- a/openspec/changes/phase-1-mvp/specs/activity-feed/spec.md +++ /dev/null @@ -1,44 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/brouter-integration/spec.md b/openspec/changes/phase-1-mvp/specs/brouter-integration/spec.md deleted file mode 100644 index 4c477ac..0000000 --- a/openspec/changes/phase-1-mvp/specs/brouter-integration/spec.md +++ /dev/null @@ -1,58 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/infrastructure/spec.md b/openspec/changes/phase-1-mvp/specs/infrastructure/spec.md deleted file mode 100644 index 69342ba..0000000 --- a/openspec/changes/phase-1-mvp/specs/infrastructure/spec.md +++ /dev/null @@ -1,65 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md b/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md deleted file mode 100644 index 2a6d055..0000000 --- a/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md +++ /dev/null @@ -1,89 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/map-display/spec.md b/openspec/changes/phase-1-mvp/specs/map-display/spec.md deleted file mode 100644 index 7975af0..0000000 --- a/openspec/changes/phase-1-mvp/specs/map-display/spec.md +++ /dev/null @@ -1,52 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/planner-journal-handoff/spec.md b/openspec/changes/phase-1-mvp/specs/planner-journal-handoff/spec.md deleted file mode 100644 index 960df69..0000000 --- a/openspec/changes/phase-1-mvp/specs/planner-journal-handoff/spec.md +++ /dev/null @@ -1,41 +0,0 @@ -## 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=&token=` 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 diff --git a/openspec/changes/phase-1-mvp/specs/planner-session/spec.md b/openspec/changes/phase-1-mvp/specs/planner-session/spec.md deleted file mode 100644 index 9e20ba9..0000000 --- a/openspec/changes/phase-1-mvp/specs/planner-session/spec.md +++ /dev/null @@ -1,81 +0,0 @@ -## 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/` - -#### Scenario: Create session from Journal callback -- **WHEN** the Journal opens `planner.trails.cool/new?callback=&token=&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/` -- **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 diff --git a/openspec/changes/phase-1-mvp/specs/route-management/spec.md b/openspec/changes/phase-1-mvp/specs/route-management/spec.md deleted file mode 100644 index c224ae5..0000000 --- a/openspec/changes/phase-1-mvp/specs/route-management/spec.md +++ /dev/null @@ -1,75 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/specs/shared-packages/spec.md b/openspec/changes/phase-1-mvp/specs/shared-packages/spec.md deleted file mode 100644 index e40a8e1..0000000 --- a/openspec/changes/phase-1-mvp/specs/shared-packages/spec.md +++ /dev/null @@ -1,56 +0,0 @@ -## 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 diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md deleted file mode 100644 index 9f9e882..0000000 --- a/openspec/changes/phase-1-mvp/tasks.md +++ /dev/null @@ -1,117 +0,0 @@ -## 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 From 9a904d9f691cbe0dddcb184d65e12b2f9215cea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 03:11:07 +0100 Subject: [PATCH 024/835] Sentry improvements: context, tracing, source maps, privacy manifest - Set Sentry user context (id, username) in Journal root for all routes - Tag Planner errors with session_id - Use reactRouterV7BrowserTracingIntegration for route-aware traces - Hidden source maps (no sourceMappingURL in bundles, .map deleted after upload to Sentry) - Privacy manifest at /privacy documenting all data collection - robots.txt blocking all bots on both apps - Suppress i18next promotional console log Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/entry.client.tsx | 13 ++- apps/journal/app/root.tsx | 18 +++- apps/journal/app/routes/home.tsx | 6 ++ apps/journal/app/routes/privacy.tsx | 98 +++++++++++++++++++ apps/journal/public/robots.txt | 2 + apps/journal/vite.config.ts | 3 +- apps/planner/app/components/SessionView.tsx | 4 +- apps/planner/app/entry.client.tsx | 13 ++- apps/planner/public/robots.txt | 2 + apps/planner/vite.config.ts | 3 +- .../sentry-improvements/.openspec.yaml | 2 + .../changes/sentry-improvements/design.md | 38 +++++++ .../changes/sentry-improvements/proposal.md | 28 ++++++ .../specs/infrastructure/spec.md | 24 +++++ openspec/changes/sentry-improvements/tasks.md | 24 +++++ packages/i18n/src/index.ts | 1 + 16 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 apps/journal/app/routes/privacy.tsx create mode 100644 apps/journal/public/robots.txt create mode 100644 apps/planner/public/robots.txt create mode 100644 openspec/changes/sentry-improvements/.openspec.yaml create mode 100644 openspec/changes/sentry-improvements/design.md create mode 100644 openspec/changes/sentry-improvements/proposal.md create mode 100644 openspec/changes/sentry-improvements/specs/infrastructure/spec.md create mode 100644 openspec/changes/sentry-improvements/tasks.md diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 449a4a7..1efd2c4 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -1,14 +1,25 @@ import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; +import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); Sentry.init({ dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", - integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + Sentry.replayIntegration(), + ], environment: sentryEnvironment, tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 6802b50..51db198 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,9 +1,11 @@ +import { useEffect } from "react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; import { initI18n } from "@trails-cool/i18n"; +import { getSessionUser } from "~/lib/auth.server"; import stylesheet from "@trails-cool/ui/styles.css?url"; initI18n(); @@ -28,7 +30,21 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } -export default function App() { +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + return { user: user ? { id: user.id, username: user.username } : null }; +} + +export default function App({ loaderData }: Route.ComponentProps) { + const user = loaderData?.user; + useEffect(() => { + if (user) { + Sentry.setUser({ id: user.id, username: user.username }); + } else { + Sentry.setUser(null); + } + }, [user]); + return ; } diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index 196ad10..b32dd7b 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -125,6 +125,12 @@ export default function Home({ loaderData }: Route.ComponentProps) {
)} + +
); } diff --git a/apps/journal/app/routes/privacy.tsx b/apps/journal/app/routes/privacy.tsx new file mode 100644 index 0000000..b0c1fd0 --- /dev/null +++ b/apps/journal/app/routes/privacy.tsx @@ -0,0 +1,98 @@ +import type { Route } from "./+types/privacy"; + +export function meta(_args: Route.MetaArgs) { + return [{ title: "Privacy — trails.cool" }]; +} + +export default function PrivacyPage() { + return ( +
+

Privacy Manifest

+

+ trails.cool is committed to privacy by design. This manifest documents + everything we collect, why, and how you can control it. +

+ +
+

Planner (planner.trails.cool)

+

+ The Planner collects no personal data. Sessions are anonymous — + there are no user accounts, no tracking, and no analytics on your routes. +

+
    +
  • No cookies (except ephemeral session state)
  • +
  • No user accounts or login
  • +
  • No route data is stored permanently without your action
  • +
  • Session data is automatically deleted after 7 days of inactivity
  • +
+
+ +
+

Journal (trails.cool)

+

+ The Journal stores the data you explicitly provide: +

+
    +
  • Account data: email, username, display name, passkey credentials
  • +
  • Routes: name, description, GPX data, geometry
  • +
  • Activities: title, description, date, linked routes
  • +
+

+ All your data is exportable at any time in open formats (GPX, JSON). + You can self-host your own instance and migrate your data completely. +

+
+ +
+

Error Tracking (Sentry)

+

+ Both apps use Sentry for + error monitoring. This helps us find and fix bugs quickly. +

+

What Sentry collects:

+
    +
  • Error details: stack traces, error messages, browser/OS info
  • +
  • Performance traces: page load times, route navigation timing
  • +
  • Session replays on error: a recording of the session leading up to an error (DOM snapshots, not video)
  • +
  • User context (Journal only): user ID and username are attached to errors for debugging
  • +
  • Session ID (Planner only): the anonymous session ID is attached to errors
  • +
+

What Sentry does NOT collect:

+
    +
  • Route or GPX data
  • +
  • Passwords or passkey credentials
  • +
  • Form input contents (masked in replays)
  • +
+

Data retention:

+

+ Sentry data is retained for 90 days and then automatically deleted. + Sentry's servers are hosted in the EU (Frankfurt). +

+
+ +
+

Third Parties

+
    +
  • Sentry (Functional Software Inc.) — error tracking, as described above
  • +
  • OpenStreetMap — map tiles are loaded from OSM tile servers. OSM's privacy policy applies to tile requests.
  • +
  • BRouter — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.
  • +
+
+ +
+

What We Don't Do

+
    +
  • We don't sell data
  • +
  • We don't show ads
  • +
  • We don't build user profiles
  • +
  • We don't use tracking pixels or analytics
  • +
  • We don't share data with anyone except as listed above
  • +
+
+ +

+ Last updated: March 2026. If this manifest changes, we'll note it here. +

+
+ ); +} diff --git a/apps/journal/public/robots.txt b/apps/journal/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/apps/journal/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/apps/journal/vite.config.ts b/apps/journal/vite.config.ts index 44fe7d1..e799794 100644 --- a/apps/journal/vite.config.ts +++ b/apps/journal/vite.config.ts @@ -6,7 +6,7 @@ import path from "node:path"; export default defineConfig({ build: { - sourcemap: true, + sourcemap: "hidden", }, plugins: [ tailwindcss(), @@ -15,6 +15,7 @@ export default defineConfig({ org: "trails-qq", project: "journal", release: { name: process.env.SENTRY_RELEASE }, + sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] }, disable: !process.env.SENTRY_AUTH_TOKEN, }), ], diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index b7b6251..bf56abc 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,5 +1,6 @@ -import { Suspense, lazy, useState, useCallback } from "react"; +import { Suspense, lazy, useState, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import * as Sentry from "@sentry/react"; import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; @@ -27,6 +28,7 @@ interface SessionViewProps { export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) { const { t } = useTranslation("planner"); + useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]); const yjs = useYjs(sessionId, initialWaypoints); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); diff --git a/apps/planner/app/entry.client.tsx b/apps/planner/app/entry.client.tsx index b266770..b425aa0 100644 --- a/apps/planner/app/entry.client.tsx +++ b/apps/planner/app/entry.client.tsx @@ -1,14 +1,25 @@ import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; +import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? (import.meta.env.PROD ? "production" : "development"); Sentry.init({ dsn: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208", - integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()], + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + Sentry.replayIntegration(), + ], environment: sentryEnvironment, tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, diff --git a/apps/planner/public/robots.txt b/apps/planner/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/apps/planner/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/apps/planner/vite.config.ts b/apps/planner/vite.config.ts index a97a32a..cb4d8a8 100644 --- a/apps/planner/vite.config.ts +++ b/apps/planner/vite.config.ts @@ -7,7 +7,7 @@ import { yjsDevPlugin } from "./app/lib/vite-yjs-plugin.ts"; export default defineConfig({ build: { - sourcemap: true, + sourcemap: "hidden", }, plugins: [ tailwindcss(), @@ -17,6 +17,7 @@ export default defineConfig({ org: "trails-qq", project: "planner", release: { name: process.env.SENTRY_RELEASE }, + sourcemaps: { filesToDeleteAfterUpload: ["./build/**/*.map"] }, disable: !process.env.SENTRY_AUTH_TOKEN, }), ], diff --git a/openspec/changes/sentry-improvements/.openspec.yaml b/openspec/changes/sentry-improvements/.openspec.yaml new file mode 100644 index 0000000..40c5540 --- /dev/null +++ b/openspec/changes/sentry-improvements/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-25 diff --git a/openspec/changes/sentry-improvements/design.md b/openspec/changes/sentry-improvements/design.md new file mode 100644 index 0000000..ee91875 --- /dev/null +++ b/openspec/changes/sentry-improvements/design.md @@ -0,0 +1,38 @@ +## Context + +Both apps have basic Sentry (`@sentry/react` + `@sentry/node`) with source map uploads, release tracking, and environment tagging. Errors appear in Sentry but lack user/session context, making triage slow. Source maps are also served publicly. + +## Goals / Non-Goals + +**Goals:** +- Identify which user or session caused an error without guessing +- Route-level performance traces (not just page loads) +- Stop serving source maps to browsers + +**Non-Goals:** +- Custom Sentry dashboards or alert rules (configure in Sentry UI) +- Server-side performance instrumentation beyond request handling +- Profiling integration + +## Decisions + +### D1: Set user context via Sentry.setUser in root loader + +Journal's root loader already fetches the session user. Call `Sentry.setUser({ id, username })` on the client when authenticated, `Sentry.setUser(null)` when not. Done in root.tsx since it runs on every navigation. + +### D2: Tag planner errors with session ID via Sentry.setTag + +In `SessionView`, call `Sentry.setTag("session_id", sessionId)` on mount. This tags all subsequent errors with the active session. + +### D3: Use reactRouterV7BrowserTracingIntegration + +Sentry provides `Sentry.reactRouterV7BrowserTracingIntegration` which hooks into React Router's navigation to create route-aware spans. Replace the generic `browserTracingIntegration()` in both `entry.client.tsx` files. Requires passing `useEffect` and `useLocation` from react-router. + +### D4: Hidden source maps via Vite config + +Change `build.sourcemap` from `true` to `"hidden"`. This generates `.map` files for the Sentry plugin to upload but doesn't add `//# sourceMappingURL` comments to the bundles. Browsers won't request the map files. The Sentry plugin's `sourcemaps.filesToDeleteAfterUpload` option can also clean them from the build output. + +## Risks / Trade-offs + +- **D3 couples Sentry to React Router version** → Acceptable; we already depend on both. If we upgrade React Router, we upgrade the Sentry integration too. +- **D4 makes browser debugging harder in production** → Acceptable; errors go to Sentry with full source context. DevTools debugging in production was never a supported workflow. diff --git a/openspec/changes/sentry-improvements/proposal.md b/openspec/changes/sentry-improvements/proposal.md new file mode 100644 index 0000000..9c0dd38 --- /dev/null +++ b/openspec/changes/sentry-improvements/proposal.md @@ -0,0 +1,28 @@ +## Why + +Sentry is deployed but errors lack context — we don't know which user or session triggered them, route-level performance is invisible, and source maps are served to clients unnecessarily. These gaps make debugging harder and leak internal details. + +## What Changes + +- Set Sentry user context when a Journal user is authenticated (user ID, username) +- Tag Planner errors with the session ID for debugging +- Replace generic `browserTracingIntegration` with `reactRouterV7BrowserTracingIntegration` for route-aware performance traces +- Configure Vite to produce source maps as `hidden` (uploaded to Sentry but not referenced in the bundle, so browsers don't fetch them) +- Wrap server-side request handlers with Sentry error capturing for unhandled exceptions + +## Capabilities + +### New Capabilities + +(None — this enhances the existing Sentry integration, no new behavioral capabilities.) + +### Modified Capabilities + +- `infrastructure`: Adding Sentry context enrichment and source map handling to the deployment pipeline + +## Impact + +- **Files**: `entry.client.tsx` (both apps), `entry.server.tsx` (journal), `server.ts` (planner), `vite.config.ts` (both apps), Journal root loader or layout +- **Dependencies**: No new packages — `@sentry/react` and `@sentry/node` already support all features +- **APIs**: No API changes +- **Security**: Source maps no longer served to clients (currently `.map` files are publicly accessible) diff --git a/openspec/changes/sentry-improvements/specs/infrastructure/spec.md b/openspec/changes/sentry-improvements/specs/infrastructure/spec.md new file mode 100644 index 0000000..478e701 --- /dev/null +++ b/openspec/changes/sentry-improvements/specs/infrastructure/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Sentry error tracking +The system SHALL enrich Sentry events with user and session context, use route-aware tracing, and prevent source maps from being served to clients. + +#### Scenario: Journal error includes user context +- **WHEN** an authenticated Journal user triggers an error +- **THEN** the Sentry event SHALL include the user's ID and username + +#### Scenario: Journal error without user context +- **WHEN** an unauthenticated visitor triggers an error +- **THEN** the Sentry event SHALL have no user context (Sentry.setUser(null)) + +#### Scenario: Planner error includes session ID +- **WHEN** an error occurs during a Planner session +- **THEN** the Sentry event SHALL include a `session_id` tag with the active session ID + +#### Scenario: Route-level performance traces +- **WHEN** a user navigates between routes in either app +- **THEN** Sentry SHALL create a transaction span named after the route pattern (e.g., `/routes/:id`) + +#### Scenario: Source maps not served to clients +- **WHEN** a client requests a `.map` file from the production server +- **THEN** the server SHALL return 404 (source maps are uploaded to Sentry during build, not shipped in the bundle) diff --git a/openspec/changes/sentry-improvements/tasks.md b/openspec/changes/sentry-improvements/tasks.md new file mode 100644 index 0000000..f13cff5 --- /dev/null +++ b/openspec/changes/sentry-improvements/tasks.md @@ -0,0 +1,24 @@ +## 1. User & Session Context + +- [x] 1.1 Set Sentry user context (id, username) in Journal root loader/component when authenticated, clear when not +- [x] 1.2 Set Sentry `session_id` tag in Planner's SessionView on mount + +## 2. Route-Aware Tracing + +- [x] 2.1 Replace `browserTracingIntegration()` with `reactRouterV7BrowserTracingIntegration` in planner entry.client.tsx +- [x] 2.2 Replace `browserTracingIntegration()` with `reactRouterV7BrowserTracingIntegration` in journal entry.client.tsx + +## 3. Source Map Hardening + +- [x] 3.1 Change `build.sourcemap` from `true` to `"hidden"` in both vite.config.ts files +- [x] 3.2 Add `sourcemaps.filesToDeleteAfterUpload` to the Sentry Vite plugin config to clean up .map files after upload + +## 4. Privacy Manifest + +- [x] 4.1 Create `/privacy` route in Journal with a user-visible privacy manifest documenting: what Sentry collects (errors, traces, session replays), what the Planner does NOT collect, data retention, and third-party disclosure (Sentry) +- [x] 4.2 Add link to privacy manifest in Journal footer/home page + +## 5. Verify + +- [x] 5.1 Test locally: build, check no `.map` files remain in build output (or no sourceMappingURL in bundles) +- [x] 5.2 Test locally: trigger error in Journal while logged in, verify Sentry.setUser was called (check devtools) diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 807b0d9..a50dca9 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -23,6 +23,7 @@ export function initI18n() { interpolation: { escapeValue: false, }, + showSupportNotice: false, }); } From 9d51fd6b3669b3fac5f108d67c271f1dafc03477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 03:13:36 +0100 Subject: [PATCH 025/835] Archive sentry-improvements, sync specs Sync Sentry error tracking requirement (5 scenarios) to main infrastructure spec. Archive completed change. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../.openspec.yaml | 0 .../2026-03-25-sentry-improvements}/design.md | 0 .../proposal.md | 0 .../specs/infrastructure/spec.md | 0 .../2026-03-25-sentry-improvements}/tasks.md | 0 openspec/specs/infrastructure/spec.md | 23 +++++++++++++++++++ 6 files changed, 23 insertions(+) rename openspec/changes/{sentry-improvements => archive/2026-03-25-sentry-improvements}/.openspec.yaml (100%) rename openspec/changes/{sentry-improvements => archive/2026-03-25-sentry-improvements}/design.md (100%) rename openspec/changes/{sentry-improvements => archive/2026-03-25-sentry-improvements}/proposal.md (100%) rename openspec/changes/{sentry-improvements => archive/2026-03-25-sentry-improvements}/specs/infrastructure/spec.md (100%) rename openspec/changes/{sentry-improvements => archive/2026-03-25-sentry-improvements}/tasks.md (100%) diff --git a/openspec/changes/sentry-improvements/.openspec.yaml b/openspec/changes/archive/2026-03-25-sentry-improvements/.openspec.yaml similarity index 100% rename from openspec/changes/sentry-improvements/.openspec.yaml rename to openspec/changes/archive/2026-03-25-sentry-improvements/.openspec.yaml diff --git a/openspec/changes/sentry-improvements/design.md b/openspec/changes/archive/2026-03-25-sentry-improvements/design.md similarity index 100% rename from openspec/changes/sentry-improvements/design.md rename to openspec/changes/archive/2026-03-25-sentry-improvements/design.md diff --git a/openspec/changes/sentry-improvements/proposal.md b/openspec/changes/archive/2026-03-25-sentry-improvements/proposal.md similarity index 100% rename from openspec/changes/sentry-improvements/proposal.md rename to openspec/changes/archive/2026-03-25-sentry-improvements/proposal.md diff --git a/openspec/changes/sentry-improvements/specs/infrastructure/spec.md b/openspec/changes/archive/2026-03-25-sentry-improvements/specs/infrastructure/spec.md similarity index 100% rename from openspec/changes/sentry-improvements/specs/infrastructure/spec.md rename to openspec/changes/archive/2026-03-25-sentry-improvements/specs/infrastructure/spec.md diff --git a/openspec/changes/sentry-improvements/tasks.md b/openspec/changes/archive/2026-03-25-sentry-improvements/tasks.md similarity index 100% rename from openspec/changes/sentry-improvements/tasks.md rename to openspec/changes/archive/2026-03-25-sentry-improvements/tasks.md diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 69342ba..7399863 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -63,3 +63,26 @@ The infrastructure SHALL configure DNS and TLS for trails.cool and planner.trail #### Scenario: HTTPS access - **WHEN** a user navigates to https://trails.cool - **THEN** the connection is secured with a valid TLS certificate + +### Requirement: Sentry error tracking +The system SHALL enrich Sentry events with user and session context, use route-aware tracing, and prevent source maps from being served to clients. + +#### Scenario: Journal error includes user context +- **WHEN** an authenticated Journal user triggers an error +- **THEN** the Sentry event SHALL include the user's ID and username + +#### Scenario: Journal error without user context +- **WHEN** an unauthenticated visitor triggers an error +- **THEN** the Sentry event SHALL have no user context (Sentry.setUser(null)) + +#### Scenario: Planner error includes session ID +- **WHEN** an error occurs during a Planner session +- **THEN** the Sentry event SHALL include a `session_id` tag with the active session ID + +#### Scenario: Route-level performance traces +- **WHEN** a user navigates between routes in either app +- **THEN** Sentry SHALL create a transaction span named after the route pattern (e.g., `/routes/:id`) + +#### Scenario: Source maps not served to clients +- **WHEN** a client requests a `.map` file from the production server +- **THEN** the server SHALL return 404 (source maps are uploaded to Sentry during build, not shipped in the bundle) From 4a721fc586d44a4ccf0c85ab8317b629ff5c04f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 03:15:38 +0100 Subject: [PATCH 026/835] Add privacy route to journal route config Route was missing from the explicit routes.ts, so typegen didn't generate types for it. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/routes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 050a85a..1e9bb96 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -19,4 +19,5 @@ export default [ route("activities/new", "routes/activities.new.tsx"), route("activities/:id", "routes/activities.$id.tsx"), route("users/:username", "routes/users.$username.tsx"), + route("privacy", "routes/privacy.tsx"), ] satisfies RouteConfig; From ef130b4b113d62579c162ff334f404f9aa84e22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 03:25:18 +0100 Subject: [PATCH 027/835] Add /deploy-status command Shows deployed container versions, latest CD run SHA, and health check for both apps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/commands/deploy-status.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .claude/commands/deploy-status.md diff --git a/.claude/commands/deploy-status.md b/.claude/commands/deploy-status.md new file mode 100644 index 0000000..47e5bc1 --- /dev/null +++ b/.claude/commands/deploy-status.md @@ -0,0 +1,19 @@ +Check the currently deployed version on production. + +Run this SSH command to get the image digests and match them to git SHAs: + +```bash +ssh -i ~/.ssh/trails-cool-deploy root@91.99.14.111 "docker compose -f /opt/trails-cool/docker-compose.yml images" 2>&1 +``` + +Then compare with the latest CD run: + +```bash +gh run list --workflow cd.yml --limit 1 --json headSha,conclusion,status -q '.[0]' +``` + +Show: +- Which images are running (journal, planner, brouter, postgres, caddy) +- The git SHA they were built from (matches the image tag) +- Whether the latest CD run matches what's deployed +- Quick health check: curl both https://trails.cool and https://planner.trails.cool From c7c1c275df40a2a6a6e4a28dd168ea675f75c601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Wed, 25 Mar 2026 03:57:20 +0100 Subject: [PATCH 028/835] Add 8 proposed changes and old trails analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/old-trails-analysis.md | 235 ++++++++++++++++++ .../changes/app-navigation/.openspec.yaml | 2 + openspec/changes/app-navigation/design.md | 40 +++ openspec/changes/app-navigation/proposal.md | 33 +++ .../app-navigation/specs/map-display/spec.md | 12 + openspec/changes/app-navigation/tasks.md | 18 ++ openspec/changes/changelog/.openspec.yaml | 2 + openspec/changes/changelog/design.md | 65 +++++ openspec/changes/changelog/proposal.md | 38 +++ .../changes/changelog/specs/changelog/spec.md | 37 +++ openspec/changes/changelog/tasks.md | 29 +++ openspec/changes/komoot-import/.openspec.yaml | 2 + openspec/changes/komoot-import/design.md | 72 ++++++ openspec/changes/komoot-import/proposal.md | 32 +++ .../komoot-import/specs/komoot-import/spec.md | 63 +++++ .../specs/route-management/spec.md | 12 + openspec/changes/komoot-import/tasks.md | 51 ++++ .../changes/planner-features/.openspec.yaml | 2 + openspec/changes/planner-features/design.md | 74 ++++++ openspec/changes/planner-features/proposal.md | 40 +++ .../specs/brouter-integration/spec.md | 8 + .../specs/crash-recovery/spec.md | 12 + .../specs/map-display/spec.md | 12 + .../specs/no-go-areas/spec.md | 16 ++ .../specs/planner-session/spec.md | 8 + .../specs/rate-limiting/spec.md | 15 ++ .../specs/session-notes/spec.md | 12 + openspec/changes/planner-features/tasks.md | 36 +++ .../planner-landing-page/.openspec.yaml | 2 + .../changes/planner-landing-page/design.md | 44 ++++ .../changes/planner-landing-page/proposal.md | 34 +++ .../specs/planner-session/spec.md | 16 ++ .../changes/planner-landing-page/tasks.md | 16 ++ .../.openspec.yaml | 2 + .../planner-multiplayer-awareness/design.md | 46 ++++ .../planner-multiplayer-awareness/proposal.md | 35 +++ .../specs/map-display/spec.md | 12 + .../specs/planner-session/spec.md | 28 +++ .../planner-multiplayer-awareness/tasks.md | 27 ++ .../changes/route-features/.openspec.yaml | 2 + openspec/changes/route-features/design.md | 71 ++++++ openspec/changes/route-features/proposal.md | 43 ++++ .../specs/activity-feed/spec.md | 12 + .../specs/activity-photos/spec.md | 16 ++ .../specs/multi-day-routes/spec.md | 16 ++ .../specs/route-management/spec.md | 12 + .../specs/route-sharing/spec.md | 30 +++ .../specs/spatial-search/spec.md | 12 + openspec/changes/route-features/tasks.md | 68 +++++ .../transactional-emails/.openspec.yaml | 2 + .../changes/transactional-emails/design.md | 62 +++++ .../changes/transactional-emails/proposal.md | 32 +++ .../specs/journal-auth/spec.md | 12 + .../specs/transactional-emails/spec.md | 44 ++++ .../changes/transactional-emails/tasks.md | 30 +++ 55 files changed, 1704 insertions(+) create mode 100644 docs/old-trails-analysis.md create mode 100644 openspec/changes/app-navigation/.openspec.yaml create mode 100644 openspec/changes/app-navigation/design.md create mode 100644 openspec/changes/app-navigation/proposal.md create mode 100644 openspec/changes/app-navigation/specs/map-display/spec.md create mode 100644 openspec/changes/app-navigation/tasks.md create mode 100644 openspec/changes/changelog/.openspec.yaml create mode 100644 openspec/changes/changelog/design.md create mode 100644 openspec/changes/changelog/proposal.md create mode 100644 openspec/changes/changelog/specs/changelog/spec.md create mode 100644 openspec/changes/changelog/tasks.md create mode 100644 openspec/changes/komoot-import/.openspec.yaml create mode 100644 openspec/changes/komoot-import/design.md create mode 100644 openspec/changes/komoot-import/proposal.md create mode 100644 openspec/changes/komoot-import/specs/komoot-import/spec.md create mode 100644 openspec/changes/komoot-import/specs/route-management/spec.md create mode 100644 openspec/changes/komoot-import/tasks.md create mode 100644 openspec/changes/planner-features/.openspec.yaml create mode 100644 openspec/changes/planner-features/design.md create mode 100644 openspec/changes/planner-features/proposal.md create mode 100644 openspec/changes/planner-features/specs/brouter-integration/spec.md create mode 100644 openspec/changes/planner-features/specs/crash-recovery/spec.md create mode 100644 openspec/changes/planner-features/specs/map-display/spec.md create mode 100644 openspec/changes/planner-features/specs/no-go-areas/spec.md create mode 100644 openspec/changes/planner-features/specs/planner-session/spec.md create mode 100644 openspec/changes/planner-features/specs/rate-limiting/spec.md create mode 100644 openspec/changes/planner-features/specs/session-notes/spec.md create mode 100644 openspec/changes/planner-features/tasks.md create mode 100644 openspec/changes/planner-landing-page/.openspec.yaml create mode 100644 openspec/changes/planner-landing-page/design.md create mode 100644 openspec/changes/planner-landing-page/proposal.md create mode 100644 openspec/changes/planner-landing-page/specs/planner-session/spec.md create mode 100644 openspec/changes/planner-landing-page/tasks.md create mode 100644 openspec/changes/planner-multiplayer-awareness/.openspec.yaml create mode 100644 openspec/changes/planner-multiplayer-awareness/design.md create mode 100644 openspec/changes/planner-multiplayer-awareness/proposal.md create mode 100644 openspec/changes/planner-multiplayer-awareness/specs/map-display/spec.md create mode 100644 openspec/changes/planner-multiplayer-awareness/specs/planner-session/spec.md create mode 100644 openspec/changes/planner-multiplayer-awareness/tasks.md create mode 100644 openspec/changes/route-features/.openspec.yaml create mode 100644 openspec/changes/route-features/design.md create mode 100644 openspec/changes/route-features/proposal.md create mode 100644 openspec/changes/route-features/specs/activity-feed/spec.md create mode 100644 openspec/changes/route-features/specs/activity-photos/spec.md create mode 100644 openspec/changes/route-features/specs/multi-day-routes/spec.md create mode 100644 openspec/changes/route-features/specs/route-management/spec.md create mode 100644 openspec/changes/route-features/specs/route-sharing/spec.md create mode 100644 openspec/changes/route-features/specs/spatial-search/spec.md create mode 100644 openspec/changes/route-features/tasks.md create mode 100644 openspec/changes/transactional-emails/.openspec.yaml create mode 100644 openspec/changes/transactional-emails/design.md create mode 100644 openspec/changes/transactional-emails/proposal.md create mode 100644 openspec/changes/transactional-emails/specs/journal-auth/spec.md create mode 100644 openspec/changes/transactional-emails/specs/transactional-emails/spec.md create mode 100644 openspec/changes/transactional-emails/tasks.md diff --git a/docs/old-trails-analysis.md b/docs/old-trails-analysis.md new file mode 100644 index 0000000..731861a --- /dev/null +++ b/docs/old-trails-analysis.md @@ -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 diff --git a/openspec/changes/app-navigation/.openspec.yaml b/openspec/changes/app-navigation/.openspec.yaml new file mode 100644 index 0000000..40c5540 --- /dev/null +++ b/openspec/changes/app-navigation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-25 diff --git a/openspec/changes/app-navigation/design.md b/openspec/changes/app-navigation/design.md new file mode 100644 index 0000000..c91ff69 --- /dev/null +++ b/openspec/changes/app-navigation/design.md @@ -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 `