New
Apps 1,895Makers 1,890This week 1,690
Promote your app−30%
Loam Logger screenshot 1
0views in
the last 30 days

Loam Logger

A mountain bike–focused ride tracker built with React + Vite + GraphQL + Prisma.

Built with Claude Code

by ryanlecours · Unclaimed · from USUnited States · added Oct 29, 2026
no ratings0 comments

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, and CORS_ORIGIN variables.

Why the container might fail to start: nixpacks.toml runs prisma migrate deploy before 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 because DATABASE_URL is missing or points at an unreachable Postgres instance — Railway's logs will show Environment variable not found: DATABASE_URL or 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_URL to 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.ts registers the loam:prerender-landing plugin, whose closeBundle hook runs apps/web/scripts/prerender-landing.mjs in a child process. The script boots a Vite SSR server, renders MarketingLanding under a StaticRouter (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 build and Vercel's npm run build:web all produce the same output, and an Nx cache hit restores the prerendered file. CI fails if the built index.html lacks the landing root or the guard.
  • The landing tree is rendered in Node, so nothing it imports may touch window, document, localStorage or matchMedia at module scope or during render. Browser-only work belongs in useEffect. 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=1 to skip the step (for a quick local build, or if the SSR render ever breaks and a deploy cannot wait). npm run prerender:web runs 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/api directory

📜 License

MIT © 2025 Ryan LeCours


🏔️ "Anyone can track a ride. Loam Logger tracks your bike."

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#mountain-biking · #rides · #tracker