One-screen terminal dashboard for the NHL and NBA playoffs. Stdlib only, single Python file, fits in 80 columns.
PLAYOFFS Thu Jun 11 2026 updated 11:38 AM PDT
RECENT
18h ago NBA R4 G4 SA 106 @ NY 107 NY 3-1
LATER TODAY
5:00 PM NHL R4 G5 VGK @ CAR
Stanley Cup Final (1 active)
CAR vs VGK tied 2-2 G5 5:00 PM VGK·CAR·VGK·CAR
NHL Conference Finals (2 done)
done: CAR-MTL 4-1 · VGK-COL 4-0
NHL Round 2 (4 done)
done: CAR-PHI 4-0 · COL-MIN 4-1 · MTL-BUF 4-3 · VGK-ANA 4-2
NHL Round 1 (8 done)
done: ANA-EDM 4-2 · BUF-BOS 4-2 · CAR-OTT 4-0 · COL-LA 4-0 · MIN-DAL 4-2
MTL-TB 4-3 · PHI-PIT 4-2 · VGK-UTA 4-2
NBA Finals (1 active)
NY vs SA NY 3-1 G5 Jun 13 ★ NY·NY·SA·NY
NBA Conference Finals (2 done)
done: NY-CLE 4-0 · SA-OKC 4-3
NBA Conference Semifinals (4 done)
done: CLE-DET 4-3 · NY-PHI 4-0 · OKC-LAL 4-0 · SA-MIN 4-2
NBA Round 1 (8 done)
done: CLE-TOR 4-3 · DET-ORL 4-3 · LAL-HOU 4-2 · MIN-DEN 4-2 · NY-ATL 4-2
OKC-PHX 4-0 · PHI-BOS 4-3 · SA-POR 4-1
When live games are happening, a LIVE NOW section appears at the top instead of RECENT, sorted by score gap (closest first) with closeout/stay-alive markers:
LIVE NOW
NHL TB 0 @ MTL 0 8:33 - 3rd G6 ★ closeout MTL leads 3-2
NHL BUF 2 @ BOS 1 End of 2nd G6 ★ closeout BUF leads 3-2
NBA ORL 88 @ DET 87 4:24 - 4th G6 ★ closeout ORL leads 3-2
NBA CLE 74 @ TOR 85 3:40 - 3rd G6 ★ stay alive CLE leads 3-2
Run it directly — stdlib only, no install step required:
python3 playoffs.pyTested on Python 3.9+.
To put it on your $PATH, symlink it:
mkdir -p ~/.local/bin
ln -s "$(pwd)/playoffs.py" ~/.local/bin/playoffsFor ambient awareness during a game night:
watch -n 60 playoffs| Section | When shown | Contents |
|---|---|---|
LIVE NOW |
Whenever games are in progress | Live games sorted by score gap, with closeout (yellow ★) for series-leader winning, stay-alive (green ★) for trailer winning, G7 marker for 3-3 series |
RECENT |
Only when LIVE NOW is empty |
Completed games from the most recent slate, found by walking back through final games until a >6h gap. Rows older than 8h are dimmed. |
LATER TODAY |
Today's scheduled games not yet started | Time, league, round/game tag, matchup |
TOMORROW |
Tomorrow's scheduled games | Same format as LATER TODAY, with "if X wins" tag on G7s contingent on tonight's outcome |
| Per-league round sections | Always | Active series with score, next-game info, game-by-game winner sequence; completed series rolled into a single done: line per round |
| Environment variable | Effect |
|---|---|
NO_COLOR |
Disable ANSI colors (per no-color.org). Presence of the variable matters, regardless of value. |
FORCE_COLOR |
Force colors even when stdout isn't a TTY. Useful for piping through less -R. |
Pipeline: gather → normalize → group → render.
- gather: Two parallel HTTPS requests to ESPN's public scoreboard endpoint, one per league, each using a date-range query covering the entire playoff window. Replaces what would otherwise be ~50 single-day requests.
- normalize: Translate ESPN's
"Lakers/Rockets"placeholder strings (used for unresolved Round 2+ slots) to"LAL/HOU"style. Once a Round 1 series is decided, resolve the placeholder to the actual winner so duplicate Round 2 entries collapse into one series. All lookups are league-keyed – NHL and NBA share team abbreviations (DAL, MIN, BOS) and nicknames (both leagues have a Kings). - group: Bucket games by
(league, frozenset({home, away}))to recover series. Sort each series by date. - render: Print sections with column-aligned formatting. Auto-hide rules (LIVE NOW takes priority over RECENT) make the dashboard adaptive without per-section configuration.
Total runtime: ~500ms.
- Derived state over reported state. ESPN's
series.completedflag can lag by hours after the deciding game ends. The dashboard computes done-state from the win count (max(wins) >= 4) instead. When the API and the data disagree, the data wins. - Range queries instead of caching. Two range queries replace single-day fetching. Disk caching was prototyped and removed once the underlying volume dropped 24x; the access pattern itself was the optimization.
- Score-gap sort in LIVE NOW.
abs(home_score - away_score)is a direct proxy for "should I tune in?" – a 1-point game with 4 minutes left ranks above a 20-point blowout, regardless of game number or start time. - Boundary validation.
_is_validruns twice ingather()– once before normalize (dropsTBD vs TBDplaceholders) and once after (defends against any same-team edge case introduced by placeholder resolution). Validate at the seam, trust the room. - Drops have a witness. Unclassifiable games are dropped by design (play-in games and regular-season leakage carry no usable headline) – but a non-empty headline that fails to classify warns on stderr first, deduped per series stage. A classifier gap once erased the entire conference-finals round from both leagues with no visible symptom; the warning makes the next ESPN vocabulary drift announce itself.
From the repo root:
python3 -m pytest tests/Tests cover the logic helpers (_safe_int, count_wins, is_series_done, round_num, fmt_state, elim_status_live, normalize_placeholders, find_recent_games, contingent_tag, parse, fmt_when). No network access; all tests use synthetic game dicts.
The suite has caught one real bug that production data was masking: round_num returning 3 for "Conference Semifinals" because "semifinals" contains "final". ESPN happens to use "1st Round" / "2nd Round" naming so the bug was dormant; the test forced the issue.
The failure also flows the other way: the round tests originally asserted invented labels like "Eastern Conference Finals" – which ESPN never sends – so the suite stayed green while every real conference-finals game ("East Final", "West Finals") fell through the classifier and silently vanished from the bracket. The round_num tests now assert the verbatim headline vocabulary captured from the 2024–2026 seasons, and gather() warns on stderr when a non-empty headline fails to classify. Fixtures mirror the wire; drops have a witness.
playoffs/
├── playoffs.py # main script, stdlib only
├── tests/
│ ├── conftest.py
│ └── test_playoffs.py
├── README.md
├── LICENSE
└── .gitignore
PLAYOFF_START = date(2026, 4, 15)is hardcoded. Update for future seasons or compute from the calendar.- Times use the system's local timezone via
datetime.astimezone()and%Zstrftime. If your terminal lies about its locale, times will too. - No offline mode – a full ESPN outage produces "ESPN unreachable"; a single-league failure still renders the surviving league, under a red "partial view" warning on stdout.
- WNBA / college basketball / NFL playoffs not supported. Adding them is one entry in the
LEAGUESconstant plus verification that ESPN uses the same JSON shape.
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard?dates={range}&seasontype=3
sport / league:hockey / nhl,basketball / nbadates:YYYYMMDDfor one day,YYYYMMDD-YYYYMMDDfor a rangeseasontype=3: post-season (excludes regular season and exhibitions)
Per-event round and game number live at competitions[0].notes[0].headline (e.g., "East 1st Round - Game 6"). Series state lives at competitions[0].series.summary but the score-derived computation is more reliable.
Observed headline vocabulary (verbatim, stable across the 2024–2026 seasons):
| Round | NHL | NBA |
|---|---|---|
| 1 | East/West 1st Round - Game N |
East/West 1st Round - Game N |
| 2 | East/West 2nd Round - Game N |
East/West Semifinals - Game N |
| 3 | East/West Final - Game N |
East/West Finals - Game N |
| 4 | Stanley Cup Final - Game N |
NBA Finals - Game N |
Note that "Conference" never appears. Unplayed deciding games carry an If Necessary suffix. Known drops (classified to round 0, no warning): NBA Play-In - ... labels, and the empty headlines on regular-season games that leak into the date window.
MIT. See LICENSE.