Commit graph

88 commits

Author SHA1 Message Date
Ullrich Schäfer
ba4c4e1b4f
Apply demo-activity-bot: Bruno, the synthetic public-walk generator
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:14:58 +02:00
Ullrich Schäfer
32661a0163
Add visibility disclosure to the binding German privacy text
The previous commit only added a bullet to the English-only Privacy
Manifest appendix. The formal, binding German sections (which the
policy explicitly declares authoritative) didn't mention visibility
at all — and the paired English summary under each section likewise
didn't.

Add a sentence under §2 "Erhobene Daten und Zwecke → Nutzerinhalte"
explaining the visibility setting, defaults, and world-visibility
consequences. Mirror in the section's English summary paragraph. Keep
the Privacy Manifest bullet as an additional plain-language mention.

Re-rendered docs/legal-archive/privacy-2026-04-20.md; both languages
present (verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:14:15 +02:00
Ullrich Schäfer
5905cdadea
Apply public-content-visibility: visibility flag + public profile
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.

Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.

Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
  centralises the rule: public → anyone; unlisted → anyone on direct
  link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
  canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
  content only.

Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
  action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.

Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
  for cross-user queries. Existing owner-scoped listRoutes/listActivities
  stay — owners see their own content regardless of visibility.

Public profile:
- /users/:username is now truly public. Renders the user's public
  routes + activities, 404s when no public content exists AND viewer
  isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.

Privacy manifest:
- Added a bullet noting public content is world-visible on profile
  and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
  snapshot.

Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
  - Private route → 404 for logged-out visitor
  - Public route → reachable + OG tags present (og:title, og:type,
    og:site_name)
  - Owner still sees own private content
  - Profile 404 when no public content
  - Profile renders when at least one public route exists
  - Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
  WebAuthn virtual-authenticator races under Playwright's default
  parallel workers.
- New public-content Playwright project added to config.

Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:11:39 +02:00
Ullrich Schäfer
5b40bd9b00
Show empty-state CTA on Route detail page when no waypoints
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.

Changes:
- Detect "empty" as no geometry AND no distance (the state every new
  route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
  / Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
  viewer-only copy), primary "Open in Planner" button (same action as
  the existing top-row button, just visually prominent), and a
  secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.

No non-empty route loses any UI — those paths stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:13:24 +02:00
Ullrich Schäfer
f16e80a2eb
Prompt users with stale terms_version to re-accept
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.

Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
  writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
  throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
  request is already on an allow-listed path
  (/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
  and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
  acceptance and bounces to a sanitised returnTo). Same-origin check
  on returnTo to avoid open-redirect abuse. Logout button is provided
  as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
  successful re-accept, missing consent, returnTo sanitisation).

No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:06:16 +02:00
Ullrich Schäfer
9c4c3d6444
Store Terms version alongside acceptance timestamp
Reviewer's follow-up: it's not enough to record when a user accepted
the Terms; we also need to record which version of the text they saw.

Changes:
- journal.users gains a nullable `terms_version` text column (nullable
  so the three pre-existing users without a version are kept as-is).
- New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single
  source of truth, reused by the legal pages' "Last updated" header
  and by the registration flow as the value to send/store.
- Registration form posts `termsVersion` alongside `termsAccepted` on
  all three relevant steps (start, finish, register-magic-link).
- API route validates that `termsVersion` is a non-empty string on
  any step that requires terms, and forwards it to the auth server.
- auth.server finishRegistration and registerWithMagicLink now take
  `termsVersion` and persist it on the users row.
- journal-auth spec gets a new scenario for version storage and a
  rejection scenario for missing version.

PRIVACY_LAST_UPDATED is also exported from the same module and used
by the Privacy page header, keeping both pages on a single legal.ts
source of truth for "last updated" labels. Privacy is not per-user
stored — it's informational, not contract.

Existing users have NULL terms_version; if we ever prompt them to
re-accept updated Terms, we can backfill with the version they
re-accept at that point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:46:34 +02:00
Ullrich Schäfer
bc311ab7bc
Apply minimal legal-review follow-ups
Privacy:
- Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry
  is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen
  werden nicht aktiv gespeichert" per reviewer's preferred wording.
  Drop the unverified "Frankfurt" hosting claim; keep
  Auftragsverarbeiter framing.
- SMTP → "externer SMTP-Dienst" per reviewer's phrasing.
- Storage-durations section: add explicit alpha-reset caveat so it
  aligns with Terms §4 (database resets).

Terms:
- § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser
  Dienst" (leaves pricing model open without promising free-forever).
- § 7 acceptable use: scraping clause softened to "kein massenhaftes
  automatisiertes Auslesen von Daten" (drops the specific "to build
  competing services" qualifier).

Imprint:
- Normalise address formatting: multi-line across both DE blocks and
  the EN § 5 TMG block (§ 18 MStV and EN block previously used a
  comma-separated single line). One style only, everywhere.
- Add whitespace-nowrap to the mailto link so the email address
  never breaks mid-word on narrow viewports.

No changes to legal bases, server-logs section, storage durations
themselves, or any third-party other than Sentry + SMTP wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:27:56 +02:00
Ullrich Schäfer
1462c54df7
Rewrite legal pages per legal review feedback
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
  required statement that we don't participate in consumer dispute
  resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
  repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.

Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
  purposes → Legal bases → Server logs → Storage durations → Third
  parties → Rights → Complaint authority → (plain-language) Privacy
  Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
  to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
  contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
  / status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
  magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
  browser sends IP+UA directly to OSM tile servers, with OSMF privacy
  policy link), Overpass (via our proxy, upstream only sees our
  server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
  the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
  aligned with current reality (no cookies/localStorage/sessionStorage
  in Planner; no replays; no PII via Sentry).

Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
  has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
  modify/limit/interrupt/discontinue the service or individual
  accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
  operator a limited, non-transferable licence to store/process/
  display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
  (standard German DACH tiering: unlimited for intent/gross neg./life-
  body-health; limited to foreseeable damages on cardinal-duty breach
  for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:57:29 +02:00
Ullrich Schäfer
248849318f
Align privacy manifest with Sentry + Overpass hardening
Three sections drifted from the actual behaviour of the deployed apps:

Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
  removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.

Sentry section:
- Drops the "session replays on error" claim — replay integration is
  not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
  are both 0.
- Documents the actual scope: Journal server-side always; Journal
  client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
  and that only the user ID is attached to logged-in Journal errors.

Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
  through our own /api/overpass so the upstream host sees our server,
  not end users.

Also bumps "Last updated" to today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:32:48 +02:00
Ullrich Schäfer
01f002cc46
Stop Sentry on logout; fix E2E + Dockerfile for merged consolidation
- sentry.client.ts: add stopSentryClient() that awaits Sentry.close() so
  the SDK tears down when a user logs out. Hub methods become no-ops
  until initSentryClient() is called again on next login.
- root.tsx: call stopSentryClient() from the user-effect when user === null.
- Add sentry-config to journal + planner Dockerfiles (was missing after
  #237 introduced the new workspace package, breaking the Dockerfile
  package check).
- Footer: replace inner <nav> with <div> — nested nav landmarks broke
  the journal "nav bar shows on all pages" e2e test (two navigation
  roles on the page).
- e2e auth: tick the required ToS checkbox in the shared registerUser
  helper so passkey registration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:40:36 +02:00
Ullrich Schäfer
8e576ac578
Consolidate Sentry config into shared package; fix inconsistencies
Introduces @trails-cool/sentry-config with three helpers that encode
the common Sentry.init options:
- nodeSentryConfig(appContext) for the two HTTP servers
- browserSentryConfig(appContext, env) for the Journal client
- mobileSentryConfig(__DEV__) for the React Native app
- drop404s beforeSend for servers

Also fixes three inconsistencies found in the audit:
- Planner server was missing sendDefaultPii: false (IPs could leak)
- Planner entry.server.tsx didn't call Sentry.captureException on SSR
  render errors; Journal's SSR did
- Sentry.init location now matches across apps (both in server.ts;
  Journal previously had it in app/entry.server.tsx, which meant init
  ran lazily on first request rather than at server startup)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:30:09 +02:00
Ullrich Schäfer
4092f21475
Log Sentry dev-inert status; explicit replay=0
When Sentry init runs but is disabled (local dev / CI), log a debug
message so developers can see Sentry *would* send in production.

Also explicitly set replaysSessionSampleRate/replaysOnErrorSampleRate
to 0 in the client and mobile configs. Replay integration isn't
installed, so this is documentation — it prevents future accidental
enablement if an integration is ever added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:15:31 +02:00
Ullrich Schäfer
cc0f44258a
Add legal pages, ToS acceptance, and alpha banner
- Journal: Impressum (§5 TMG), Terms of Service, GDPR privacy page
- Registration flow: required ToS checkbox, termsAcceptedAt persisted
- Alpha banner on Journal (always visible); alpha badge on Planner hero
- Footer with legal links + GitHub on both apps
- Reduce PII: sendDefaultPii=false, Sentry init only after login (Journal)
- Drop Sentry from Planner (no login, no consent possible)
- Drop localStorage caching from i18n client detection
- Sync SSR i18n resources each request so locale edits HMR without restart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 00:08:48 +02:00
Ullrich Schäfer
3bcb1fce7c
Add 6-digit login code for mobile authentication
Magic links open in the device browser, not the OAuth in-app browser,
so the session doesn't carry over. A login code lets mobile users
type a 6-digit code from their email into the login page instead.

- Generate 6-digit numeric code alongside magic link token
- Store code in magic_tokens table
- Add verify-code step to login API endpoint
- Show code input UI after magic link is sent
- Include code in email template
- i18n keys for code UI (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:56:42 +02:00
Ullrich Schäfer
e69f93800b Fix TypeScript parameter property crash in Node strip-only mode
oauth.server.ts used a TypeScript parameter property (`public code: string`
in constructor) which is not supported by Node.js v25's strip-only TypeScript
mode. This caused the journal to crash-loop on startup after seeding OAuth
clients.

Also enable `erasableSyntaxOnly` in tsconfig.base.json so `pnpm typecheck`
catches any future use of non-erasable TypeScript syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:00:00 +02:00
Ullrich Schäfer
7b2fe1ce80
Fix typecheck: add missing unstable_url and unstable_pattern to route test args
React Router 7 now requires these properties on loader/action args.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:10:23 +02:00
Ullrich Schäfer
55463d1f56
Add mocked unit tests for REST API route handlers
Tests the full handler flow with mocked DB: auth guard (401), Zod
validation (400), successful responses, and 404s for missing resources.
Also adds ~ alias resolution to Journal vitest config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:59:45 +02:00
Ullrich Schäfer
234ab9f8c5
Add unit tests for REST API validation and pagination
Tests Zod schema validation for all API endpoints (routes, activities,
compute, uploads), error codes, and cursor pagination logic. 21 new
tests covering the validation layer without requiring a running DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:58:18 +02:00
Ullrich Schäfer
18c3c37eaf
Implement Journal REST API v1 endpoints
Add all REST API endpoints for mobile app consumption:

Routes:
- GET /api/v1/routes — paginated list with cursor, Zod validation
- GET /api/v1/routes/:id — full detail with GPX, versions
- POST /api/v1/routes — create with Zod-validated body
- PUT /api/v1/routes/:id — update, creates new version
- DELETE /api/v1/routes/:id — returns 204

Activities:
- GET /api/v1/activities — paginated list with cursor
- GET /api/v1/activities/:id — full detail
- POST /api/v1/activities — create with GPX stat extraction
- DELETE /api/v1/activities/:id — returns 204

Supporting:
- POST /api/v1/routes/compute — BRouter proxy
- POST /api/v1/uploads — presigned upload URL generation

Device management:
- GET /api/v1/auth/devices — list connected devices with isCurrent
- DELETE /api/v1/auth/devices/:id — revoke device token
- Store device_name on token exchange

Infrastructure:
- requireApiUser() guard — returns 401 with structured error
- apiError() helper for consistent error responses
- All endpoints use Zod schemas from @trails-cool/api for validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:57:27 +02:00
Ullrich Schäfer
1ae406a8aa
Implement OAuth2 PKCE auth, discovery, and mobile API client
Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:41:40 +02:00
Ullrich Schäfer
08070cdd90
Sync notes through GPX, Journal, and Planner roundtrip
Full notes lifecycle:
- GPX: description field in GpxData, <metadata><desc> in generate/parse
- Export: Plan GPX and Save to Journal include notes as description
- Journal: updateRoute extracts description from GPX, stores on route
- Reimport: Edit in Planner passes notes via URL params → Yjs Y.Text
- Drop import: GPX with <desc> restores notes in session

Spec updated: session-notes gains GPX export, Journal sync, and
reimport requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:35:40 +02:00
Ullrich Schäfer
7e89de0f0e
Reduce map fly animation to 200ms
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:48:41 +02:00
Ullrich Schäfer
b010cd8c59
Fly map to highlighted day segment on hover
When hovering a day row in the route detail breakdown, the map smoothly
flies to fit that segment's bounds (0.5s animation). On mouse leave it
flies back to the full route bounds. Refactored geometry splitting into
shared helpers used by both DayColoredRoute and FlyToSegment.

Rotation not implemented — Leaflet doesn't support native bearing
rotation. Would require leaflet-rotate plugin or MapLibre GL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:46:46 +02:00
Ullrich Schäfer
7029c85dd0
Add route detail day segment highlighting and per-day GPX export
- Hovering a day row dims other segments (opacity 0.3) and thickens
  the hovered day's segment (weight 5) for clear visual focus
- Each day row has a GPX download button that exports just that day's
  track segment via /api/routes/:id/gpx?day=N
- GPX endpoint uses computeDays to extract the correct track slice

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:44:11 +02:00
Ullrich Schäfer
5604fe1c82
Add per-day map coloring and remaining unit tests
- RouteMapThumbnail: Color route segments per day with alternating colors
  when dayBreaks are present, pass through from route detail page
- overnight.test.ts: 6 tests for isOvernight/setOvernight with Y.Doc
- daybreaks-extraction.test.ts: 3 tests for GPX → dayBreaks index extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:12:27 +02:00
Ullrich Schäfer
b496cd9b02
Add Journal day breakdown and elevation chart dividers
- routes.server.ts: Extract dayBreaks from parsed GPX waypoints on save
- routes.$id.tsx: Compute per-day stats in loader, render day breakdown
  section with distance/ascent/descent per day
- ElevationChart: Dashed vertical lines at day boundaries
- i18n: Add Journal day breakdown keys (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:56:57 +02:00
Ullrich Schäfer
9b2adebc4d Add ClientMap wrapper to avoid lazy() SSR resolution
.client.tsx modules return undefined on the server, but lazy() still
tries to resolve them during SSR causing "Element type is invalid".
Replace lazy()+Suspense with a ClientMap component that dynamically
imports the map only in useEffect (client-side only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:52:57 +02:00
Ullrich Schäfer
81eb38a24d Fix Leaflet SSR error: rename to .client.tsx
Leaflet accesses `window` on import, crashing during SSR. Renamed
RouteMapThumbnail to .client.tsx so React Router excludes it from
the server bundle entirely. Updated all lazy() imports to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:52:57 +02:00
Ullrich Schäfer
f80ed10a8b Remove unused journal health route file
api.health.ts was never registered in routes.ts — the server.ts
handler intercepts /api/health before React Router sees it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:24 +02:00
Ullrich Schäfer
e358c432e7
Fix lint: duration is never reassigned, use const
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:04:10 +02:00
Ullrich Schäfer
80291ccf6e
Pass workout stats (distance, duration, startedAt) through on import
ActivityInput now accepts optional distance, duration, and startedAt.
GPX-derived stats take precedence when available, but provider stats
are used as fallback for workouts without GPS data.

The import form and Import All now pass these fields from the Wahoo
workout data so activities show correct metadata even without a FIT file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:59:54 +02:00
Ullrich Schäfer
20b91ef511
Fix Wahoo import bugs, add Import All, update spec
Bugs fixed:
- FIT parser returns timestamps as Date objects, not strings — convert
  to ISO 8601 before passing to generateGpx (caused str.replace crash)
- FIT parser already converts semicircles to degrees — remove redundant
  conversion that produced near-zero coordinates
- Wahoo CDN URLs are pre-signed S3 URLs — remove Bearer auth header
  from download requests (caused 400 "Unsupported Authorization Type")
- Filter out third-party workouts (fitness_app_id >= 1000) since Wahoo
  does not share their data via the API

UX improvements:
- Individual imports use fetcher (no page refresh), button shows
  "Importing..." inline
- "Import all" button imports all unimported workouts on the page
  sequentially with progress indicator
- Add @vitejs/plugin-basic-ssl for local HTTPS dev (opt-in via HTTPS=1)

Also adds unit test for FIT-to-GPX conversion with real fixture file,
and updates wahoo-import spec to reflect all behaviors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:47:48 +02:00
Ullrich Schäfer
fdd193cbf0
Add activity import source badge, delete action, and No GPS indicator
- Activity detail: show "Imported from wahoo" badge when activity was
  imported from an external provider
- Activity detail: add delete button with confirmation, cleans up
  sync_imports so the workout can be reimported
- Import page: show "No GPS" badge with tooltip on workouts that have
  no file URL from the provider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:49:42 +02:00
Ullrich Schäfer
7994ea79f4
Fix overview error rate panel, add request metrics to journal
Dashboard: switch error rate panel from app-level http_request_duration
(never observed) to Caddy's caddy_http_response_duration_seconds_count.

Journal: add custom server.ts (matching planner pattern) that observes
http_request_duration_seconds on every request. Replaces react-router-serve
with a custom Node HTTP server that handles /api/health, /api/metrics,
static assets, and request timing. Removes redundant React Router routes
for health and metrics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:20:18 +02:00
Ullrich Schäfer
9aaa17c898
Fix Wahoo import crash for workouts without FIT files
Some Wahoo workouts (indoor, manual entries) don't have a FIT file URL.
Both the manual import action and webhook handler called downloadFile
unconditionally, throwing "No file URL for workout". Now skips the
download and creates the activity without GPX when no file is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:41:28 +01:00
Ullrich Schäfer
b6711d23d6
Add Wahoo activity sync with provider-agnostic framework
Provider-agnostic sync framework + Wahoo as first implementation:

Framework (apps/journal/app/lib/sync/):
- SyncProvider interface for OAuth2, webhooks, import, conversion
- Provider registry for settings UI iteration
- Generic sync_connections + sync_imports tables
- Token storage with auto-refresh

Wahoo provider:
- OAuth2 with workouts_read, user_read, offline_data scopes
- Webhook-based auto-import (workout_summary events)
- Manual import page with pagination
- FIT→GPX conversion via fit-file-parser
- Webhook token verification

Routes:
- /api/sync/connect/:provider — OAuth redirect
- /api/sync/callback/:provider — OAuth callback
- /api/sync/disconnect/:provider — remove connection
- /api/sync/webhook/:provider — webhook receiver
- /sync/import/:provider — manual import page

Settings: Connected Services section with per-provider connect/disconnect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:22:02 +01:00
Ullrich Schäfer
6aac8bd885
Add map previews to journal route and activity pages
- Route/activity list pages: map thumbnails with route drawn on OSM tiles
- Route/activity detail pages: interactive Leaflet map with zoom controls
- Server: expose GeoJSON from PostGIS via ST_AsGeoJSON (simplified for lists)
- RouteMapThumbnail component: shared, supports thumbnail and interactive modes
- Placeholder shown for routes/activities without geometry
- Archive journal-route-previews change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:42:07 +01:00
Ullrich Schäfer
b6179fbdd2
Initialize no-go areas client-side via URL params
In dev mode, the sessions API and the Yjs WebSocket server are
separate processes (Vite plugin vs React Router action), so
server-side Yjs doc initialization doesn't reach the client.

Now no-go areas flow the same way as waypoints:
API response → URL params → client-side Yjs initialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:47:40 +01:00
Ullrich Schäfer
d5bcecb345
Fix distance calculation for GPX files without elevation data
Distance was only stored in the elevation profile array, which was
empty when track points had no <ele> elements. Now computed via
haversine independently and exposed as gpxData.distance.

Tested: berlin-dresden-radweg-2021.gpx (5660 points, no elevation)
now correctly reports 248.8 km.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:32:47 +01:00
Ullrich Schäfer
6ed828ef9c
Fix PostGIS geom population: use parseGpxAsync + raw SQL
The initial approach (#150) didn't work because:
1. routes.server.ts used parseGpx (sync, needs browser DOMParser)
   instead of parseGpxAsync (uses linkedom on Node.js) — GPX parsing
   silently failed, so stats AND geom were never populated
2. Drizzle's customType doesn't pass SQL expressions through toDriver

Fixes:
- Switch to parseGpxAsync for all server-side GPX parsing
- Use db.execute() with raw ST_GeomFromGeoJSON SQL after insert
- Remove unused lineStringFromCoords helper from schema
- Share setGeomFromGpx between routes and activities
- Add unit tests for GPX→GeoJSON coordinate extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:32:07 +01:00
Ullrich Schäfer
b4c3f97296
Populate PostGIS geom column from GPX track data
Extract coordinates from parsed GPX tracks and store as PostGIS
LineString geometry via ST_GeomFromGeoJSON. Applied to:
- createRoute / updateRoute (routes.server.ts)
- createActivity / createRouteFromActivity (activities.server.ts)

Adds lineStringFromCoords() helper to the journal schema that
builds the SQL expression from [lon, lat] coordinate pairs.

Unblocks spatial queries (e.g. route discovery via ST_Intersects).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:05:02 +01:00
Ullrich Schäfer
4403e22006
Remove useEffect flicker from ClientDate
Now that both server and client use the same locale from context,
the post-hydration useEffect that re-formatted with the browser's
OS locale was causing a visible flicker. Just render with the
context locale directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:48:01 +02:00
Ullrich Schäfer
38ada3bd4f
Pass full locale from Accept-Language to ClientDate
Add detectLocale() that extracts the full locale tag (e.g. "de-DE")
from the request, not just the language. The journal root loader passes
it via LocaleContext so ClientDate renders with the correct locale on
both server and client — no more hardcoded "en-US" fallback.

Also fixes settings page hydration mismatch by using ClientDate for
passkey creation dates instead of inline toLocaleDateString().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:46:21 +02:00
Ullrich Schäfer
79f000fcc5
Upgrade i18next v26 + react-i18next v17 with SSR-safe init
Split initI18n() into separate server/client initialization paths
inspired by remix-i18next's architecture:

- initI18nServer(lng): no LanguageDetector, per-request language from
  Accept-Language header
- initI18nClient(): LanguageDetector reads <html lang> first to match
  server, then localStorage and navigator
- detectLanguage(request): parses Accept-Language for best match

Fixes rehydration errors caused by v26 removing initImmediate option
(replaced with initAsync) and LanguageDetector running on the server
where browser APIs don't exist.

Also makes <html lang> dynamic instead of hardcoded "en".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:38:36 +02:00
Ullrich Schäfer
4859f5d5e0
Only show add-passkey prompt when user has no passkeys
The prompt now only appears when coming from signup/login with
?add-passkey=1 AND the user has zero passkeys. Passkey count display
removed from home — that info lives in /settings now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:50:30 +02:00
Ullrich Schäfer
d426912945
Merge branch 'main' into chore/static-assets 2026-03-29 10:36:22 +02:00
Ullrich Schäfer
518ee9ab69
Add favicons, apple-touch-icons, and Chrome DevTools JSON
Eliminates 404 noise from:
- /favicon.ico (browsers)
- /apple-touch-icon.png, /apple-touch-icon-precomposed.png (iOS)
- /.well-known/appspecific/com.chrome.devtools.json (Chrome)

Favicon uses the Waypoint Dot logo mark (sage green, SVG + ICO).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:35:03 +02:00
Ullrich Schäfer
a766bffad5
Add purpose column to magic_tokens, fix email change race condition
- Add `purpose` column (default: 'login') to distinguish login tokens
  from email-change tokens
- verifyMagicToken only matches purpose='login'
- verifyEmailChange only matches purpose='email-change'
- Re-check email availability at verification time, not just initiation
  — prevents race where someone registers with the new email between
  request and verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:28:53 +02:00
Ullrich Schäfer
715b272395
Fix i18n: use common:key namespace syntax for cross-namespace keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:19:11 +02:00
Ullrich Schäfer
070a316164
Fix missing common.delete i18n key in settings page
Use both journal and common namespaces so t("common.delete") resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:16:41 +02:00