Turn every Strava activity description into a rich, auto-generated training report.
DescGen is a dockerized application that checks for new activities, pulls stats from all of your connected services, writes a detailed Strava description, and exposes the latest payload as a local JSON API for other dashboards and automations.
I created this because I found that I kept checking 5 different sites for unique stats that they provided. I decided to just pull all of the stats that I want into one place, now I only check my Strava description and I can see everything at a glance. I found over time this gives me a fantastic snapshot of exactly where I was at in my training, what the conditions were, and what kind of load I was under.
- Auto-updates new Strava activities descriptions on a heartbeat (default every 5 minutes).
- Local API endpoint to read latest output and force reruns.
- Graphical Template Editor Web UI
- Different Profiles for different descriptions based on Run type.
- Export/import template bundles so you can move configs between instances and share amung the community.
π 412 days in a row
π
Longest run in 90 days
π
2nd best GAP pace this month
π€οΈπ‘οΈ Misery Index: 14.9 π (hot) | π AQI: 22 Good
π€οΈπ₯ 7d avg Deficit:-753 cal | Pre-Run: [π₯©:40g π:60g]
π€οΈπ¦ Training Readiness: 83 π’ | π 47 | π€ 86
ππ 7:18/mi | πΊοΈ 8.02 | ποΈ 612' | π 58:39 | πΊ 5.1
ππ£ 176spm | πΌ 914 kJ | β‘ 271 W | π 149 | βοΈ1.03
π π’ Productive | 4.1 : 0.1 - Tempo
π ποΈ 65 | π¦ 78 | πΏ -20% - Optimal π’
π ποΈ 857 | π¦ 901 | πΏ 1.1 - Optimal π’
β€οΈβπ₯ 57.2 | βΎ Endur: 7312 | π» Hill: 102
7οΈβ£ Past 7 days:
π 7:44/mi | πΊοΈ 41.6 | ποΈ 3,904' | π 5:21:08 | πΊ 27
π
Past 30 days:
π 7:58/mi | πΊοΈ 156 | ποΈ 14,902' | π 20:04:51 | πΊ 101
π This Year:
π 8:05/mi | πΊοΈ 284 | ποΈ 24,117' | π 36:40:27 | πΊ 184
- In Dockge
+ Compose - Paste the sample
docker-compose.yml:
services:
auto-stat-worker:
image: seanap/descgen:latest
container_name: auto-stat-worker
command: ["python", "worker.py"]
network_mode: bridge
env_file:
- .env
volumes:
- ./data:/app/state
healthcheck:
test: ["CMD-SHELL", "python -c \"import os,sys; from pathlib import Path; from storage import is_worker_healthy; state=Path(os.getenv('STATE_DIR','state')); log=state / os.getenv('PROCESSED_LOG_FILE','processed_activities.log'); age=int(os.getenv('WORKER_HEALTH_MAX_AGE_SECONDS','900')); sys.exit(0 if is_worker_healthy(log, age) else 1)\""]
interval: 60s
timeout: 10s
retries: 3
start_period: 45s
restart: unless-stopped
auto-stat-api:
image: seanap/descgen:latest
container_name: auto-stat-api
command: ["/bin/sh", "-c", "gunicorn --bind 0.0.0.0:${API_PORT:-1609} --workers ${API_WORKERS:-2} --threads ${API_THREADS:-4} --timeout ${API_TIMEOUT_SECONDS:-120} api_server:app"]
network_mode: bridge
env_file:
- .env
volumes:
- ./data:/app/state
ports:
- "1609:1609"
healthcheck:
test: ["CMD-SHELL", "python -c \"import os,sys,urllib.request; p=os.getenv('API_PORT','1609'); u='http://127.0.0.1:%s/ready' % p; r=urllib.request.urlopen(u, timeout=5); sys.exit(0 if getattr(r, 'status', 200) == 200 else 1)\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
restart: unless-stopped- Paste the
.envvalues below the compose
# Strava
STRAVA_CLIENT_ID=your_strava_client_id
STRAVA_CLIENT_SECRET=your_strava_client_secret
STRAVA_REFRESH_TOKEN=your_strava_refresh_token
STRAVA_ACCESS_TOKEN=your_strava_access_token
# Garmin
ENABLE_GARMIN=true
GARMIN_EMAIL=you@example.com
GARMIN_PASSWORD=your_garmin_password
# Intervals.icu
ENABLE_INTERVALS=true
INTERVALS_API_KEY=your_intervals_api_key
INTERVALS_USER_ID=your_intervals_user_id
# Legacy alias still supported: USER_ID
# WeatherAPI
ENABLE_WEATHER=true
WEATHER_API_KEY=your_weatherapi_key
# Smashrun
ENABLE_SMASHRUN=true
SMASHRUN_ACCESS_TOKEN=your_smashrun_access_token
# Crono API
ENABLE_CRONO_API=false
CRONO_API_BASE_URL=http://<local_ip>:8777
CRONO_API_KEY=optional_if_using
# Quiet Hour Config
ENABLE_QUIET_HOURS=true
QUIET_HOURS_START=0
QUIET_HOURS_END=4
# DescGen Runtime
TIMEZONE=America/New_York
# Legacy alias still supported: TZ
#POLL_INTERVAL_SECONDS=300
#LOG_LEVEL=INFO
#STATE_DIR=state
#PROCESSED_LOG_FILE=processed_activities.log
#LATEST_JSON_FILE=latest_activity.json
#STRAVA_TOKEN_FILE=strava_tokens.json
#RUNTIME_DB_FILE=runtime_state.db
#WORKER_HEALTH_MAX_AGE_SECONDS=900
#RUN_LOCK_TTL_SECONDS=900
#SERVICE_RETRY_COUNT=2
#SERVICE_RETRY_BACKOFF_SECONDS=2
#SERVICE_COOLDOWN_BASE_SECONDS=60
#SERVICE_COOLDOWN_MAX_SECONDS=1800
#ENABLE_SERVICE_CALL_BUDGET=true
#MAX_OPTIONAL_SERVICE_CALLS_PER_CYCLE=10
#ENABLE_SERVICE_RESULT_CACHE=true
#SERVICE_CACHE_TTL_SECONDS=600
# API runtime
#API_WORKERS=2
#API_THREADS=4
#API_TIMEOUT_SECONDS=120
#API_PORT=1609
#DOCKER_IMAGE=seanap/descgen:latest- Confirm API is alive:
curl http://localhost:1609/health
curl http://localhost:1609/ready- Open Description Editor
http://localhost:1609/editor
Full Endpoint List
GET /healthGET /readyGET /latestGET /service-metricsPOST /rerun/latest(rerun most recent activity)POST /rerun/activity/<activity_id>(rerun specific Strava activity)POST /rerunwith optional JSON body:{ "activity_id": 1234567890 }GET /editor/schema?context_mode=latest_or_sample(available template data keys)GET /editor/fixtures(pinned sample fixtures for preview/testing)GET /editor/profiles(list profile workspace configuration + active working profile)PUT /editor/profiles/<profile_id>(enable/disable profile, update priority)POST /editor/profiles/working(set current editor working profile)GET /editor/template(active template for profile; optionalprofile_id)GET /editor/template/default(factory template)GET /editor/template/export(downloadable JSON bundle of active template; optionalprofile_id)GET /editor/template/versions(saved template version history; optionalprofile_id)GET /editor/template/version/<version_id>(specific saved template; optionalprofile_id)GET /editor/snippets(quick insert snippets for the web editor)GET /editor/starter-templates(curated starter layouts for quick setup)GET /editor/context/sample(sample context payload for testing)PUT /editor/template(save custom template; optionalprofile_id)POST /editor/template/import(import and publish template from bundle JSON; optionalprofile_id)POST /editor/template/rollback(rollback active template to a prior version; optionalprofile_id)POST /editor/validate(validate a template string; optionalcontext_mode,profile_id)POST /editor/preview(render preview; optionalcontext_mode,profile_id)
Editor UI:
GET /editor(web UI with builder, snippet palette, click-to-insert fields, and preview context switcher)
Examples:
curl -X POST http://localhost:1609/rerun/latest
curl -X POST http://localhost:1609/rerun/activity/1234567890
curl -X POST http://localhost:1609/rerun -H "Content-Type: application/json" -d '{"activity_id":1234567890}'
curl "http://localhost:1609/editor/schema?context_mode=latest_or_sample"
curl http://localhost:1609/editor/fixtures
curl http://localhost:1609/editor/profiles
curl -X POST http://localhost:1609/editor/profiles/working -H "Content-Type: application/json" -d '{"profile_id":"trail"}'
curl http://localhost:1609/editor/template
curl "http://localhost:1609/editor/template?profile_id=trail"
curl http://localhost:1609/editor/template/export
curl "http://localhost:1609/editor/template/versions?profile_id=trail"
curl http://localhost:1609/editor/snippets
curl http://localhost:1609/editor/starter-templates
curl http://localhost:1609/editor/context/sample
curl http://localhost:1609/editor/context/sample?fixture=winter_grind
curl -X POST http://localhost:1609/editor/template/import -H "Content-Type: application/json" -d '{"bundle":{"template":"{{ activity.gap_pace }}","name":"Imported Template"},"author":"cli-user","context_mode":"sample","profile_id":"trail"}'
curl -X POST http://localhost:1609/editor/validate -H "Content-Type: application/json" -d '{"context_mode":"sample","profile_id":"trail","template":"{{ activity.gap_pace }} | {{ activity.distance_miles }}"}'
curl -X POST http://localhost:1609/editor/preview -H "Content-Type: application/json" -d '{"context_mode":"fixture","fixture_name":"humid_hammer","profile_id":"trail","template":"{{ training.vo2 }} | {{ periods.week.distance_miles }}mi"}'
curl -X POST http://localhost:1609/editor/template/rollback -H "Content-Type: application/json" -d '{"version_id":"vYYYYMMDDTHHMMSSffffffZ-abcdef1234","profile_id":"trail"}'
open http://localhost:1609/editor- Go to
https://www.strava.com/settings/apiand create/open your API app. - Set Authorization Callback Domain to
localhostduring setup. - Copy
STRAVA_CLIENT_IDandSTRAVA_CLIENT_SECRETinto.env. - Open this URL (replace
YOUR_STRAVA_CLIENT_ID):
https://www.strava.com/oauth/authorize?client_id=YOUR_STRAVA_CLIENT_ID&response_type=code&redirect_uri=http://localhost/exchange_token&approval_prompt=force&scope=read,activity:read_all,activity:write
- Authorize the app.
- If browser shows a localhost error page, that is expected.
- Copy the
code=value from the URL. - Exchange code for tokens:
curl -X POST https://www.strava.com/oauth/token \
-d client_id=YOUR_STRAVA_CLIENT_ID \
-d client_secret=YOUR_STRAVA_CLIENT_SECRET \
-d code=THE_CODE_FROM_URL \
-d grant_type=authorization_code- Put response values into
.env:
STRAVA_REFRESH_TOKEN=refresh_tokenSTRAVA_ACCESS_TOKEN=access_token(optional; refresh token is what matters long-term) (Legacy aliasesREFRESH_TOKENandACCESS_TOKENare still accepted.)
Strava gotchas:
invalid redirect_uri: callback domain and URL do not match.- Missing
code=: authorization was denied. - Updates fail: ensure
activity:writescope was granted.
- Open Intervals.icu settings.
- In Developer/API section, copy your API key and athlete ID.
- Set
.envvalues:
INTERVALS_API_KEYINTERVALS_USER_ID(Legacy aliasUSER_IDis still accepted.)
Quick test:
curl -u API_KEY:YOUR_INTERVALS_API_KEY \
"https://intervals.icu/api/v1/athlete/YOUR_ATHLETE_ID/activities?oldest=2026-01-01"- Sign up at
https://www.weatherapi.com/signup.aspx. - Copy your key from the WeatherAPI dashboard.
- Set
.env:
WEATHER_API_KEY
Quick test:
curl "https://api.weatherapi.com/v1/current.json?key=YOUR_WEATHER_API_KEY&q=New+York&aqi=yes"- Open
https://api.smashrun.com/v1/documentation. - Launch API Explorer and authorize.
- Copy your bearer token.
- Set
.env:
SMASHRUN_ACCESS_TOKEN
Quick test:
curl -H "Authorization: Bearer YOUR_SMASHRUN_ACCESS_TOKEN" \
"https://api.smashrun.com/v1/my/stats"misery.index = D(weather discomfort) + tail(R(weather hazard risk))
Detailed Algorithm Explanation
This version is running-specific and uses two coupled heads:0is ideal.- Higher is always more miserable.
- Discomfort (
D) and danger risk (R) are modeled separately. Death(>100) is intended to come from true hazard regimes, not casual stacking.
Primary factors:
- Apparent thermal load (temp + heat index + wind chill blending)
- Dew point and humidity
- Wind comfort + strong-wind effort burden (
>10 mph) with context damping - Sun/cloud proxy
- Precipitation and snow interaction penalties
Model structure:
D(discomfort): smooth convex penalties around running comfort bands.R(risk): regime-gated hazard modes (heat,cold,cold-wet/storm) combined withlogsumexp.- Final score:
D + soft risk-tail; mild wind in already-bad weather raises discomfort but does not automatically imply emergency risk.
Severity buckets (misery.index):
0-5: ideal>5-15: mild>15-30: moderate>30-50: high>50-75: very high>75-100: extreme>100: death
Polarity is separate from severity:
misery.index.polarity:hot,cold, orneutralmisery.index.emoji: chosen from severity + polaritymisery.index: display scalar (numeric)
Template usage:
{{ misery.index }}{{ misery.index.emoji }}{{ misery.index.polarity }}{{ misery.index.severity }}
These are computed with the live algorithm in this repo and include one hot-polarity and one cold-polarity example for every severity bucket.
| Bucket | Hot Example (Temp/Dew/RH/Wind/Conditions) | Hot MI | Cold Example (Temp/Dew/RH/Wind/Conditions) | Cold MI |
|---|---|---|---|---|
ideal |
64F / 40F / 60% / 4 mph / Clear |
2.7 π |
45F / 20F / 45% / 6 mph / Overcast |
2.5 π |
mild |
70F / 55F / 60% / 8 mph / Clear |
9.4 π |
38F / 24F / 45% / 8 mph / Overcast |
10.2 π |
moderate |
80F / 40F / 60% / 4 mph / Clear |
22.3 π |
38F / 22F / 65% / 12 mph / Overcast |
23.0 π |
high |
80F / 65F / 70% / 4 mph / Clear |
40.0 π |
30F / 18F / 65% / 14 mph / Overcast |
40.0 π |
very_high |
82F / 72F / 70% / 1 mph / Clear |
62.5 π₯΅ |
20F / 20F / 65% / 12 mph / Overcast |
63.0 π° |
extreme |
88F / 76F / 60% / 4 mph / Clear |
87.7 π‘ |
20F / 16F / 65% / 20 mph / Overcast |
82.5 π₯Ά |
death |
90F / 70F / 70% / 5 mph / Clear |
110.4 β οΈ |
24F / 0F / 50% / 28 mph / Moderate snow |
110.0 β οΈ |