Technical Documentation

How DataFlows

A plain-English explanation of how ride data gets from Strava to your screen — the complete pipeline from raw GPS data to leaderboards, charts, and coaching tips.

3
Pipeline Stages
1hr
Cache Refresh
<50ms
Cached Response
0
Secrets on Device
01 — The Big Picture
Three Simple Steps
Every piece of data in the app follows this path. No exceptions.
🚴

Step 1 — Riders Log Rides on Strava

Club members ride their bikes with Strava running (phone or Garmin). When they finish, Strava saves the ride: distance, time, elevation, speed, route. This is completely normal Strava usage — nothing changes for riders.

Because all riders are members of the Desi Cycling Club on Strava (Club ID: 212760), their rides appear in the club's activity feed automatically.

Step 2 — Cloudflare Worker Fetches & Crunches

A serverless JavaScript function running on Cloudflare's edge network calls the Strava API every hour. It fetches all club rides for the current week, groups them by rider, calculates totals and averages, and stores the result in a KV cache.

This is the brain of the system. The Worker handles authentication, pagination, aggregation, speed calculation, and caching — so the app never touches Strava directly.

📱

Step 3 — App Displays the Dashboard

The iOS app (or Android PWA) calls GET /club-data on the Worker. It receives a clean JSON response with per-member stats and individual activities. The app renders 5 tabs: leaderboard, charts, insights, coaching tips, and weekly comparison.

If the KV cache has data (it almost always does), the response takes <50 milliseconds. No waiting.

02 — Inside the Cloudflare Worker
The Engine Room
What happens when the Worker runs. Every step, every transformation.
Worker URL
https://dcc-strava.amit-r-kamat.workers.dev
Endpoints
MethodEndpointPurpose
GET/club-dataCurrent week stats (default) or historical with ?weekOffset=-1
POST/feature-requestSubmit feature request → creates Jira ticket in SCRUM project
GET/featuresLive interactive feature dashboard (HTML) — pulls from Jira
GET/features-apiSame data as /features but raw JSON
Step 2a — Token Management

Getting Permission from Strava

Strava requires an OAuth access token for every API call. Access tokens expire after 6 hours. The Worker manages this automatically:

// 1. Check if cached token is still valid (>5 min remaining)
const expires = await env.STRAVA_KV.get("strava_access_token_expires");
if (expires - now > 300) return cachedToken; // still good

// 2. If expired, refresh using the stored refresh token
const refreshToken = await env.STRAVA_KV.get("strava_refresh_token");
// POST to Strava with client_id + client_secret + refresh_token
// Strava returns: new access_token, new refresh_token, expires_at

// 3. Store ALL new tokens back to KV (Strava rotates refresh tokens)
await env.STRAVA_KV.put("strava_access_token", newToken);
await env.STRAVA_KV.put("strava_refresh_token", newRefresh);
await env.STRAVA_KV.put("strava_access_token_expires", expiresAt);
🔐
Security: Token Proxy Pattern
The Strava client_secret and refresh_token are stored as Cloudflare environment secrets. They never touch the iOS binary or the Android PWA. The app only calls /club-data — no auth headers needed.
Step 2b — Fetching Club Activities

Pulling Rides from Strava

The Worker calls the Strava Club Activities API and pages through all results:

// Strava endpoint
GET /api/v3/clubs/212760/activities?per_page=200&page=1

// Returns (newest first):
{ athlete: { firstname, lastname }, distance: 38200, // meters
  moving_time: 5400, // seconds
  total_elevation_gain: 150, // meters
  type: "Ride", sport_type: "Ride",
  start_date: "2026-03-18T07:30:00Z" }

// Pagination: keep fetching pages until oldest activity is before weekStart
// or no more results. Stops early — doesn't fetch the entire club history.
⚠️
Important: What Strava Does NOT Return
The club activities endpoint does not include average_speed. It only returns distance, moving_time, elevation, type, and date. Speed must be calculated manually from distance ÷ time.
Step 2c — Aggregation (The Number Crunching)

From Raw Rides to Per-Member Stats

1. Filter to Week
Only activities between Monday 00:00 UTC and Sunday 23:59 UTC of the target week are included.
2. Group by Rider
Activities grouped by "Firstname L." format. Each member gets a running total of distance, elevation, time, and ride count.
3. Calculate Averages
Average speed is weighted by distance — a 100km ride counts more than a 5km ride. This prevents short rides from skewing the average.
Speed Calculation (The Fix)
// Per-activity speed: calculated from raw distance and time
const movingSec = act.moving_time ?? 0;
const speedKmh = movingSec > 0
  ? (act.distance / movingSec) * 3.6   // meters/sec → km/h
  : 0;

// Per-member weighted average speed:
// Sum of (speed × distance) for all rides, divided by total distance
// Example: 2 rides at 25km/h (40km) and 15km/h (10km)
// = (25×40 + 15×10) / (40+10) = 1150/50 = 23.0 km/h
// (not simple average of 20.0 — longer rides weigh more)
Step 2d — KV Caching

Why the App Feels Instant

Cache Key
club_data_week_2026-03-23 — Monday's date as the key. Each week gets its own cache entry.
Cache TTL
1 hour. A cron trigger runs every hour to pre-warm the cache. First request after expiry: ~2s. All subsequent: <50ms from nearest edge.
// Cache hit path (fast — <50ms)
const cached = await env.STRAVA_KV.get(cacheKey);
if (cached) return new Response(cached, { headers: { "X-Cache": "HIT" } });

// Cache miss path (slow — ~2s, calls Strava)
const payload = await fetchAndCacheWeek(env, weekOffset);
await env.STRAVA_KV.put(cacheKey, JSON.stringify(payload), { expirationTtl: 3600 });
03 — Week-on-Week Comparison
How Trend Arrows Work
The table shows whether each rider improved or declined compared to last week. Here's the logic.
The Two-Week Data Strategy

The Worker fetches one week at a time. The iOS app uses a local disk cache (WeeklyCache.swift) to remember previous weeks:

📡
This Week
Live from Worker
💾
Last Week
Disk cache
🔄
Compare
Calculate trend
📊
Display
Arrow + colour
Trend Direction Rules
ArrowMeaningRule
ImprovementthisWeekKM > lastWeekKM × 1.10 (>10% increase)
DeclinethisWeekKM < lastWeekKM × 0.90 (>10% decrease)
SteadyWithin ±10% of last week
New RiderNo previous week data available
💡
Cache Lifecycle
When the app fetches this week's data, it saves a MemberSnapshot to disk. Next Monday, that snapshot becomes the "previous week" baseline. Snapshots expire after 14 days. This means trend arrows are always accurate — even if the user was offline last week.
04 — Speed Calculation Fix (March 2026)
The 0.0 km/h Bug
A critical bug where all speed values showed 0.0 km/h — found and fixed on 27 March 2026.
The Bug

Every rider's speed was 0.0 km/h

The Worker code assumed Strava's club activities endpoint returns an average_speed field. It does not. The Strava /clubs/{id}/activities endpoint returns a limited set of fields — distance, moving_time, elevation, type, and date. No speed.

// BROKEN — average_speed is always undefined
const speedKmh = (act.average_speed ?? 0) * 3.6;
// Result: (undefined ?? 0) * 3.6 = 0 — every single time
The Fix

Calculate speed from distance and time

Speed = distance ÷ time. Both fields are always available. The fix is simple and bulletproof:

// FIXED — calculate from fields that always exist
const movingSec = act.moving_time ?? 0;
const speedKmh = movingSec > 0
  ? (act.distance / movingSec) * 3.6
  : 0;

// Example: 38,200m in 5,400s
// = (38200 / 5400) * 3.6 = 7.07 * 3.6 = 25.5 km/h ✓
Deployed 27 Mar 2026 Worker v3e20e80d KV cache refreshes hourly
Before vs After
RiderBefore (Bug)After (Fix)Distance
Amit K.0.0 km/h21.8 km/h351.8 km
Harshal K.0.0 km/h26.4 km/h114.8 km
Avinash R.0.0 km/h24.4 km/h221.1 km
Sanjay L.0.0 km/h19.7 km/h254.0 km
Ramesh K.0.0 km/h16.9 km/h156.7 km
05 — Response Format
What the App Receives
The exact JSON structure returned by GET /club-data.
GET /club-data Response
{
  "lastFetchedAt": "2026-03-27T10:00:00.000Z",
  "weekLabel": "w/c 23 Mar",
  "weekStart": "2026-03-23",  // Monday
  "weekEnd": "2026-03-29",    // Sunday
  "memberCount": 10,
  "totalActivities": 49,
  "members": [{
    "name": "Amit K.",
    "totalDistance": 351.8,    // km
    "totalElevation": 2437,   // meters
    "totalMovingTime": 58020, // seconds
    "rideCount": 9,
    "avgSpeed": 21.8,       // km/h (weighted average)
    "movingTimeFormatted": "16h 7m",
    "activities": [{
      "name": "Morning Ride",
      "distance": 38.2,      // km
      "movingTime": 5400,    // seconds
      "elevationGain": 150,  // meters
      "averageSpeed": 25.5,  // km/h (calculated)
      "type": "Ride",
      "sportType": "Ride",   // Road, MountainBikeRide, GravelRide, EBikeRide, VirtualRide
      "startDate": "2026-03-25T07:30:00Z"
    }]
  }]
}
06 — Quick Links
Where to Go