Strava Animated Activity Feed — Build Plan
0 minute read
Strava Animated Activity Feed — Build Plan
Overview
A GitHub Actions-powered static feed:
- Python pipeline (runs in CI daily) — authenticates with Strava, fetches current-year activities, renders an animated GIF card per activity, commits everything to the repo
- Jekyll page — reads the committed JSON and displays a responsive feed of animated cards
The existing animate_gpx.py comparison tool and this pipeline share a common rendering core (gpx_core.py) so logic isn’t duplicated.
Year-scoped design
The feed always shows the current calendar year only. At year rollover the GitHub Action automatically deletes the previous year’s GIFs and resets the cache, keeping the repo lean. Steady-state repo growth: ~150–300 MB/year, cleared at each new year.
Deployment model
| Concern | Decision |
|---|---|
| Where GIFs are rendered | GitHub Actions (headless, no basemap) |
| Where GIFs are stored | assets/activity-cards/{id}.gif — committed to the repo |
| Who triggers the pipeline | GitHub Actions cron (daily at 6 AM UTC) + manual dispatch |
| How the page is served | GitHub Pages (Jekyll), same as the rest of the site |
| Year rollover | Action detects new year, deletes old GIFs + resets cache, starts fresh |
| Basemap tiles | Off in CI — dark background only (looks great, faster, no network dep) |
site/ output folder | Eliminated — outputs go directly into the Jekyll asset tree |
build.py master script | Eliminated — GitHub Action orchestrates the steps directly |
Final File Structure
(repo root)
│
├── animate_gpx.py ← existing comparison tool (unchanged CLI)
├── gpx_core.py ← ✓ DONE: shared rendering core
├── strava_auth.py ← ✓ DONE: OAuth + token refresh
│
├── scripts/
│ ├── fetch_strava.py ← existing: fetches summary data for training dashboard
│ ├── fetch_activities.py ← NEW: fetches GPS streams for feed cards
│ └── render_cards.py ← NEW: renders one GIF per activity
│
├── assets/
│ ├── activity-cards/
│ │ ├── 12345678.gif ← committed; one per current-year activity
│ │ └── ...
│ └── data/
│ ├── strava-activities.json ← existing (training dashboard)
│ └── activity-cards.json ← NEW: feed metadata (generated by render_cards.py)
│
├── _pages/
│ └── activity-feed.md ← NEW: Jekyll feed page
│
├── .github/workflows/
│ ├── fetch-strava.yml ← existing (training dashboard)
│ └── update-activity-feed.yml ← NEW: daily feed pipeline
│
├── config.json ← existing timing config (unchanged)
├── requirements.txt ← updated with new deps
├── .strava_token.json ← gitignored — local OAuth token
└── activities_cache.json ← gitignored locally; committed in CI via workflow
Part 1: Refactor — Extract gpx_core.py ✓ DONE
gpx_core.py exports:
parse_gpx(path) -> (points, has_timestamps)
assign_timestamps(pts, start, end) -> points
to_xy(lat, lon) -> np.ndarray
interp_pos(xy, ts, t) -> np.ndarray | None
render_animation(
tracks, # list of {name, ts, xy}
output_path, # .gif or .mp4
duration, fps, dpi, figsize,
show_hud, # bool — time counter + progress bar
loop, # bool — cyclic seamless trail; GIF loops infinitely
basemap, # bool — fetch CartoDB tiles (local use only)
) -> None
animate_gpx.py is now a thin CLI wrapper. test_render.py imports data functions from gpx_core and keeps its own themed render function for local preview.
Part 2: Strava OAuth — strava_auth.py ✓ DONE
Manages access/refresh tokens in .strava_token.json.
Three runtime paths:
- Token file valid → returns token silently
- Token expired → refreshes via refresh token, saves updated file
- No token file +
STRAVA_REFRESH_TOKENenv var present → bootstrap path (skips browser flow; uses the existing refresh token already stored in GitHub Secrets)
In GitHub Actions the bootstrap path is never needed — the Action always calls the Strava API directly using the env-var credentials (same pattern as scripts/fetch_strava.py).
strava_auth.py is a local development tool. CI uses inline token refresh (see Part 5).
Part 3: Fetch Activities — scripts/fetch_activities.py
Strava API calls
GET /athlete/activities → activity summaries (filtered to current year)
GET /activities/{id}/streams → GPS + time stream per activity
(keys: latlng, time)
What it does
- Accepts
--year(default: current calendar year) - Fetches all activities for that year from the Strava API (paginated, stops when it hits a prior-year activity)
- Filters to outdoor activities with GPS — skips
VirtualRide,EllipticalTrainer, etc. - For each activity not already in the cache, fetches the GPS stream
- Saves/updates
activities_cache.json:
[
{
"id": 12345678,
"name": "Morning Run",
"type": "Run",
"start_date": "2026-03-09T17:59:00Z",
"distance": 8432,
"moving_time": 2835,
"total_elevation_gain": 124,
"latlng": [[37.77, -122.41], "..."],
"timestamps": [0, 3, 6, "..."]
}
]
- Timestamps from the Strava stream are relative (seconds since activity start) — stored as-is;
render_cards.pyconverts to absolute usingstart_date - Always checks cache before fetching GPS streams (Strava rate limit: 100 req/15 min)
Year rollover behaviour
When the script detects that activities_cache.json contains data from a prior year (i.e. --year doesn’t match the cached year), it wipes the cache and starts fresh. The GitHub Action also deletes all committed GIFs at this point (see Part 5).
CLI usage
python scripts/fetch_activities.py # current year, incremental
python scripts/fetch_activities.py --year 2026 # explicit year
python scripts/fetch_activities.py --refresh # re-fetch all streams (ignore cache)
Env vars required (CI)
STRAVA_CLIENT_ID
STRAVA_CLIENT_SECRET
STRAVA_REFRESH_TOKEN
Part 4: Render Cards — scripts/render_cards.py
What it does
- Reads
activities_cache.json - For each activity that does not already have a GIF at
assets/activity-cards/{id}.gif, callsgpx_core.render_animation():figsize = (4, 4)— small square cardduration = 3secondsfps = 20dpi = 100show_hud = Falseloop = Truebasemap = False— dark background, no map tiles (CI-safe, fast)
- Writes
assets/data/activity-cards.json— trimmed metadata for the website:
{
"year": 2026,
"generated": "2026-03-23T06:12:00Z",
"activities": [
{
"id": 12345678,
"name": "Morning Run",
"type": "Run",
"start_date": "2026-03-09T17:59:00Z",
"distance": 8432,
"moving_time": 2835,
"elevation": 124,
"card": "/assets/activity-cards/12345678.gif"
}
]
}
- Activities sorted newest-first in the JSON
- Skips activities with no GPS data (stream fetch failed or filtered out)
CLI usage
python scripts/render_cards.py # incremental (skip existing GIFs)
python scripts/render_cards.py --rerender 12345678 # force re-render one activity
python scripts/render_cards.py --all # re-render everything
Part 5: GitHub Action — .github/workflows/update-activity-feed.yml
Runs daily at 6 AM UTC. Also triggerable manually.
Steps
1. Checkout repo
2. Set up Python
3. pip install dependencies
4. Inline token refresh (POST to Strava OAuth, write access token to env)
5. Detect year rollover:
if activities_cache.json exists and its year != current year:
- delete assets/activity-cards/*.gif
- delete activities_cache.json
6. python scripts/fetch_activities.py
7. python scripts/render_cards.py
8. git add assets/activity-cards/ assets/data/activity-cards.json activities_cache.json
9. git commit + push (only if something changed)
Env vars / secrets needed
STRAVA_CLIENT_ID (already in GitHub Secrets)
STRAVA_CLIENT_SECRET (already in GitHub Secrets)
STRAVA_REFRESH_TOKEN (already in GitHub Secrets)
Year rollover detail
The Action checks the year field in activities_cache.json (or the year of the oldest cached activity) against the current year. On mismatch:
- All
assets/activity-cards/*.giffiles are deleted and the deletion is committed activities_cache.jsonis wiped- The pipeline runs fresh, building the new year’s feed from scratch
Part 6: Jekyll Feed Page — _pages/activity-feed.md
A standard Jekyll page (like the training dashboard) that loads /assets/data/activity-cards.json via JavaScript and renders the feed client-side.
Feed JS behaviour
- Fetches
/assets/data/activity-cards.json - Renders a card grid — each card shows:
- Animated GIF (loops continuously)
- Activity name
- Date (e.g. “Mar 9”)
- Distance (miles, 1 decimal)
- Moving time (
1h 15mor42m) - Sport icon (run / ride / walk / hike / swim)
- Cards sorted newest-first
- Filter buttons by sport type
- Shows the feed year prominently so it’s clear this is a year-scoped view
Visual style
- Dark theme matching the GIF aesthetic and site’s Berkeley Blue palette
- Responsive grid: 3 columns desktop → 2 tablet → 1 mobile
- Subtle glow on card hover
.gitignore entries
# Strava pipeline — secrets and local-only files
.env
.strava_token.json
# Cache is gitignored locally but committed by CI
# (add to .gitignore locally if you don't want it cluttering git status)
# activities_cache.json
Note: assets/activity-cards/*.gif and assets/data/activity-cards.json are NOT gitignored — they are committed outputs that GitHub Pages serves directly.
requirements.txt (pipeline deps)
# existing (local tools)
gpxpy>=1.5.0
matplotlib>=3.6.0
contextily>=1.3.0
pyproj>=3.4.0
numpy>=1.23.0
# new
requests>=2.28.0 # Strava API calls
Pillow>=9.0.0 # GIF export
python-dotenv>=1.0.0 # .env loading (local dev only)
Key Implementation Notes
GIF export looping —
PillowWriteris used for all GIF output. The GIF loops infinitely by default in modern browsers without needing explicitloop=0metadata.Strava rate limits — 100 requests per 15 minutes, 1000 per day. One GPS stream fetch per new activity. The cache ensures streams are never re-fetched.
Strava stream format —
latlngis a list of[lat, lon]pairs.timeis a list of integers (seconds since activity start). Both are under thedatakey in the response.Token refresh in CI — done inline at the top of the Action (POST to
https://www.strava.com/oauth/tokenwithgrant_type=refresh_token). The resulting access token is written to$GITHUB_ENVfor use by subsequent steps.strava_auth.pyis not used in CI.Activities with no GPS — some activity types (indoor rides, treadmill runs) have no
latlngstream.fetch_activities.pyskips the stream fetch for these;render_cards.pyskips rendering them. They are excluded fromactivity-cards.json.activities_cache.jsonin CI — committed to the repo so the Action can do incremental fetches across daily runs without re-downloading all streams. It is safe to commit because it contains no credentials, only activity metadata and GPS coordinates. ```
