A comprehensive guide to the RunFlow running performance dashboard
- Project Overview
- Architecture
- Data Models
- Metrics & Calculations
- API Reference
- Component Architecture
- Strava Integration
- Training Plans
- Development Guide
- Deployment
- Troubleshooting
- Mobile App (Capacitor)
RunFlow is a production-grade running performance dashboard that combines structured training plans with deep analytics. It is designed for runners who want to:
- Sync activities automatically from Strava or Google Health Connect
- Track fitness metrics using scientifically-established models
- Create training plans for races (5K, 10K, Half Marathon, Marathon)
- Analyze performance trends and training effectiveness
- Monitor training load using CTL/ATL/TSB metrics
| Feature | Description |
|---|---|
| Strava Integration | OAuth authentication with real-time webhook support |
| Health Connect | Direct sync from Garmin, Peloton, etc. via Mobile App |
| VDOT Calculator | Based on Jack Daniels' Running Formula |
| Fitness Metrics | CTL (Chronic Training Load), ATL (Acute Training Load), TSB (Training Stress Balance) |
| TRIMP | Training Impulse calculation based on heart rate |
| Running TSS | Running-specific Training Stress Score |
| Marathon Shape | Predictive model for marathon readiness |
| Training Plans | Automated workout generation based on VDOT |
Frontend/Backend:
- Next.js 15 (App Router)
- React 19
- TypeScript (strict mode)
- Tailwind CSS 4
- React Query (@tanstack/react-query)
- Recharts (data visualization)
- next-themes (dark/light mode)
Database & ORM:
- PostgreSQL 16
- Prisma ORM 7
Authentication:
- Auth.js (NextAuth v5)
- Strava OAuth
- Email (Magic Code / Password)
Infrastructure:
- Docker (multi-stage builds)
- Docker Compose (orchestration)
- Redis (rate limiting & locks)
RunFlow/
├── prisma/
│ └── schema.prisma # Database schema
├── public/ # Static assets
├── scripts/
│ ├── restore.sh # Database restore script
│ └── entrypoint.sh # Container entry point
├── android/ # Native Android wrapper (Capacitor)
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── api/ # API routes
│ │ │ ├── analytics/ # Analytics endpoints
│ │ │ ├── sync/ # Strava sync
│ │ │ ├── webhooks/ # Strava webhooks
│ │ │ └── auth/ # NextAuth handlers
│ │ ├── dashboard/ # Dashboard pages
│ │ ├── analytics/ # Analytics pages
│ │ └── globals.css # Global styles
│ ├── components/ # React components
│ │ ├── dashboard/ # Dashboard-specific components
│ │ ├── analysis/ # Analysis charts
│ │ └── providers/ # Context providers
│ └── lib/ # Core business logic
│ ├── metrics/ # Analytics calculations
│ │ ├── vdot.ts # VDOT calculator
│ │ ├── fitness.ts # CTL/ATL/TSB
│ │ ├── trimp.ts # Heart rate training impulse
│ │ └── runalyze.ts # Runalyze algorithms
│ ├── strava/ # Strava integration
│ ├── services/ # Business logic services
│ └── utils/ # Utility functions
├── tests/ # Test files
├── docker-compose.yml # Production deployment
├── docker-compose.dev.yml # Development overrides
├── Dockerfile # Multi-stage container build
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
└── next.config.mjs # Next.js configuration
RunFlow uses a Service Layer Pattern to separate business logic from API routes:
Request -> API Route -> Service Layer -> Prisma/Database -> Response
|
v
Metrics Calculations
Key services:
src/lib/services/analytics.ts- Analytics aggregationsrc/lib/strava/sync.ts- Strava synchronizationsrc/lib/services/plan.ts- Training plan generation
┌─────────────────────────────────────────────────────────────────────┐
│ RunFlow Data Flow │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Strava │───▶│ Webhook │───▶│ Database │◀───│ Sync │ │
│ │ │ │ Handler │ │ │ │ Service │ │
│ └──────────┘ └──────────┘ └────▲─────┘ └──────────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ Health Conn. │───▶ Mobile App ─────┘ (via API) │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Activities │ │
│ │ with HR data │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Metrics Calculation │ │
│ │ (TRIMP, rTSS, VDOT) │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Fitness Metrics │ │
│ │ (CTL, ATL, TSB) │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ Dashboard Display │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User │───────│ Activity │ │ Goal │
├─────────────┤ 1 N ├─────────────┤ N 1 ├─────────────┤
│ id │ │ id │ │ id │
│ email │ │ stravaId │ │ name │
│ stravaId │ │ type │ │ raceType │
│ hrMax │ │ distance │ │ raceDate │
│ hrRest │ │ movingTime │ │ targetTime │
│ weight │ │ averageHr │ │ isActive │
│ ... │ │ trimp │ │ ... │
├─────────────┤ │ runningTss │ ├─────────────┤
│ DailyFitness│ │ estimatedVdot│ │ Workout │
├─────────────┤ │ ... │ 1 N ├─────────────┤
│ userId │ └─────────────┘ │ id │
│ date │ │ goalId │
│ ctl │ │ workoutType │
│ atl │ │ description │
│ tsb │ │ isCompleted │
└─────────────┘ └─────────────┘
The User model stores athlete profile information and authentication:
model User {
// Identity
id String @id @default(cuid())
email String? @unique
name String?
image String?
// Strava OAuth
stravaId String? @unique
stravaAccessToken String?
stravaRefreshToken String?
stravaTokenExpiry DateTime?
// Athlete Profile (for calculations)
sex Sex @default(MALE)
birthDate DateTime?
hrMax Int? // Maximum heart rate
hrRest Int? // Resting heart rate
weight Float? // kg
height Float? // cm
// HR Zone thresholds (% of hrMax)
hrZone1Max Int @default(130) // Z1: Active Recovery
hrZone2Max Int @default(148) // Z2: Endurance
hrZone3Max Int @default(160) // Z3: Tempo
hrZone4Max Int @default(170) // Z4: Threshold
hrZone5Max Int @default(178) // Z5: VO2max
hrZone6Max Int @default(187) // Z6: Anaerobic
// Z7: Neuromuscular Power (derived as > Z6)
// VDOT Correction
vdotCorrectionFactor Float @default(1.0)
vdotReferenceRaceDate DateTime?
vdotReferenceRaceTime Int? // seconds
vdotReferenceRaceType String? // "5K", "10K", "HALF", "MARATHON"
// Sync Status
lastSyncAt DateTime?
syncInProgress Boolean @default(false)
// Relations
activities Activity[]
goals Goal[]
DailyFitness DailyFitness[]
}Stores synchronized Strava activities:
model Activity {
id String @id @default(cuid())
userId String
stravaId BigInt @unique
// Activity Type
type ActivityType // RUN, RIDE, SWIM, etc.
sportType String?
// Basic Metrics
name String
description String?
startDate DateTime
timezone String?
distance Float // meters
movingTime Int // seconds
elapsedTime Int // seconds
// Speed
averageSpeed Float? // m/s
maxSpeed Float? // m/s
gradeAdjustedSpeed Float? // m/s (GAP)
// Heart Rate
averageHr Float?
maxHr Int?
averageCadence Float? // spm
hasHeartrate Boolean @default(false)
// Elevation
totalElevation Float? // meters gained
elevHigh Float?
elevLow Float?
calories Float?
// Calculated Metrics
trimp Float? // HR-based training impulse
runningTss Float? // Running-specific stress
estimatedVdot Float? // VDOT from this activity
// HR Zone Breakdown (seconds)
hrZone1Time Int?
hrZone2Time Int?
hrZone3Time Int?
hrZone4Time Int?
hrZone5Time Int?
hrZone6Time Int?
hrZone7Time Int?
// Raw Data
rawJson Json?
streams Json? // time, hr, distance, altitude, velocity
// Classification
trainingType WorkoutType? // Auto-tagged or manual
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Cached daily fitness metrics for performance:
model DailyFitness {
id String @id @default(cuid())
userId String
date DateTime
// Metrics (pre-calculated)
ctl Float @default(0) // Chronic Training Load
atl Float @default(0) // Acute Training Load
tsb Float @default(0) // Training Stress Balance
ctlRunning Float @default(0) // Running-only CTL
// Daily Load (source data)
trimp Float @default(0)
runningTss Float @default(0)
@@unique([userId, date])
}Training plan structure:
model Goal {
id String @id @default(cuid())
userId String
// Race Details
name String
raceType RaceType // FIVE_K, TEN_K, HALF_MARATHON, MARATHON
raceDate DateTime
targetTime Int? // seconds
// Predictions
currentVdot Float?
predictedTime Int? // seconds
marathonShapeFactor Float @default(1.0)
// Plan Settings
weeklyMileageGoal Float? // km/week at peak
planWeeks Int @default(12)
runsPerWeek Int @default(4)
ridesPerWeek Int @default(0)
strengthPerWeek Int @default(0)
swimsPerWeek Int @default(0)
// Phase Customization
taperWeeks Int @default(2)
peakWeeks Int @default(4)
buildWeeks Int @default(4)
// Preferred Days
longRunDay Int @default(0) // Sunday
workoutDay Int @default(3) // Wednesday
restDays Json @default("[1, 5]")
// Status
isActive Boolean @default(true)
completedAt DateTime?
workouts Workout[]
}
model Workout {
id String @id @default(cuid())
goalId String
scheduledDate DateTime
workoutType WorkoutType // EASY, LONG_RUN, TEMPO, INTERVALS, etc.
description String
targetDistance Float? // meters
targetDuration Int? // seconds
targetPace Float? // sec/km
targetHrZone Int? // 1-5
isCompleted Boolean @default(false)
completedAt DateTime?
linkedActivityId String? // Links to actual Activity
goal Goal @relation(fields: [goalId], ...)
}enum Sex {
MALE
FEMALE
}
enum ActivityType {
RUN
VIRTUAL_RIDE
RIDE
WALK
HIKE
SWIM
WORKOUT
OTHER
}
enum RaceType {
FIVE_K
TEN_K
HALF_MARATHON
MARATHON
}
enum WorkoutType {
EASY // Easy run
LONG_RUN // Long run
TEMPO // Tempo run
INTERVALS // Interval training
REPETITIONS // Repetition training
RECOVERY // Recovery run
RACE // Race
REST // Rest day
CROSS_TRAIN // Cross-training
RIDE // Cycling
SWIM // Swimming
STRENGTH // Strength training
OTHER // Other
}Source: src/lib/metrics/vdot.ts
Based on Jack Daniels' Running Formula (Daniels-Gilbert formula).
// Velocity in meters per minute
const velocity = distanceMeters / timeMinutes;
// Oxygen cost (ml/kg/min)
const vo2 = -4.60 + 0.182258 * velocity + 0.000104 * velocity²;
// %VO2max sustained for this duration
const percentVO2max = 0.8 +
0.1894393 * e^(-0.012778 * timeMinutes) +
0.2989558 * e^(-0.1932605 * timeMinutes);
// VDOT = VO2 / %VO2max
const vdot = vo2 / percentVO2max;| Pace Type | %VO2max | Description |
|---|---|---|
| E (Easy) | 65-79% | Easy/recovery running |
| M (Marathon) | ~78% | Marathon pace |
| T (Threshold) | 88% | Lactate threshold |
| I (Interval) | 100% | VO2max intervals |
| R (Repetition) | 105% | Speed/repetitions |
import { calculateVdot, analyzeRace, formatPace } from '@/lib/metrics/vdot';
// Calculate VDOT from a race result
const vdot = calculateVdot({
distance: '10K',
timeSeconds: 2700 // 45:00
});
// => vdot ≈ 45.0
// Get full analysis
const analysis = analyzeRace({ distance: '5K', timeSeconds: 1500 });
// => {
// vdot: 42.5,
// predictions: { '5K': 1500, '10K': 3150, HALF: 6990, MARATHON: 14700 },
// trainingPaces: { easy: {...}, marathon: 330, threshold: 280, ... }
// }
// Format pace
formatPace(330); // "5:30/km"Source: src/lib/metrics/fitness.ts
Based on Banister's Impulse-Response Model.
| Metric | Formula | Description |
|---|---|---|
| CTL (Chronic Training Load) | 42-day EWMA of TRIMP | Represents fitness |
| ATL (Acute Training Load) | 7-day EWMA of TRIMP | Represents fatigue |
| TSB (Training Stress Balance) | CTL - ATL | Represents form/freshness |
// Decay factors
const CTL_DECAY = exp(-1/42); // ~0.9765
const ATL_DECAY = exp(-1/7); // ~0.8669
// Daily update
ctl = ctl * CTL_DECAY + trimp * (1 - CTL_DECAY);
atl = atl * ATL_DECAY + trimp * (1 - ATL_DECAY);
tsb = ctl - atl;| TSB Range | Status | Description |
|---|---|---|
| >= +25 | Peaked | Ready to race! |
| +5 to +24 | Fresh | Good form |
| -10 to +4 | Neutral | Optimal training zone |
| -30 to -11 | Fatigued | Monitor recovery |
| < -30 | Very Fatigued | Risk of overtraining |
Source: src/lib/metrics/trimp.ts
TRIMP measures training load based on heart rate duration.
// For activities with HR data
const avgHrRatio = (avgHr - hrRest) / (hrMax - hrRest);
const durationMinutes = movingTime / 60;
const trimp = durationMinutes * avgHrRatio * 0.64 * exp(avgHrRatio * 1.92);// Zone time in seconds
const trimp = (zone1Time * 0.2) + (zone2Time * 0.4) +
(zone3Time * 0.6) + (zone4Time * 0.8) + (zone5Time * 1.0);Source: src/lib/metrics/fitness.ts
Running-specific Training Stress Score.
// Intensity Factor = threshold_pace / actual_pace
const intensityFactor = thresholdPaceSecPerKm / actualPace;
// rTSS = duration_hours × IF² × 100
const tss = durationHours * intensityFactor² * 100;Different activities contribute differently to training load:
| Activity Type | CTL | Running TSS | Category |
|---|---|---|---|
| Run, VirtualRun, TrailRun | Yes | Yes | Running |
| Ride, VirtualRide | Yes | No | Cycling |
| Swim, Rowing, Elliptical | Yes | No | Cross-training |
| Walk, Hike | Yes | No | Light activity |
All API routes use NextAuth.js session authentication. Supports:
- Strava OAuth: For main activity syncing.
- Email/Password: Traditional login.
- Magic Link: Passwordless login via email.
GET /api/auth/signin - Sign in page
GET /api/auth/signout - Sign out
GET /api/auth/session - Get current session
POST /api/auth/callback/credentials - Email login
Returns unified dashboard data.
Response:
{
stats: {
currentWeekMileage: number; // km
lastSyncAt: string | null; // ISO date
syncInProgress: boolean;
totalActivities: number;
};
recentActivities: ActivityListItem[];
activeGoals: Goal[];
todayWorkout: WorkoutWithLinkedActivity | null;
}Triggers Strava activity synchronization.
Rate Limiting: 100 requests per 15 minutes
Response:
{
success: boolean;
synced: number; // Activities synced
total: number; // Total activities
message: string;
}Returns current sync status.
Response:
{
syncInProgress: boolean;
lastSyncAt: string | null;
totalActivities: number;
}Handles Strava webhook events.
Events:
create- New activity createdupdate- Activity updateddelete- Activity deleted
Validation: Uses STRAVA_VERIFY_TOKEN for signature verification.
Returns detailed analytics statistics.
Response:
{
currentWeekMileage: number;
effectiveVO2max: number;
rawVO2max: number;
vdotCorrectionFactor: number;
marathonShape: {
shape: number; // 0-100 score
mileageScore: number;
longRunScore: number;
crossTrainingScore: number;
details: { ... };
};
currentVdot: number | null;
ctl: number;
atl: number;
tsb: number;
workloadRatio: number; // ATL/CTL
easyTrimp: number; // Z1+Z2 TRIMP
hrMax?: number;
}Returns fitness history for charting.
Response:
{
history: Array<{
date: string;
ctl: number;
atl: number;
tsb: number;
}>;
}Returns paginated activity list.
Query Parameters:
limit(default: 20) - Items per pageoffset(default: 0) - Pagination offsettype(optional) - Filter by activity type
Response:
{
activities: ActivityListItem[];
total: number;
limit: number;
offset: number;
}Create a manual activity (not from Strava).
Request:
{
name: string;
type: ActivityType;
distance: number; // meters
movingTime: number; // seconds
averageHr?: number;
startDate: string; // ISO date
}Update an activity (mainly for training type overrides).
Request:
{
trainingType: WorkoutType | null;
}Delete an activity.
Returns all user goals.
Response:
{
goals: Goal[];
}Create a new goal/training plan.
Request:
{
name: string;
raceType: RaceType;
raceDate: string; // ISO date
targetTime?: number; // seconds
weeklyMileageGoal?: number; // km
planWeeks?: number;
runsPerWeek?: number;
ridesPerWeek?: number;
strengthPerWeek?: number;
swimsPerWeek?: number;
longRunDay?: number; // 0=Sunday, ..., 6=Saturday
workoutDay?: number;
restDays?: number[];
}Update a goal.
Delete a goal and all associated workouts.
Get all workouts for a goal.
Response:
{
workouts: WorkoutWithLinkedActivity[];
}Update a workout.
Request:
{
workoutType?: WorkoutType;
description?: string;
targetDistance?: number;
targetDuration?: number;
targetPace?: number;
targetHrZone?: number;
isCompleted?: boolean;
linkedActivityId?: string | null;
}Get user profile and settings.
Response:
{
user: {
id: string;
name: string | null;
email: string | null;
image: string | null;
hrMax: number | null;
hrRest: number | null;
weight: number | null;
height: number | null;
hrZone1Max: number;
hrZone2Max: number;
hrZone3Max: number;
hrZone4Max: number;
vdotCorrectionFactor: number;
};
}Update user profile.
Request:
{
hrMax?: number;
hrRest?: number;
weight?: number;
height?: number;
hrZone1Max?: number;
hrZone2Max?: number;
hrZone3Max?: number;
hrZone4Max?: number;
vdotCorrectionFactor?: number;
}App
├── Providers
│ ├── ThemeProvider
│ ├── UserMetricsProvider
│ └── NotificationProvider
├── Dashboard
│ ├── RaceCountdown
│ ├── TodayWorkout
│ ├── TrainingStatusCard (CTL/ATL/TSB)
│ ├── WorkoutScheduleCard
│ └── ActivityList
├── Analytics
│ ├── AnalyticsDashboard
│ ├── CombinedAnalyticsChart
│ ├── FitnessChart (CTL/ATL/TSB over time)
│ └── RacePredictionChart
└── Modals
├── ProfileModal
├── SettingsModal
├── EditWorkoutModal
├── ActivityDetailsModal
└── ShapeCalibrationModal
Main analytics view with all metrics and charts.
Features:
- VDOT display with calibration
- Marathon Shape score
- CTL/ATL/TSB cards with color-coded status
- Fitness trend chart
- Race prediction chart
- Weekly mileage chart
Uses Recharts to display CTL/ATL/TSB over time.
Props:
interface FitnessChartProps {
data: Array<{
date: string;
ctl: number;
atl: number;
tsb: number;
}>;
height?: number;
}Displays weekly workout schedule with edit capabilities.
Features:
- Drag-and-drop reordering (via @dnd-kit)
- Inline editing of workouts
- Linking to actual activities
- Completion status
Shows current fitness metrics with color-coded status.
Status Colors:
- Green (Peaked): TSB >= +25
- Lime (Fresh): TSB >= +5
- Yellow (Neutral): TSB >= -10
- Orange (Fatigued): TSB >= -30
- Red (Very Fatigued): TSB < -30
Detailed view of a single activity.
Features:
- Basic metrics (distance, time, pace)
- Heart rate zones
- Elevation chart
- Interactive streams chart (if data available)
- Training type classification
Provides user metrics (HR zones, VDOT correction) to all child components.
const UserMetricsContext = {
hrMax: number | null;
hrRest: number | null;
vdotCorrectionFactor: number;
updateUserMetrics: (data: Partial<UserMetrics>) => Promise<void>;
};Manages dark/light mode using next-themes.
1. User clicks "Connect with Strava"
2. Redirect to Strava authorization page
3. User approves access
4. Strava redirects to callback with code
5. NextAuth exchanges code for tokens
6. Tokens stored in User model
read:activity- Read activitiesactivity:read_all- Access all activities (not just recent)profile:read_all- Read athlete profile
Strava does not provide a UI for webhook subscription. Use curl:
curl -X POST https://www.strava.com/api/v3/push_subscriptions \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d callback_url=https://your-domain.com/api/webhooks/strava \
-d verify_token=YOUR_VERIFY_TOKENVerify Webhook Subscription:
curl -X GET https://www.strava.com/api/v3/push_subscriptions \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRETStrava API limits:
- Standard: 100 requests per 15 minutes
- Premium: 1000 requests per 15 minutes
RunFlow implements:
- In-memory rate limiting
- Request queuing for large syncs
- Exponential backoff on errors
Strava access tokens expire after 6 hours. RunFlow automatically:
- Detects expired tokens via API errors
- Uses refresh token to get new access token
- Updates User model with new tokens
- Retries the failed request
RunFlow training plans follow a periodized approach:
┌─────────────────────────────────────────────────────┐
│ TRAINING PLAN │
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ BASE │ │ BUILD │ │ PEAK │ │ TAPER │ │
│ │ Phase │ │ Phase │ │ Phase │ │ Phase │ │
│ ├─────────┤ ├─────────┤ ├─────────┤ ├─────────┤ │
│ │ ~40% │ │ ~35% │ │ ~15% │ │ ~10% │ │
│ │ Easy │ │ Tempo/ │ │ Inter- │ │ Easy │ │
│ │ + │ │ Intervals│ vals │ │ sharpen-│ │
│ │ Long │ │ │ │ │ │ ing │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────┘
Workouts are generated based on:
- User's VDOT - Determines paces for all workouts
- Race Type - Dictates the mix of workout types
- Plan Duration - Affects phase lengths
- Weekly Mileage Goal - Scales workout distances
- Preferred Days - User's available training days
| Type | Purpose | Typical Content |
|---|---|---|
| EASY | Build aerobic base | 4-8 km @ easy pace |
| LONG_RUN | Endurance | 12-32 km @ easy/marathon pace |
| TEMPO | Lactate threshold | 4-10 km @ threshold pace |
| INTERVALS | VO2max | 5x1km @ interval pace |
| REPETITIONS | Speed/economy | 10x200m @ repetition pace |
| RECOVERY | Active recovery | 3-5 km very easy |
| RACE | Taper/race | Race pace efforts |
Cross-training activities are scheduled based on user preferences:
- RIDE - Cycling (indoor/outdoor)
- SWIM - Swimming
- STRENGTH - Strength training
- CROSS_TRAIN - Other activities
These contribute to CTL but not to running-specific TSS.
- Node.js 20+
- Docker & Docker Compose
- Git
- Strava API credentials
# Clone repository
git clone https://github.com/thies2005/RunFlow.git
cd RunFlow
# Install dependencies
npm install
# Copy environment file
cp .env.example .env
nano .env
# Set up database
docker compose up -d db
npm run db:push
# Run development server
npm run devDevelopment server runs at http://localhost:3000
# Run all tests
npm test
# Run with coverage
npm test -- --coverage
# Run specific test file
npm test -- vdot.test.ts# Push schema changes (dev)
npm run db:push
# Generate Prisma client
npm run db:generate
# Open Prisma Studio
npm run db:studioRunFlow uses:
- TypeScript strict mode
- ESLint with Next.js config
- Prettier for formatting (recommended)
# Lint code
npm run lint
# Fix linting issues
npm run lint -- --fixTo add a new calculated metric:
- Create calculation function in
src/lib/metrics/ - Add TypeScript type in
src/lib/types.ts - Update analytics service to calculate the metric
- Add database field if caching is needed (Prisma schema)
- Create UI component to display the metric
Example structure:
// src/lib/metrics/newMetric.ts
export function calculateNewMetric(input: MetricInput): number {
// Calculation logic
return result;
}
// src/lib/services/analytics.ts
export async function getAnalytics(userId: string) {
// ...
const newMetric = calculateNewMetric(data);
return { ..., newMetric };
}# Build and start all services
docker compose up -d --buildThis builds:
app- Next.js applicationdb- PostgreSQL databasemigrator- Database schema migrationbackup- Automated backupstunnel- Cloudflare Tunnel (optional)
| Variable | Required | Description |
|---|---|---|
NEXTAUTH_URL |
Yes | Your application URL |
NEXTAUTH_SECRET |
Yes | Generate with openssl rand -base64 32 |
AUTH_URL |
Optional | Alias supported by Auth.js v5 |
AUTH_SECRET |
Optional | Alias supported by Auth.js v5 |
AUTH_TRUST_HOST |
Optional | Set to true behind reverse proxies |
STRAVA_CLIENT_ID |
Yes | From Strava API settings |
STRAVA_CLIENT_SECRET |
Yes | From Strava API settings |
STRAVA_VERIFY_TOKEN |
Yes | Custom string for webhook validation |
DATABASE_URL |
Optional | Default: postgresql://runflow:runflow@db:5432/runflow |
ENCRYPTION_KEY |
Required (Prod) | 32-byte base64 key for encrypting Strava tokens at rest |
TUNNEL_TOKEN |
Optional | Cloudflare Tunnel token |
POSTGRES_USER |
Optional | Default: runflow |
POSTGRES_PASSWORD |
Optional | Default: runflow |
POSTGRES_DB |
Optional | Default: runflow |
Automated backups run every 6 hours and retain:
- Daily backups: 7 days
- Weekly backups: 4 weeks
- Monthly backups: 6 months
Backups are stored in ./backups directory.
Restore from backup:
./scripts/restore.sh "backup_file_name.sql.gz"For secure access without port forwarding:
- Create a Cloudflare Tunnel
- Get the tunnel token
- Add
TUNNEL_TOKENto.env - The tunnel service will start automatically
# Pull latest changes
git pull origin master
# Rebuild and restart
docker compose up -d --buildRunFlow builds natively for:
linux/amd64- Standard servers/VPSlinux/arm64- ARM servers (e.g., Raspberry Pi 4, Oracle Cloud ARM)
Buildx automatically creates multi-architecture images.
- Production image uses Next.js standalone output (
output: "standalone"). - Build stage uses BuildKit cache mounts for
.next/cacheand Prisma generated client output. SENTRY_DISABLE_SOURCEMAP_UPLOAD=1is set during image build to avoid optional sourcemap upload overhead.- If deployment fails after successful compile during static page generation, review Coolify build memory headroom and timeout settings.
Problem: Activities not syncing from Strava
Solutions:
- Check Strava token is valid: Look at
stravaTokenExpiryin database - Manually trigger sync: Use sync button in dashboard
- Verify webhook is subscribed: Use curl to check subscription
- Check rate limiting: Wait 15 minutes if limit exceeded
Problem: VDOT seems incorrect
Solutions:
- Ensure heart rate data is available for activities
- Verify HR zones are configured correctly in settings
- Check if VDOT correction factor is applied
- Calibrate using a known race result
Problem: Database connection errors
Solutions:
- Verify database is healthy:
docker compose ps - Check database logs:
docker compose logs db - Restart database:
docker compose restart db - Verify DATABASE_URL is correct
Problem: Docker build fails
Solutions:
- Clear Docker cache:
docker builder prune -a - Check for sufficient disk space
- Verify network connectivity for npm downloads
- Try building without cache:
docker compose build --no-cache
Problem: Strava webhook not validating
Solutions:
- Verify STRAVA_VERIFY_TOKEN matches in both
.envand Strava subscription - Ensure webhook URL is publicly accessible (not localhost)
- Check Strava API application settings for correct callback domain
- Test webhook manually using Strava's "Send test event" button
VO2 = -4.60 + 0.182258v + 0.000104v²
%VO2max = 0.8 + 0.1894393e^(-0.012778t) + 0.2989558e^(-0.1932605t)
VDOT = VO2 / %VO2max
where v = velocity (m/min), t = time (min)
TRIMP = t × HR_ratio × 0.64e^(1.92 × HR_ratio)
HR_ratio = (HR_avg - HR_rest) / (HR_max - HR_rest)
TSS = t(h) × IF² × 100
IF = pace_threshold / pace_actual
CTL_today = CTL_yesterday × e^(-1/42) + TRIMP × (1 - e^(-1/42))
ATL_today = ATL_yesterday × e^(-1/7) + TRIMP × (1 - e^(-1/7))
TSB = CTL - ATL
- Jack Daniels' Running Formula: Book by Jack Daniels
- Banister Impulse-Response Model: Original research on training load
- Strava API Documentation: https://developers.strava.com/
- Next.js Documentation: https://nextjs.org/docs
- Prisma Documentation: https://www.prisma.io/docs
RunFlow uses Capacitor to wrap the Next.js web application into a native Android app. This allows for a unified codebase while leveraging native device features.
The mobile app integrates with Google Health Connect to sync workouts from other apps (like Garmin, Peloton, etc.) directly into RunFlow.
- Plugin:
@capacitor-community/health-connect - Data Flow:
Health Connect -> Android App -> RunFlow Web API-> Database - Sync Logic: Checks for new activities on app launch or manual sync trigger.
- Heart Rate Zones: The app calculates time-in-zone locally using the raw heart rate samples from Health Connect before sending the summary to the server.
To build the Android app:
# 1. Build the Next.js web app (static export)
npm run build
# 2. Sync with Capacitor
npx cap sync
# 3. Open Android Studio to build APK/Bundle
npx cap open androidRunFlow v1.2.0 | https://github.com/thies2005/RunFlow