Strava Animated Activity Feed — Build Plan


0 minute read

Strava Animated Activity Feed — Build Plan

Overview

A GitHub Actions-powered static feed:

  1. 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
  2. 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

ConcernDecision
Where GIFs are renderedGitHub Actions (headless, no basemap)
Where GIFs are storedassets/activity-cards/{id}.gif — committed to the repo
Who triggers the pipelineGitHub Actions cron (daily at 6 AM UTC) + manual dispatch
How the page is servedGitHub Pages (Jekyll), same as the rest of the site
Year rolloverAction detects new year, deletes old GIFs + resets cache, starts fresh
Basemap tilesOff in CI — dark background only (looks great, faster, no network dep)
site/ output folderEliminated — outputs go directly into the Jekyll asset tree
build.py master scriptEliminated — 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:

  1. Token file valid → returns token silently
  2. Token expired → refreshes via refresh token, saves updated file
  3. No token file + STRAVA_REFRESH_TOKEN env 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.py converts to absolute using start_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, calls gpx_core.render_animation():
    • figsize = (4, 4) — small square card
    • duration = 3 seconds
    • fps = 20
    • dpi = 100
    • show_hud = False
    • loop = True
    • basemap = 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/*.gif files are deleted and the deletion is committed
  • activities_cache.json is 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 15m or 42m)
    • 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

  1. GIF export loopingPillowWriter is used for all GIF output. The GIF loops infinitely by default in modern browsers without needing explicit loop=0 metadata.

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

  3. Strava stream formatlatlng is a list of [lat, lon] pairs. time is a list of integers (seconds since activity start). Both are under the data key in the response.

  4. Token refresh in CI — done inline at the top of the Action (POST to https://www.strava.com/oauth/token with grant_type=refresh_token). The resulting access token is written to $GITHUB_ENV for use by subsequent steps. strava_auth.py is not used in CI.

  5. Activities with no GPS — some activity types (indoor rides, treadmill runs) have no latlng stream. fetch_activities.py skips the stream fetch for these; render_cards.py skips rendering them. They are excluded from activity-cards.json.

  6. activities_cache.json in 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. ```