Commit graph

40 commits

Author SHA1 Message Date
Ullrich Schäfer
5da7ffa037 Locked-account profiles: private = stub + Pending follow flow
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:38:26 +02:00
Ullrich Schäfer
811d5f62f5 Implement social-feed: local follows + /feed + profile visibility
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.

Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
  federation forward-compat. Local IRIs look like
  `${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
  Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
  'public'). Existing users land 'public' via the default; current
  effective behavior is unchanged.

Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
  truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
  countFollowers / countFollowing / listFollowers / listFollowing.
  Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
  follows → activities WHERE visibility='public', reverse-chrono.

Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
  for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.

UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
  "New Activity".

Privacy manifest updated to document the new follows relation and
profile_visibility setting.

Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.

Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 22:51:18 +02:00
Ullrich Schäfer
19f25f8e26 Personal activity dashboard for signed-in users
Previously a signed-in user landing on `/` saw the same page as an
anonymous visitor: the visitor hero, the flagship marketing cards, and
the instance-wide public feed. "Home" should mean "your stuff."

The home route now branches on session: signed-out visitors keep the
marketing + public-feed layout from #303; signed-in users get a
personal dashboard showing their own activities reverse-chronologically
(all visibilities), a welcome line linking to their profile, and a
"New Activity" CTA. The public instance feed and marketing blurbs are
suppressed for signed-in users — they'd be redundant on a personal
landing surface.

Loader picks `listActivities(user.id)` vs `listRecentPublicActivities`
based on session, so only one feed query runs per request.

Updates the `journal-landing` spec with the new session-based split and
a "Personal dashboard for signed-in users" requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:16:56 +02:00
Ullrich Schäfer
f0ef989ac3 Rebuild the Journal home around a public feed and flagship marketing
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.

The new home is one layout that serves both audiences:

- Hero: a product-describing h1 ("Federated outdoor journal") +
  tagline. The site name already lives in the top banner and nav brand,
  so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
  Planner demotes to a small "Or try the Planner without an account →"
  link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
  for Planner / Journal / Federation / Ownership, matching the Planner
  home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
  a "Powered by trails.cool — about the project" link back to the
  flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
  activities on the instance, with owner + distance + date. Empty state
  for fresh installs.

Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
  users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
  cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
  so self-hosters (who deploy without these workflows) default to off.

Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
  so the visibility contract is pinned (public in feed, unlisted +
  private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
  feed layout contract and the `IS_FLAGSHIP` toggle, so future
  instance-branding or empty-state changes have a stable anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:00:23 +02:00
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
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
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
77e3c1b1ee
Build Routes tab with paginated list and Zod-validated API responses
Routes tab (Phase 3.1):
- Paginated FlatList fetching from Journal REST API
- Route cards with name, distance (km), elevation gain, day count
- Pull-to-refresh, loading spinner, error state with retry, empty state
- Safe area insets for notch and tab bar

API client:
- Import types from @trails-cool/api instead of hand-written interfaces
- Parse list/detail responses through Zod schemas at runtime
- Add zod as mobile app dependency

i18n:
- Add mobile namespace (en + de) for routes, activities, profile, login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:15:40 +02:00
Ullrich Schäfer
6ec95216e1
Add smoothness, track type, cycleway, and bike route color modes
Four new route visualization modes extracted from BRouter WayTags:

- Smoothness: excellent (green) → impassable (dark red)
- Track Type: grade1 (green, best) → grade5 (red, worst)
- Cycleway: track/lane/shared_lane/no with direction awareness
- Bike Route: icn/ncn/rcn/lcn priority from route_bicycle_* tags

Each mode includes map polyline coloring, elevation chart coloring,
inline legend, hover label, i18n (EN+DE), and mock fixture data.

Dockerfile patched to also expose tracktype in BRouter WayTags
(smoothness, cycleway, and route_bicycle already present).

Dropdown order: plain, elevation, grade, surface, road type, speed
limit, smoothness, track type, cycleway, bike route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
61d34821df
Re-add speed limit color mode with BRouter profile patch
The dummyUsage trick needs a separate assign per tag:
  assign dummyUsage2 = maxspeed=
(not appended to the existing smoothness= line)

- Dockerfile: Patch all BRouter profiles to include maxspeed in
  WayTags output via separate assign dummyUsage2
- brouter.ts: Extract maxspeed with direction handling
  (maxspeed:forward/backward + reversedirection awareness)
- ColoredRoute: maxspeed color mode (green ≤30, yellow ≤50,
  orange ≤70, red ≤100, dark red 100+)
- ElevationChart: maxspeed chart rendering, legend, hover "X km/h"
- PlannerMap: maxspeeds state + Yjs read + prop passing
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures: maxspeed data for E2E tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:44 +02:00
Ullrich Schäfer
cec613d989
Fix E2E select locator, remove maxspeed mode (data unavailable)
E2E fixes:
- Color mode select locator uses option[value='highway'] filter
  instead of fragile page.locator("select").last()

Removed maxspeed color mode:
- BRouter does not expose maxspeed in WayTags output despite the tag
  existing in its lookup table. The dummyUsage profile trick only
  affects cost calculation, not tiledesc output.
- Can revisit if BRouter adds maxspeed to WayTags in a future version
  or if we fork/patch the BRouter profiles more deeply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
4bcd6137de
Fix E2E select locator and add speed limit color mode
E2E fixes:
- Color mode select locator now uses option[value='highway'] filter
  instead of fragile page.locator("select").last()

Speed limit color mode:
- Extract maxspeed tags from BRouter tiledesc (with forward/backward
  direction handling following brouter-web's pattern)
- Color route and chart by speed limit: green ≤30, yellow ≤50,
  orange ≤70, red ≤100, dark red 100+ km/h
- Legend with speed thresholds, hover shows "X km/h"
- i18n: EN "Speed Limit" / DE "Tempolimit"
- Mock fixtures include maxspeed data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
1e2f6beded
Add road type color mode to route visualization
Extract highway=* tags from BRouter tiledesc messages alongside surface
data and add a new "Road Type" color mode that colors the route polyline
and elevation chart by OSM highway classification (cycleway, residential,
path, etc.). Includes color palette, legend, hover labels, i18n (EN+DE),
unit tests for tag extraction, and E2E tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:13:43 +02:00
Ullrich Schäfer
ec584c8b88
Add inline color legends next to chart title for all modes
Legends sit between the title and the mode selector dropdown:
- Grade: colored swatches with percentage thresholds
- Elevation: gradient bar from low (green) to high (red)
- Surface: text hint about surface coloring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
da1a5248d6
Move color mode selector to elevation chart, add grade profile
- Moved color mode dropdown from header bar to inline with the
  elevation chart title — closer to the visualization it controls
- Removed ColorModeToggle component from SessionView header
- Added "Grade" color mode: colors route and elevation chart by
  steepness (green <3%, yellow <6%, orange <10%, red <15%, dark red 15%+)
- Grade hover label shows percentage (e.g. "+4.2%")
- Fixed useTranslation namespace in ElevationChart ("planner")
- i18n: Added grade keys for en + de

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 03:21:39 +02:00
Ullrich Schäfer
3a0c40d9fd
Add Export Day Segments option for multi-day routes
When a route has overnight waypoints, the export dropdown shows a
third option: "Export Day Segments" which downloads one GPX file per
day. Each file is named day-N-start-name.gpx with the day's track
segment. Only visible when the route has multiple days.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:48:51 +02:00
Ullrich Schäfer
c23c325965
Add i18n keys and unit tests for overlays
- i18n: POI category names, panel UI strings (en + de)
- overpass.test.ts: Query building, response parsing, deduplication
- poi-cache.test.ts: Tile quantization, cache hit/miss, bbox filtering
- poi-categories.test.ts: Profile-to-overlay mapping, category validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:19: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
94016fd4c4
Add sidebar day breakdown and overnight toggle UI
- DayBreakdown: Collapsible day sections with per-day stats
- WaypointSidebar: Overnight toggle button (moon icon), day-grouped view
  when any waypoint is marked overnight, route summary in header
- SessionView: Wire useDays() hook into sidebar via SidebarTabs
- i18n: Add multiDay keys for en + de (day labels, overnight, stats)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:53:43 +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
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
743169550e
Add undo/redo to planner
Yjs UndoManager tracks waypoints, no-go areas, and notes. Only
local mutations (origin "local") are undoable — remote changes
from other users are not.

- Keyboard shortcuts: Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z/Y (redo)
- Shortcuts suppressed in text inputs (browser-native undo there)
- Undo/redo buttons in header with disabled state
- stopCapturing on drag to isolate drag as one undo step
- All mutation sites wrapped with "local" origin
- 5 unit tests for UndoManager behavior
- Archived undo-redo change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:04:01 +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
a9f8ee61f0
Add GPX file import to planner + archive change
Two import entry points:
- Home page: "Import GPX" button next to "Start Planning"
- In-session: drag-and-drop GPX onto the map (with confirmation)

Parses GPX client-side, extracts waypoints (Douglas-Peucker) and
no-go areas from extensions. Non-GPX files show error toast.

Also:
- Fix spec drift: non-GPX drop now shows error toast (was silent)
- Add E2E tests for import button, invalid GPX, and session creation
- Archive gpx-import-planner change, sync specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:31:44 +01:00
Ullrich Schäfer
e12479293d
Add descriptions to export dropdown, fix z-index and alignment
- Add description text below each export option explaining the difference
- Fix z-index (z-[1001]) so dropdown renders above the map
- Align dropdown left instead of right to avoid overflow
- Widen dropdown (w-56) to fit descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:06:58 +01:00
Ullrich Schäfer
4b711abaec
Add split export: Export Route vs Export Plan
Split button with default "Export GPX" (track only) and dropdown:
- Export Route: clean track for use in any app
- Export Plan: track + waypoints + no-go areas in GPX extensions
  (trails:planning namespace) for reimporting into the planner

Also:
- Add no-go area serialization to generateGpx (GPX extensions)
- Parse no-go areas from GPX extensions on import
- Initialize no-go areas in Yjs session from imported GPX
- Save to Journal now exports track only (consistent with Export Route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:52:39 +01:00
Ullrich Schäfer
b9a77be020
Add route error toasts with waypoint rollback
- Show error toasts when route calculation fails, with distinct messages:
  - "No route found" for BRouter 4xx (unroutable waypoints, missing tiles)
  - "Route calculation failed" for server errors / network failures
  - "Too many requests" for rate limiting
- Rollback all waypoints to last known good state on route failure
- Guard against restore→recompute loop via restoringRef flag
- Extract reusable useToasts hook from awareness toast logic
- Add BRouterError class to distinguish client vs server errors
- API returns 422 for unroutable requests instead of blanket 502

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:09:20 +02:00
Ullrich Schäfer
be5766fa50
Add account settings page with passkey management
- /settings with profile, security, and account sections
- Profile: edit display name and bio
- Security: list passkeys with device labels, add/delete with
  last-passkey warning
- Account: email change with magic link verification, account
  deletion with username confirmation
- Settings link in nav for authenticated users
- E2E tests: auth guard, profile editing, passkey add/delete,
  last-passkey warning, account deletion, nav visibility
- i18n: full en + de translations for all settings UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:09:06 +02:00
Ullrich Schäfer
9adf9b977a
Passkey browser compatibility: detect support, magic link fallback
- Login: hide passkey button when browser lacks WebAuthn, default to
  magic link mode, hide "back to passkey" link
- Register: detect passkey support and offer magic link registration as
  fallback. New server-side registerWithMagicLink() creates account
  without credential and sends verification email.
- Home: show passkey count for logged-in users, show amber unsupported
  message instead of hiding the add-passkey prompt entirely
- i18n: add passkeyNotSupported, passkeyNotSupportedRegister,
  registerWithMagicLink, passkeyStatus keys (en + de)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 09:54:37 +02:00
Ullrich Schäfer
b080a15fb1
Add route interactions: click-to-split, drag-to-reshape, colored route rendering
- Enrich BRouter response with per-point 3D coordinates and segment boundary
  tracking (EnrichedRoute interface)
- ColoredRoute component: plain, elevation gradient (green→yellow→red), and
  surface color modes with invisible wide polyline for click targeting
- Click-to-split: click on route polyline inserts waypoint at nearest point,
  mapped to correct segment via boundary indices
- MidpointHandles: draggable CircleMarkers at route segment midpoints for
  reshaping, hidden below zoom 12, opaque on hover
- Color mode toggle (select) synced via Yjs routeData
- i18n keys for color mode labels (en + de)
- Unit tests for segment boundary tracking (13 tests)
- E2E tests for enriched route response and color mode toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:15:55 +01:00
Ullrich Schäfer
0a8dd0b766
Add transactional emails (SMTP) and planner features (no-go areas, notes, crash recovery)
Transactional emails:
- Add nodemailer SMTP email module with dev-mode console logging
- Magic link template and welcome template with HTML + plain text
- Wire sendMagicLink into login flow, sendWelcome into registration
- Update privacy page and deploy docs for SMTP configuration

Planner features:
- No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs,
  passed to BRouter as nogos parameter, route recomputes on change
- Session notes: collaborative Y.Text textarea in sidebar tab
- Crash recovery: periodic localStorage save of Yjs state, restore on reconnect
- Rate limit session creation (10/IP/hour) in /new route

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:00:42 +01:00
Ullrich Schäfer
9b4f4c6759
Add multiplayer awareness: participant list, name editing, cursor styling, join/leave toasts
Implement the planner-multiplayer-awareness OpenSpec change:

- Add setUserName to use-yjs.ts that persists to localStorage and syncs via awareness
- Create ParticipantList component showing all session participants with color dots,
  host badge, and inline name editing for the local user
- Replace plain text cursor labels with SVG pointer arrows and styled name tags
  (colored background, drop shadow, rounded corners, z-index below map controls)
- Track awareness add/remove events and display auto-dismissing toasts (3s)
  when participants join or leave the session
- Add i18n keys for participant UI in English and German

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:40:35 +01:00
Ullrich Schäfer
bb4f193405 Add app navigation bars to Journal and Planner
Journal gets a nav bar in root.tsx with conditional links: Routes and
Activities for authenticated users, Sign In and Register for guests.
Active route is highlighted via useLocation(). User menu shows username
linking to profile and a logout button.

Planner SessionView header title becomes a home link back to /.

Adds i18n keys (en + de) for all nav labels. Updates E2E tests to use
scoped nav selectors now that links appear in both nav bar and page body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 03:12:27 +00:00
Ullrich Schäfer
4ddaad8c5f
Add planner landing page with features and CTAs
Replace blank home page with a landing page explaining collaborative
route planning. Hero section with "Start Planning" CTA, 5 feature cards
(collaboration, routing, elevation, GPX, no account), secondary CTA
linking to Journal, and footer with privacy/source/attribution links.

All strings in en + de via i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:03:06 +01:00
Ullrich Schäfer
b96b32cb53 Wire up i18n in both apps
Initialize i18n via initI18n() in both root.tsx files and replace all
hardcoded user-facing strings with useTranslation() calls. Adds missing
translation keys for planner (profiles, connection states, save flow)
and journal (auth pages, route management, passkey prompts).

Both English and German translations are complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:47:58 +00:00
Ullrich Schäfer
2cfa5e54e7
Implement shared packages (tasks 2.1-2.6)
- @trails-cool/types: Route, Activity, Waypoint, RouteVersion, RouteMetadata
- @trails-cool/gpx: GPX parser (XML→waypoints/tracks/elevation) and generator
  with round-trip test, haversine distance, elevation gain/loss computation
- @trails-cool/map: MapView (Leaflet + OSM/OpenTopoMap/CyclOSM layer switcher),
  RouteLayer (GeoJSON polyline rendering)
- @trails-cool/ui: Button (primary/secondary/ghost), Input (with label/error),
  Card components with Tailwind styling
- @trails-cool/i18n: react-i18next config with English + German translations,
  browser language detection, fallback to English

All 13 unit tests pass. Both apps build successfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:09:35 +01:00