mobile-app: Unified React Native + Expo app combining Planner and Journal. OAuth2 PKCE auth, MapLibre maps, versioned REST API with Zod schemas, configurable server URL, offline SQLite, Web Push relay notifications. TanStack Query + Zustand state management. Jest + Maestro testing. 76 tasks across 5 phases. map-core-package: Extract renderer-agnostic map definitions (tiles, color palettes, POI categories, z-index) into @trails-cool/map-core. Pure refactor preparing for MapLibre on mobile. 27 tasks. mobile-activity-recording: GPS recording, live stats, HealthKit/Health Connect export. Separated from mobile-app for independent scheduling. mobile-nearby-sync: BLE route sync between nearby devices for offline group riding. QR waypoint sharing as simpler v1. TXQR noted as future. journal-rest-api spec: Full API contract — endpoints, auth, pagination, errors, discovery, versioning, BRouter proxy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
Context
trails.cool is a two-app web platform: Planner (stateless, collaborative, Yjs) and Journal (accounts, PostgreSQL, ActivityPub). The Planner uses Leaflet for maps, Yjs for real-time sync, and BRouter for routing. The Journal stores routes, activities, and user data. Shared packages (types, gpx, i18n) are pure TypeScript.
The mobile app combines both apps into a single React Native + Expo experience, communicating with the Journal as its backend. The user is an experienced React Native/Expo developer.
Goals / Non-Goals
Goals:
- Unified mobile app (plan + journal in one)
- Authenticate with existing Journal account
- View and edit routes on the go
- Record GPS activities
- Work offline for viewing routes
- Reuse shared TypeScript packages
Non-Goals:
- Full collaborative Yjs editing on mobile (too complex for v1 — use direct API)
- Self-hosting the mobile app (App Store distribution only)
- Replacing the web Planner (mobile editing is simplified)
- ActivityPub federation from mobile (handled by Journal server)
- Offline route computation (BRouter requires server)
Decisions
D1: Expo managed workflow with monorepo integration
Place the app at apps/mobile/ in the existing pnpm monorepo. Use Expo managed workflow (no native code ejection) with EAS Build for CI/CD. Share packages via pnpm workspace — @trails-cool/types, @trails-cool/gpx, @trails-cool/i18n imported directly.
The gpx package uses linkedom for Node.js XML parsing. On mobile, use the built-in DOMParser (available in React Native's JSC/Hermes via a polyfill or the existing browser code path in parseGpx).
D2: Authentication via OAuth2 PKCE
The Journal currently uses passkeys + magic links. Neither works well in a native app context. Add an OAuth2 authorization code flow with PKCE to the Journal:
- Mobile app opens Journal's
/oauth/authorizein an in-app browser - User authenticates via existing passkey/magic link
- Journal redirects back with auth code
- Mobile app exchanges code for access + refresh tokens
- Tokens stored in Expo SecureStore
This keeps the auth UI on the web (no need to implement passkey in native code) while giving the mobile app long-lived tokens.
The mobile app sends a device name (e.g., model + OS version) during token exchange. The Journal stores this alongside the token, enabling a "Connected Devices" list on the Journal web settings page where users can see active sessions and revoke individual devices. This is primarily relevant for the hosted version — self-hosted instances with a single user would rarely use it, but the data is stored regardless.
D2b: Configurable server URL
The mobile app defaults to https://trails.cool but allows connecting to any self-hosted Journal instance. On the login screen, a "Connect to a different server" option lets the user enter a custom URL (e.g., https://trails.example.org). The app validates the URL by fetching /.well-known/trails-cool (or a similar discovery endpoint) to confirm it's a compatible instance.
The server URL is stored persistently and used as the base for all API calls and the OAuth2 login flow. Users can switch instances from the Profile tab — this logs them out and clears local data.
The /.well-known/trails-cool discovery endpoint returns an apiVersion semver string (e.g., "1.0.0"). The mobile app declares a requiredApiVersion (minimum semver it needs). On connect and on app foreground, the app checks:
- Server
apiVersionsatisfies app's required range → normal operation. - Server
apiVersiontoo low → Block: "This server needs to be updated. Please ask the administrator to upgrade." The app still allows read-only access to cached offline data.
The API follows semver: patch versions for fixes, minor versions for additive changes (new endpoints, new optional fields), major versions only for breaking changes. Since the API is backwards compatible by design, a newer server always works with an older app — no "server too new" check needed.
D2c: Shared API contract package
A new packages/api/ workspace package (@trails-cool/api) serves as the single source of truth for the REST API:
API_VERSIONconstant (semver string)- TypeScript types for all request/response shapes (e.g.,
RouteListResponse,CreateActivityRequest) - Endpoint path constants (e.g.,
ENDPOINTS.routes.list = "/api/v1/routes") - Error response type
The package uses Zod schemas as the source of truth — TypeScript types are inferred via z.infer<>. The Journal server uses schemas to validate incoming request bodies. The mobile client can optionally validate responses (useful for self-hosted instances on older versions). Both sides share the same schemas — runtime validation and type safety from one definition.
D3: MapLibre GL for mobile maps
Leaflet is web-only. Use react-native-maplibre-gl for mobile — OSM vector tiles, GPU-accelerated, free, consistent with the web's OSM data source. Requires an Expo config plugin but is well-supported.
The web Planner stays on Leaflet for now, but map logic should be structured for future convergence:
@trails-cool/map-core(new package): Renderer-agnostic definitions — tile source URLs, overlay configs, color palettes (route coloring, POI categories), z-index layering order. Pure data, no rendering code.@trails-cool/map(existing): Leaflet-specific components for web, imports frommap-coreapps/mobile/: MapLibre-specific components for mobile, imports frommap-core
This way, switching the web to MapLibre later means replacing @trails-cool/map internals while map-core stays unchanged. The mobile app validates that map-core abstractions work before the web migration.
D4: Simplified route editing (no Yjs)
Mobile route editing talks directly to the Journal API:
- Fetch route GPX → parse waypoints
- User adds/moves/deletes waypoints on the map
- Compute route via BRouter (through a proxy endpoint on the Journal, or direct to the Planner's BRouter instance)
- Save updated GPX back to Journal API
No real-time collaboration on mobile. If the user needs full collaborative editing, deep-link to the Planner web app.
D5: Offline with SQLite + tile cache
- Routes: Download GPX + parsed waypoints into Expo SQLite
- Map tiles: Use
react-native-maps' built-in tile caching, or download tile packages for specific regions - Activities: Stored locally first, synced to Journal when online
- Edit queue: Changes made offline queued and synced on reconnect (conflict resolution: last-write-wins, with a warning if the server version changed)
D6: Activity recording with expo-location
Use expo-location for GPS tracking in the foreground/background:
- Start/stop recording from the active route view
- Live stats: distance, duration, current speed, elevation gain
- Track points stored locally, converted to GPX on stop
- Save as Journal activity linked to the route
- Export to HealthKit (iOS) / Health Connect (Android) via
expo-health
D7: Navigation structure
Tab bar with 4 tabs:
- Map: Current route on map, quick edit, start recording
- Routes: List of user's routes from Journal
- Activities: List of recorded activities
- Profile: Account settings, offline data management, sync status
D8: BRouter routing via Journal API proxy
The mobile app computes routes through the Journal API: POST /api/v1/routes/compute. The Journal forwards the request to its BRouter instance and returns the enriched route. This avoids exposing BRouter publicly and works with self-hosted instances where the BRouter container isn't publicly accessible.
D9: Photo uploads via presigned URLs
Route and activity photos are stored in S3/Garage (existing infrastructure). The API returns presigned upload URLs — the mobile app uploads directly to storage, then confirms the upload via the API. This avoids proxying large files through the Journal server.
D10: Cursor-based pagination
All list endpoints (routes, activities) use cursor-based pagination. The response includes a nextCursor field — the client passes it back to get the next page. This handles feeds that change (new items, deletions) without skipping or duplicating entries.
D11: Expo monorepo support
Use Expo's built-in experiments.monorepo: true in app.config.ts to resolve pnpm workspace packages in Metro. This handles symlink resolution and shared package discovery automatically.
D12: Push notifications via Web Push relay
Following Mastodon's proven architecture for federated push notifications:
- Journal instances implement standard Web Push API (RFC 8030) — no Apple/Google dependencies on the server side
- trails.cool hosts a relay service that translates Web Push → APNs (iOS) and FCM (Android). Self-hosted instances send standard Web Push to this relay.
- Notifications are end-to-end encrypted — the relay forwards encrypted payloads without reading them
- Self-hosted admins need zero configuration — no Apple Developer account, no Firebase project. Standard Web Push just works.
- The relay is open-source — self-hosters who want full independence can run their own relay with their own APNs/FCM credentials
Flow: Journal instance → Web Push (encrypted) → trails.cool relay → APNs/FCM → phone
This aligns with the ActivityPub federation direction and matches how Mastodon clients (Toot!, Ice Cubes, etc.) handle push for thousands of self-hosted instances through a single relay.
D13: State management
TBD — to be discussed.
Risks / Trade-offs
- BRouter access from mobile: The BRouter instance runs in Docker alongside the Planner. The mobile app needs a route computation endpoint. Options: (a) proxy through Journal API, (b) expose BRouter publicly with auth, (c) use a public BRouter instance. Option (a) is safest.
- Map tile licensing: Google Maps requires an API key and has usage-based pricing. Apple Maps is free on iOS. Consider MapLibre with OpenStreetMap tiles for a free, consistent cross-platform solution.
- React Native Maps vs MapLibre:
react-native-mapsis more mature but ties to Google/Apple.react-native-maplibre-gluses vector tiles and is more consistent with the web's OSM-based approach. Worth evaluating. - Offline storage size: Map tiles for a region can be hundreds of MB. Need a download manager with progress and storage budget.
- App Store review: GPS background tracking + health data access need careful privacy descriptions.