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.
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
| Method | Endpoint | Purpose |
| GET | /club-data | Current week stats (default) or historical with ?weekOffset=-1 |
| POST | /feature-request | Submit feature request → creates Jira ticket in SCRUM project |
| GET | /features | Live interactive feature dashboard (HTML) — pulls from Jira |
| GET | /features-api | Same 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
🔄
Compare
Calculate trend
Trend Direction Rules
| Arrow | Meaning | Rule |
| ↑ | Improvement | thisWeekKM > lastWeekKM × 1.10 (>10% increase) |
| ↓ | Decline | thisWeekKM < lastWeekKM × 0.90 (>10% decrease) |
| → | Steady | Within ±10% of last week |
| ★ | New Rider | No 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
| Rider | Before (Bug) | After (Fix) | Distance |
| Amit K. | 0.0 km/h | 21.8 km/h | 351.8 km |
| Harshal K. | 0.0 km/h | 26.4 km/h | 114.8 km |
| Avinash R. | 0.0 km/h | 24.4 km/h | 221.1 km |
| Sanjay L. | 0.0 km/h | 19.7 km/h | 254.0 km |
| Ramesh K. | 0.0 km/h | 16.9 km/h | 156.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"
}]
}]
}