AI Cycling
026 Build Your Own Training Tools 1,693 words · 8 min

Build A Personal Training Dashboard With Streamlit In An Evening

Every self-coached rider I know asks the same three questions, over and over, all season:

  1. Am I carrying more fitness into this block than I was into the last one, or am I just tired?
  2. Has my power actually improved at the durations my event cares about?
  3. Is my aerobic base getting deeper, or am I still falling apart in hour three?

You are paying a subscription that half-answers all three. TrainingPeaks Premium runs around £17 a month, WKO5 is about $199 up front, Xert is roughly $10 a month, and stacking two of them puts you near £200 a year. For that you get a Performance Management Chart you can’t re-window, a power curve that compares “this year vs last year” when what you want is “March block vs July block”, and a decoupling number buried three clicks deep that includes the café stop.

The fix is a single Python file. Not a product, not a repo with CI, just dash.py sitting in a folder on your laptop. Mine is 214 lines and it answers those three questions better than anything I’ve paid for, because it answers them the way I asked them. This walkthrough is a complete cycling training dashboard in Python using Streamlit, with the actual code.

Get the data without the OAuth dance

Skip Strava’s API. The read rate limits are tight, the OAuth refresh loop is annoying to babysit, and you’d be re-implementing an ingestion layer you already have. intervals.icu already pulls from Garmin, Wahoo, Zwift and Strava, already computes load, and hands you raw streams over HTTP Basic auth with a key you generate in Settings. It costs nothing and takes one line to authenticate.

# dash.py
from pathlib import Path
import pandas as pd, requests, streamlit as st

BASE = "https://intervals.icu/api/v1"
ATHLETE = st.secrets["ICU_ATHLETE"]          # e.g. "i123456"
AUTH = ("API_KEY", st.secrets["ICU_KEY"])    # literal "API_KEY" as username
CACHE = Path.home() / ".cache" / "icu-streams"
CACHE.mkdir(parents=True, exist_ok=True)

@st.cache_data(ttl=3600)
def activities(oldest: str, newest: str) -> pd.DataFrame:
    r = requests.get(f"{BASE}/athlete/{ATHLETE}/activities",
                     params={"oldest": oldest, "newest": newest},
                     auth=AUTH, timeout=30)
    r.raise_for_status()
    df = pd.DataFrame(r.json())
    df["start"] = pd.to_datetime(df["start_date_local"]).dt.tz_localize(None)
    return df.sort_values("start")

def streams(activity_id: str) -> pd.DataFrame:
    """1 Hz watts/HR/time. Cached to parquet so you fetch each ride once."""
    f = CACHE / f"{activity_id}.parquet"
    if f.exists():
        return pd.read_parquet(f)
    r = requests.get(f"{BASE}/activity/{activity_id}/streams",
                     params={"types": "time,watts,heartrate"},
                     auth=AUTH, timeout=60)
    r.raise_for_status()
    df = pd.DataFrame({s["type"]: s["data"] for s in r.json()})
    df.to_parquet(f)
    return df

Two things worth knowing. The parquet cache matters: pulling streams for 90 rides takes about four minutes cold and under two seconds warm, and you will reload this page constantly while you’re fiddling with it. And intervals.icu returns 1 Hz streams even if your head unit recorded on smart recording, so a window of 300 samples is genuinely 300 seconds. That single guarantee removes most of the resampling code you’d otherwise write against raw .fit files with fitdecode.

Put your key in .streamlit/secrets.toml:

ICU_ATHLETE = "i123456"
ICU_KEY = "abc123..."

View one: load, with a ramp rate you can read

CTL and ATL are just exponentially weighted means of daily training load, 42 days and 7 days. Nine lines.

def load_curve(df: pd.DataFrame, ctl=42, atl=7) -> pd.DataFrame:
    daily = (df.set_index("start")["icu_training_load"]
               .resample("D").sum().asfreq("D", fill_value=0))
    c = daily.ewm(alpha=1/ctl, adjust=False).mean()
    a = daily.ewm(alpha=1/atl, adjust=False).mean()
    out = pd.DataFrame({"load": daily, "CTL": c, "ATL": a})
    out["TSB"] = out["CTL"].shift(1) - out["ATL"].shift(1)
    out["ramp"] = out["CTL"] - out["CTL"].shift(7)   # CTL/week
    return out

That ramp column is the reason to build this. TrainingPeaks will show you the CTL line; it will not put “you are adding 9.4 CTL per week and the sustainable ceiling for you is about 6” on the screen as a number. Mine reads:

              CTL    ATL    TSB    ramp
2026-06-29   71.2   68.4   +4.1    +2.8
2026-07-06   76.9   92.1  -20.9    +5.7
2026-07-13   84.4  104.6  -25.3    +7.5
2026-07-20   89.1   74.2   +8.2    +4.7
2026-07-27   83.6   51.8  +30.4    -5.5

Week of 13 July at +7.5 is where the ankle niggle started. I know that because the number was in front of me, not because I reconstructed it in October.

Rendering it takes four lines with Altair, and the key detail is interpolate="step-after" on the daily load bars so a 180 TSS Saturday reads as a block, not a spike:

import altair as alt

def load_view(lc: pd.DataFrame):
    d = lc.reset_index(names="date")
    bars = alt.Chart(d).mark_bar(opacity=0.25).encode(x="date:T", y="load:Q")
    lines = (alt.Chart(d.melt("date", ["CTL", "ATL"]))
               .mark_line(strokeWidth=2)
               .encode(x="date:T", y="value:Q", color="variable:N"))
    st.altair_chart(bars + lines, use_container_width=True)
    a, b, c = st.columns(3)
    a.metric("CTL", f"{d.CTL.iloc[-1]:.1f}", f"{d.ramp.iloc[-1]:+.1f}/wk")
    b.metric("TSB", f"{d.TSB.iloc[-1]:+.1f}")
    c.metric("28d load", f"{d.load.tail(28).sum():.0f} TSS")

View two: a power curve that compares the windows you choose

Mean maximal power is a rolling mean and a max. The whole primitive is five lines:

DURS = [5, 15, 30, 60, 120, 300, 480, 600, 1200, 1800, 3600, 5400]

def mean_max(watts: pd.Series, durs=DURS) -> dict:
    s = pd.Series(watts, dtype="float64").interpolate(limit=3).fillna(0)
    return {d: float(s.rolling(d, min_periods=d).mean().max())
            for d in durs if len(s) >= d}

def curve(acts: pd.DataFrame) -> pd.Series:
    best = {}
    for aid in acts["id"]:
        for d, w in mean_max(streams(aid).get("watts", pd.Series(dtype=float))).items():
            best[d] = max(best.get(d, 0.0), w)
    return pd.Series(best).sort_index()

A four-hour ride is 14,400 samples; twelve rolling windows over it finish in under 80 ms on a laptop, so a 90-ride window from a warm parquet cache renders in about two seconds. Then two date pickers and a subtraction:

def curve_view(df):
    l, r = st.columns(2)
    a = l.date_input("Block A", (pd.Timestamp("2026-01-01"), pd.Timestamp("2026-03-31")))
    b = r.date_input("Block B", (pd.Timestamp("2026-06-01"), pd.Timestamp("2026-08-31")))
    win = lambda rng: df[df.start.between(*map(pd.Timestamp, rng))]
    ca, cb = curve(win(a)), curve(win(b))
    out = pd.DataFrame({"A": ca, "B": cb})
    out["delta"] = out.B - out.A
    out["pct"] = (out.delta / out.A * 100).round(1)
    st.dataframe(out.round(0), use_container_width=True)

Here is the output that changed how I trained this year, 72 kg rider, winter FTP 285 W, target event a club 25 in September:

 dur      A (Jan-Mar)   B (Jun-Aug)   delta    pct
   5s          1042 W        1011 W     -31   -3.0
  30s           612 W         598 W     -14   -2.3
  60s           468 W         474 W      +6   +1.3
   5m           352 W         361 W      +9   +2.6
  20m           301 W         318 W     +17   +5.6
  60m           278 W         295 W     +17   +6.1

Sprint down 3%, hour power up 6%. For a TT rider that is a correct trade and I should stop worrying about the 5-second number. For the same rider entering a hilly gravel race with repeated punchy climbs, that table is a warning. No subscription tool tells you which of those two you are, because it doesn’t know your calendar. You do, and your dashboard is two windows wide.

View three: decoupling that excludes the café stop

Pw:Hr drift is the cleanest durability signal available from a power meter and a chest strap. Friel’s threshold is 5%: under that, your aerobic system held the effort; over it, you were writing cheques hour one couldn’t cash. The catch is that the metric is only meaningful on steady rides, and every platform I’ve used computes it on rides where it’s noise.

def drift(s: pd.DataFrame, min_min=45, max_vi=1.06):
    d = s.dropna(subset=["watts", "heartrate"])
    d = d[(d.heartrate > 90) & (d.watts > 0)]          # drop coasting + soft-pedal
    if len(d) < min_min * 60:
        return None
    np_ = (d.watts.rolling(30, min_periods=30).mean() ** 4).mean() ** 0.25
    if np_ / d.watts.mean() > max_vi:                  # not a steady ride
        return None
    h = len(d) // 2
    r1 = d.watts[:h].mean() / d.heartrate[:h].mean()
    r2 = d.watts[h:].mean() / d.heartrate[h:].mean()
    return (r1 - r2) / r1 * 100

The heartrate > 90 and variability-index filters are the whole point. They throw away roughly two thirds of my rides, and what survives is comparable ride to ride:

date         moving    NP    avg HR    drift
2026-04-12    3h02    208      141     +7.8%
2026-05-24    3h17    211      140     +6.3%
2026-06-21    3h11    214      138     +4.9%
2026-08-09    3h24    217      136     +2.6%

Four points, one per month, and the story is unambiguous: the base work landed. Notice the trend is visible in HR at a higher NP, which is the tell that it’s real adaptation rather than a cool week. I sanity-checked the same rides against intervals.icu’s own Pw:Hr field and got within 0.4 percentage points on three of four, with the outlier being a ride where my filter cut a long descent that theirs kept. That disagreement is information, and I’d rather own the filter than guess at someone else’s.

Wire it up:

st.set_page_config(page_title="Training dashboard", layout="wide")
df = activities("2026-01-01", str(pd.Timestamp.today().date()))
t1, t2, t3 = st.tabs(["Load", "Power curve", "Decoupling"])
with t1: load_view(load_curve(df))
with t2: curve_view(df)
with t3: drift_view(df)

pip install streamlit pandas requests altair pyarrow, then streamlit run dash.py, and it opens on localhost:8501 with hot reload on save. First run from cold cache took me about four minutes of stream fetching for a nine-month season. Every run after that is two seconds.

What this doesn’t do, and why that’s fine

There’s no multi-user auth, no mobile layout, no error handling worth the name, and if intervals.icu changes a field name the page throws a KeyError at you in red. It won’t do FTP detection, it has no opinion about your training, and it will not email you. That absence is the feature: 214 lines is small enough that you can read the whole thing in ten minutes and know exactly what every number means, which is more than you can say about any proprietary composite score on your current dashboard. If you want the general case for owning your own analysis layer instead of renting it, that’s the argument behind Build Your Own Training Tools.

The next thing to add is the view none of the paid tools do properly: mean maximal power after a kJ threshold. Filter each ride’s stream to samples where cumulative work exceeds 2000 kJ, run the same mean_max over what’s left, and plot 5-minute power fresh against 5-minute power deep. That’s nine extra lines on top of code you now have, and for anyone riding anything longer than two hours it’s the number that predicts the result.

Tonight, pull one ride, print its drift value, and see whether it matches what the app on your phone told you. If it does, you’ve validated the pipeline in fifteen minutes. If it doesn’t, you’ve just found out something about a metric you’ve been trusting.