A mountain bike–focused ride tracker built with React + Vite + GraphQL + Prisma. Loam Logger lets riders log, analyze, and visualize their rides while tracking bike components and wear over time.
Built by Ryan LeCours to combine data-driven performance tracking with the MTB lifestyle.
🚀 Tech Stack
Web App
- Framework: Vite + React 19 + TypeScript
- Styling: Tailwind CSS v4 + modular CSS architecture
- State / API: Apollo Client (GraphQL)
- Animations: Motion (Framer Motion)
- Drag & Drop: @dnd-kit
- Testing: Vitest + React Testing Library
- Deployment: Vercel
- Theme: Custom Loam palette (obsidian + forest + mahogany tones)
Mobile App
- Framework: React Native + Expo Router
- Navigation: File-based routing with tab navigation
- State / API: Apollo Client (GraphQL with bearer token auth)
- Auth: Email/password, Google Sign-In, Apple Sign-In
- Storage: Expo SecureStore for encrypted token storage
Backend API
- Runtime: Node.js 20 + TypeScript
- Framework: Express + Apollo Server (GraphQL)
- ORM: Prisma ORM
- Database: PostgreSQL (hosted on Railway)
- Auth: JWT tokens (cookie-based for web, bearer token for mobile)
- Hosting: Railway
Monorepo
- Build System: Nx
- Package Manager: npm workspaces
- Shared Libraries:
@loam/graphql(GraphQL operations),@loam/shared(types & utils)
📁 Monorepo Structure
loam-logger/
├── apps/
│ ├── web/ # React + Vite web app
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── dashboard/ # Dashboard components
│ │ │ │ ├── gear/ # Bike & component management
│ │ │ │ └── ui/ # Shared UI components
│ │ │ ├── pages/
│ │ │ └── styles/
│ │ │ ├── index.css # Main entry
│ │ │ ├── design-system/ # Colors, typography, utilities
│ │ │ ├── components/ # Button, card, form styles
│ │ │ ├── layout/ # Navigation, backgrounds
│ │ │ ├── pages/ # Page-specific styles
│ │ │ └── animations.css
│ │ └── vite.config.ts
│ │
│ ├── api/ # Express + GraphQL API
│ │ ├── prisma/
│ │ │ ├── schema.prisma
│ │ │ └── migrations/
│ │ └── src/
│ │ ├── server.ts
│ │ ├── graphql/
│ │ ├── routes/
│ │ └── auth/
│ │
│ └── mobile/ # React Native + Expo app
│ ├── app/
│ │ ├── (auth)/ # Auth screens
│ │ └── (tabs)/ # Main app tabs
│ └── src/
│ ├── lib/ # Apollo Client, auth utils
│ └── hooks/ # Auth context
│
├── libs/
│ ├── graphql/ # Shared GraphQL operations
│ │ ├── src/
│ │ │ ├── operations/
│ │ │ ├── fragments/
│ │ │ └── generated/
│ │ └── codegen.ts
│ │
│ └── shared/ # Shared types & utilities
│ └── src/
│ ├── types/
│ ├── utils/
│ └── constants/
│
├── nx.json # Nx workspace config
├── tsconfig.base.json # Shared TypeScript config
└── package.json # npm workspaces config
⚙️ Setup
1. Clone & install
git clone https://github.com/ryanlecours/loam-logger.git
cd loam-logger
npm install
2. Environment variables
Backend (apps/api/.env)
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=require"
CORS_ORIGIN="http://localhost:5173"
FRONTEND_URL="http://localhost:5173"
# Redis (required for job queues and rate limiting)
REDIS_URL="redis://localhost:6379"
# Garmin Connect API
GARMIN_CONSUMER_KEY="your-garmin-consumer-key"
GARMIN_CONSUMER_SECRET="your-garmin-consumer-secret"
# Strava API
STRAVA_CLIENT_ID="your-strava-client-id"
STRAVA_CLIENT_SECRET="your-strava-client-secret"
STRAVA_WEBHOOK_VERIFY_TOKEN="your-webhook-verify-token"
# WHOOP API
WHOOP_CLIENT_ID="your-whoop-client-id"
WHOOP_CLIENT_SECRET="your-whoop-client-secret"
# Google OAuth (web + mobile)
GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_IOS_CLIENT_ID="your-google-ios-client-id.apps.googleusercontent.com"
# Apple Sign-In (mobile)
APPLE_BUNDLE_ID="com.example.yourapp"
# Device attestation on mobile signup (App Attest + Play Integrity)
DEVICE_ATTESTATION_MODE="monitor" # off | monitor | enforce
APPLE_TEAM_ID="ABCDE12345"
PLAY_INTEGRITY_SERVICE_ACCOUNT_JSON="" # service account key JSON, raw or base64
DEVICE_ATTESTATION_BYPASS_EMAILS="" # comma-separated exact addresses you own; no +tag folding, no domains
ANDROID_PACKAGE_NAME="com.loamlabs.loamlogger" # default; must match the Play app or every verdict fails package_mismatch
# Cloudflare Turnstile on web signup. Dev uses Cloudflare's always-pass test secret.
TURNSTILE_MODE="monitor" # off | monitor | enforce
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
TURNSTILE_ALLOWED_HOSTNAMES="" # e.g. "loamlogger.app,www.loamlogger.app"; empty skips the check
# RevenueCat (IAP webhooks)
REVENUECAT_WEBHOOK_AUTH_KEY="your-revenuecat-webhook-auth-key"
# Auth
JWT_SECRET="your-jwt-secret"
SESSION_SECRET="your-session-secret"
Frontend (apps/web/.env)
VITE_API_URL=http://localhost:4000/graphql
# Cloudflare's always-pass test site key; leave unset to hide the widget
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
🧩 Database Setup
cd apps/api
# validate the schema + env
npx prisma validate --schema=./prisma/schema.prisma
# generate Prisma client
npx prisma generate --schema=./prisma/schema.prisma
# create / apply local migrations
npx prisma migrate dev --schema=./prisma/schema.prisma
# optional: open Prisma Studio
npx prisma studio
If using Railway, your DATABASE_URL will point to the hosted Postgres instance.
🖥️ Run Locally
All commands should be run from the root directory of the monorepo:
Web App
npm run dev:web
Starts Vite on http://localhost:5173
API
npm run dev:api
Runs the GraphQL API at http://localhost:4000/graphql
Mobile App
npm run dev:mobile
Starts Expo development server. Scan QR code with Expo Go app on your device.
Build All
npm run build
Builds all apps using Nx affected commands.
Run Tests
npm run test:web
Runs all 400+ unit tests with Vitest.
🧠 Features
✅ Current
- Gear Management — Full bike and component management system
- Component Health Tracking — Service predictions with status indicators (overdue, due soon, all good)
- Dashboard — Priority bike hero, recent rides, component health panels
- Service Logging — Log component services with date tracking and wear baselines
- Bike Detail Pages — Individual bike profiles with specs, components, and service history
- E-bike Support — Motor power, torque, and battery specifications
- 99 Spokes Integration — Import bike specifications from 99spokes.com
- Spare Components — Track components not currently installed on bikes
- Light/Dark Theming — Premium dark theme with obsidian + forest tones
- Modular CSS Architecture — Refactored styling system with design tokens
- Drag & Drop Bike Sorting — Reorder bikes on dashboard
- Comprehensive Test Coverage — 700+ unit tests across components
🔗 Data Source Integrations
Loam Logger connects to popular fitness platforms to automatically import your rides:
| Platform | Features |
|---|---|
| Garmin Connect | OAuth integration, webhook-based real-time sync, historical backfill with import sessions |
| Strava | OAuth integration, webhook-based real-time sync, gear mapping to bikes, historical backfill |
| WHOOP | OAuth integration, webhook-based real-time sync, Cycling + Mountain Biking support, API v2 with UUID workout IDs |
Key capabilities:
- Real-time sync — Webhooks push new activities as they're recorded
- Historical backfill — Import past rides by year or YTD with incremental checkpointing
- Cross-provider duplicate detection — Avoid importing the same ride from multiple sources
- Automatic bike assignment — Strava gear IDs map to your bikes; single-bike users get auto-assignment
- Component hour tracking — Ride duration automatically updates component wear metrics
🔜 Coming Soon
- Analytics dashboard with ride insights
- Mobile app features (ride sync, gear tracking, offline support)
📊 Analytics Stack (Planned)
Frontend (React)
└── PostHog (events, funnels, feature flags)
Backend (Node / GraphQL)
├── PostgreSQL (source of truth)
├── Aggregated metrics tables
└── Sentry (errors + performance)
Infrastructure
├── Vercel Analytics (traffic)
└── PostHog (product insights)
🧱 Theming
The Loam Logger design system uses earthy tones inspired by trail environments.
Color System:
- Obsidian base — Deep near-blacks (rgb(12, 12, 14))
- Forest/Sage accents — Muted greens for primary actions
- Mahogany warmth — Reddish-brown accent colors
- Warm-tinted neutrals — Subtle warmth in grays
Defined in styles/design-system/colors.css.
🔒 Environment & Deployment
Railway → Backend
- Deploy backend using the Railway CLI or GitHub integration.
- Set your production
DATABASE_URL,JWT_SECRET, andCORS_ORIGINvariables.
Why the container might fail to start:
nixpacks.tomlrunsprisma migrate deploybefore the server process on every boot, so the service will not come up if pending migrations can't apply. The start command retries the migration up to 5 times with 5 s backoff to absorb the cold-start race where Postgres hasn't finished booting yet; after that it exits and Railway's restart policy takes over. If the container still can't boot, it's usually becauseDATABASE_URLis missing or points at an unreachable Postgres instance — Railway's logs will showEnvironment variable not found: DATABASE_URLor a connection error from Prisma. Confirm the var is set on the service's Variables tab in Railway before retrying the deploy. This "fail-fast on schema mismatch" is intentional: we'd rather the container refuse to start than boot and throw cryptic "column does not exist" errors at runtime.
Vercel → Frontend
- Set
VITE_API_URLto your Railway backend endpoint. - Enable automatic redeploys from
main.
🧑💻 Development Workflow
GraphQL Code Generation
When you update the GraphQL schema:
# 1. Start the API (required for introspection)
npm run dev:api
# 2. Generate TypeScript types
npm run codegen
# 3. Commit the generated files
git add libs/graphql/src/generated/
git commit -m "Update GraphQL types"
Important: Generated files are committed to the repository. See libs/graphql/README.md for details.
Nx Commands
# Lint all affected projects
npx nx affected -t lint
# Build all affected projects
npx nx affected -t build
# Run specific app
npx nx serve web # Web app
npx nx serve api # API
npx nx start mobile # Mobile app
# Run tests
npx nx test web # Web tests
# View project graph
npx nx graph
Landing page prerender
The web app is a client-rendered SPA, so crawlers that skip JavaScript would see an empty #root. To keep the marketing landing page readable by search and AI crawlers, every production build of web ends by prerendering the / route into dist/apps/web/index.html:
apps/web/vite.config.tsregisters theloam:prerender-landingplugin, whosecloseBundlehook runsapps/web/scripts/prerender-landing.mjsin a child process. The script boots a Vite SSR server, rendersMarketingLandingunder aStaticRouter(src/prerender-entry.tsx), and writes the markup inside#root(src/prerender-inject.ts).- On any path other than
/, an inline guard keeps that markup invisible until React mounts and replaces it (RevealAfterMount). If a Content-Security-Policy without'unsafe-inline'is ever added, the guard needs a nonce or must move into the bundle. nx build web,npm run buildand Vercel'snpm run build:weball produce the same output, and an Nx cache hit restores the prerendered file. CI fails if the builtindex.htmllacks the landing root or the guard.- The landing tree is rendered in Node, so nothing it imports may touch
window,document,localStorageormatchMediaat module scope or during render. Browser-only work belongs inuseEffect. A violation fails the production build with a stack trace from the prerender step. - The step adds a few seconds to a production build: the child boots a Vite SSR server from the same
vite.config.ts(plugins included, with the Sentry token blanked), renders once, and exits. If the render throws, the build fails with the stack trace. - Set
LOAM_SKIP_PRERENDER=1to skip the step (for a quick local build, or if the SSR render ever breaks and a deploy cannot wait).npm run prerender:webruns the step on its own against an existing build.
CI/CD
GitHub Actions runs automatically on push and PR. See CI_CD_GUIDE.md for troubleshooting.
Key points:
- Only affected projects are tested/built
- GraphQL types must be generated locally and committed
- Production APIs have introspection disabled for security
Development Notes
- Nx workspace with affected commands for faster builds
- Shared libraries (
@loam/graphql,@loam/shared) for code reuse - React 19.1 enforced via npm overrides across all projects
- Prisma commands run from
apps/apidirectory
📜 License
MIT © 2025 Ryan LeCours
