Google Sheets sync
scripts/fetch_google_sheet.py reads the configured sheet range, validates expected columns, parses each week, and writes data/training-plan.json.
Google Sheets is the editable training plan, supplement tracker, and nutrition log; Strava is the running log; Cloudflare Worker handles live Strava webhooks; and GitHub Pages plus Cloudflare Pages serve the static dashboard during the transition.
data/strava-activities.json.Google Sheets stores the planned marathon build, Strava actual writeback fields, supplement checkbox history, and nutrition history. You can adjust daily estimated mileage, session notes, weekly summaries, phase details, supplement checkboxes, and meal-level nutrition rows there.
Strava API provides actual runs: distance, time, pace, elevation, heart rate and cadence when Strava includes them, activity names, dates, and Strava links.
Cloudflare Worker is the live Strava sync worker. It receives Strava webhook events, refreshes Strava tokens privately, fetches changed activities, and stores sanitized actual-run JSON in Workers KV.
GitHub Actions is the private Sheet and deployment worker. It reads the Google service account secret, optionally calls OpenAI for nutrition estimates, fetches the Sheet, and prepares the static deployment output.
OpenAI API is used only by the private workflow script when an OPENAI_API_KEY secret is configured. The public site never calls OpenAI directly.
GitHub Pages and Cloudflare Pages are public static hosts. They have no API secrets in frontend JavaScript.
sckl-marathon-dashboard/
|-- index.html # Main static dashboard
|-- nutrition.html # Standalone nutrition tracker
|-- backend.html # Technical architecture page
|-- race.html # Race execution page
|-- styles.css # Shared visual system
|-- app.js # Browser rendering, charts, status, and comparisons
|-- nutrition.js # Nutrition page rendering and empty states
|-- race.js # Pace calculator and race page interactions
|-- data/
| |-- training-plan.json # Generated Google Sheets plan
| |-- strava-activities.json # Generated Strava run data
| |-- supplements.json # Generated Google Sheets supplement history
| |-- nutrition.json # Generated Google Sheets nutrition history
| |-- mock-training-plan.json # Local fallback plan
| |-- mock-strava-activities.json
| |-- mock-supplements.json
| `-- mock-nutrition.json
|-- scripts/
| |-- fetch_google_sheet.py # Reads and normalises Google Sheet rows
| |-- process_nutrition_ai.py # Private raw food log -> nutrition estimate writer
| |-- fetch_strava.py # Refreshes Strava token and fetches runs
| |-- sync_strava_actuals_to_sheet.py # Writes run actuals back to the plan
| |-- sync_training_calendar.py # Syncs planned runs into Google Calendar
| `-- exchange_strava_code.py # One-time OAuth helper
|-- .github/workflows/
| |-- deploy-pages.yml # Scheduled/manual sync and Pages deploy
| `-- sync-calendar.yml # Manual training calendar sync
|-- requirements.txt
|-- .env.example
`-- README.md
scripts/fetch_google_sheet.py reads the configured sheet range, validates expected columns, parses each week, and writes data/training-plan.json.
scripts/fetch_strava.py refreshes the access token with your refresh token, fetches recent activities, keeps running activities, and writes public training metrics.
scripts/sync_strava_actuals_to_sheet.py reads the generated Strava JSON and writes only the daily Actual and Actual Distance Ran fields in the Training Plan tab.
deploy-pages.yml copies HTML, CSS, JavaScript, and generated JSON into one output folder, then deploys that same folder to Cloudflare Pages and GitHub Pages.
The Sync data button opens the GitHub Actions workflow page. From there, run the workflow manually to fetch the latest sheet and Strava data.
scripts/fetch_google_sheet.py reads the Supplements tab into data/supplements.json. The nutrition page displays that checkbox history without editing it from Cloudflare Pages.
scripts/fetch_google_sheet.py also reads the Nutrition tab into data/nutrition.json. The standalone nutrition page displays that history without editing it from Cloudflare Pages.
scripts/process_nutrition_ai.py reads raw food logs from private helper columns, calls OpenAI from GitHub Actions, and writes estimates back into the Sheet before the JSON sync.
scripts/sync_training_calendar.py reads the Training Plan, skips Wednesday runs by default, applies the preferred AM/PM timing rules, and tags Google Calendar events with hidden plan IDs so later Sheet edits update existing events.
The pace table is a practical coaching estimate, not a lab result. It starts from recent performance anchors, converts those into equivalent training intensities, then widens the final ranges for Singapore heat, humidity, hills, and day-to-day fatigue.
The baseline estimate uses a 37:00 tropical 10K, cross-checked against a 1:19 half marathon and historical 2:45 cool-weather marathon fitness. This points roughly to a VDOT-style fitness level around 57.
A 2:50 marathon requires about 4:02/km in neutral conditions. For SCKL conditions, the working marathon-effort range is softened to roughly 4:10-4:20/km so effort stays sustainable in heat.
Threshold, tempo, interval, and repetition ranges are built around common race-equivalent training intensities, then expressed as ranges rather than exact single paces.
If sleep, shin soreness, humidity, or hills raise the effort, the intended stimulus wins over the number on the watch. Easy days should stay easy enough to support the next key session.
| Zone | Derivation |
|---|---|
| Recovery | Easy aerobic pace plus extra buffer for tired legs, injury caution, and post-workout recovery. |
| Easy aerobic | Set around current comfortable easy running, roughly 5:15-5:30/km, with room either side for fatigue and weather. |
| Long run easy | Sits slightly steadier than recovery but below steady aerobic, so long runs can absorb hills and later workout sections. |
| Marathon effort | Starts from 2:50 goal pace, then adjusts slower for tropical race conditions and rolling terrain. |
| Tempo / threshold | Estimated from 10K and half-marathon equivalents, then separated into longer tempo work and shorter threshold intervals. |
| 10K / VO2 / speed | Derived from race-equivalent faster-than-threshold work and converted into track-friendly repetition ranges. |
GOOGLE_SERVICE_ACCOUNT_JSON, STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, and STRAVA_REFRESH_TOKEN. Add OPENAI_API_KEY to enable AI nutrition processing. These stay private in GitHub Actions.
GOOGLE_SHEET_ID points to your training sheet. GOOGLE_SHEET_RANGE is currently A:AQ so the daily actual columns and weekly summary column are included. GOOGLE_CALENDAR_ID, TRAINING_CALENDAR_TIMEZONE, and TRAINING_CALENDAR_COLOR_ID configure the optional calendar sync.
The frontend only knows public file paths such as data/training-plan.json. It never receives Google or Strava secrets.
.env can hold local copies of the same values for testing scripts, while .env.example documents the required names without real credentials.
data/training-plan.json contains the plan from Google Sheets: week number, week start date, phase, weekly mileage, daily sessions, daily actual writeback fields, long run target, weekly summary, and notes.
data/strava-activities.json contains the generated actual training data used by the dashboard: athlete profile, sync timestamp, run distance, moving time, elapsed time, elevation, heart rate, cadence, activity names, dates, and Strava links.
data/supplements.json contains read-only supplement checkbox history from the Supplements tab: date, week, training phase, supplement names, and whether each supplement was taken.
data/nutrition.json contains read-only meal-level nutrition rows from the Nutrition tab, plus daily totals and rolling seven-day calorie and protein averages. The sync reads columns A:T by header name, but only public nutrition fields are written into the generated JSON; raw food logs, guidelines, AI status, timestamps, and errors remain Sheet workflow columns.
{
"metadata": { "generated_at": "2026-05-11T12:00:00+08:00" },
"weeks": [
{
"week_number": 1,
"week_start_date": "2026-05-11",
"phase": "Base",
"target_weekly_mileage_km": 61,
"week_summary": "Base week with 61 km across 5 planned runs..."
}
]
}
The website is public, so anything written into generated JSON can be viewed by anyone with the site link. API secrets are private, but published activity metrics are public.
Nutrition rows are public once synced into data/nutrition.json, so only track food items, assumptions, and notes you are comfortable showing on the public dashboard.
The frontend does not call OpenAI or any other estimation API directly. AI-assisted nutrition estimates are generated in GitHub Actions or locally, written into Google Sheets, then synced as static JSON.
If a Strava token or Google service account key is ever shared outside the secret stores, rotate it and update GitHub Actions secrets.
The dashboard is a training visibility tool, not a coaching or medical system. Risk flags and progress views should guide decisions, not replace judgement around injury, sleep, heat, or recovery.
Source code and setup notes live in the GitHub repository.
The manual sync workflow is available at deploy-pages.yml.
The manual calendar sync workflow is available at sync-calendar.yml.