Information Flow

1You edit the marathon plan in Google Sheets. The sheet stays the source of truth for planned weeks, dates, daily mileage, notes, and weekly summaries.
2You record runs normally through Strava. The dashboard only needs Strava API access; it does not need Strava data typed into the sheet.
3Strava sends activity create, update, and delete webhook events to a Cloudflare Worker. The Worker updates a KV cache so actual runs can appear without waiting for a site rebuild.
4GitHub Actions still runs the Sheet sync workflow on schedule, on code changes, or when you press the Sync data button and manually run the workflow.
5If raw nutrition rows are waiting, GitHub Actions calls OpenAI privately, writes macro estimates back into Google Sheets, then runs the normal Sheet sync.
6The workflow reads private credentials from GitHub secrets, fetches the Sheet, and prepares one static output folder with Google Sheets, Supplements, Nutrition, and fallback Strava JSON.
7GitHub Pages and Cloudflare Pages host the same static site during the migration. The dashboard loads live actuals from the Worker first, then falls back to data/strava-activities.json.

What Each Service Does

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.

Repository Map

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

Workflow Responsibilities

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.

Strava sync

scripts/fetch_strava.py refreshes the access token with your refresh token, fetches recent activities, keeps running activities, and writes public training metrics.

Strava actual writeback

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.

Pages deployment

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.

Manual refresh

The Sync data button opens the GitHub Actions workflow page. From there, run the workflow manually to fetch the latest sheet and Strava data.

Supplements

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.

Nutrition

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.

Nutrition AI

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.

Calendar 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.

Pace Range Derivation

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.

Performance anchor

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.

Marathon target

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.

Training zones

Threshold, tempo, interval, and repetition ranges are built around common race-equivalent training intensities, then expressed as ranges rather than exact single paces.

Adjustment rule

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
RecoveryEasy aerobic pace plus extra buffer for tired legs, injury caution, and post-workout recovery.
Easy aerobicSet around current comfortable easy running, roughly 5:15-5:30/km, with room either side for fatigue and weather.
Long run easySits slightly steadier than recovery but below steady aerobic, so long runs can absorb hills and later workout sections.
Marathon effortStarts from 2:50 goal pace, then adjusts slower for tropical race conditions and rolling terrain.
Tempo / thresholdEstimated from 10K and half-marathon equivalents, then separated into longer tempo work and shorter threshold intervals.
10K / VO2 / speedDerived from race-equivalent faster-than-threshold work and converted into track-friendly repetition ranges.

Secrets And Runtime Config

GitHub repository secrets

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.

GitHub repository variables

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.

Frontend config

The frontend only knows public file paths such as data/training-plan.json. It never receives Google or Strava secrets.

Local development

.env can hold local copies of the same values for testing scripts, while .env.example documents the required names without real credentials.

Data Contract

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..."
    }
  ]
}

Privacy And Limits

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.

Technical Reference

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.