Design — Working terminal refresh + past/upcoming scores
Date: 2026-07-08 Status: approved (pending spec review)
Context
The first-class surfaces of this project are the terminal TUI (tui/dashboard.py,
Textual/Python, launched via the dashboard command → uv run) and the morning paper
(morning-paper script → POST /api/edition → puppeteer renders /print to a PDF). The
Next.js webapp at :4317 is effectively the backend: it serves /api/* (data) and /print
(paper source). The interactive webapp UI is not a priority.
Two problems motivate this work:
-
TUI refresh doesn't work.
fetch_sources()calls bareGET /api/sources, which returns cached snapshots only (currentState). The server supports?fresh=1(refresh stale sources) and?force=1(refresh all), but the TUI never sends them — so pressingrjust re-reads the same cache. Root cause: missing query param. -
No previous-day scores, and no live updates.
lib/sources/modules/sports.tsfetches only today →+daysAhead(never yesterday).lib/edition-data.ts:70already wantsyesterday..todayfor the paper but the data was never fetched. The sports source also refreshes every 600s, so live scores never move in the TUI within a game.
Data source finding
The user's separate sportsball TUI (~/Developer/Home/sportsball, Go/Bubble Tea) keeps
live scores fresh. Investigation: it pulls the same ESPN public API the dashboard already
uses — https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard
(internal/espn/client.go:19). There is no better data to sync to, and sportsball has no API
or persisted score store (config.json holds only favorites) — nothing to sync from. So we
borrow its techniques, staying independent:
-
Flat ~15s poll while live (
commands.go:62) — "live scores feel fresh without hammering ESPN"; one scoreboard call per league per tick is cheap. -
Per-league past/future windows (
internal/model/league.go:46-51), tuned to each sport's cadence:League ESPN path ( sport/league)back fwd World Cup soccer/fifa.world7 7 MLB baseball/mlb2 3 NBA basketball/nba3 4 WNBA basketball/wnba3 4 NHL hockey/nhl3 4 NFL football/nfl8 8 -
Favorites live in
~/.config/sportsball/config.jsonasfavorites: [{league, id, abbr, name}]. We reuse these so the user doesn't re-enter teams.
Decisions
- Scores window: per-league (sportsball catalog), superseding an earlier flat "yesterday-only". Better for weekly sports (NFL needs ~8 days back to show the last game).
- Layout: FINAL / TODAY / UPCOMING sections, grouped by league within each. Shared shape across TUI and paper.
- Paper shows FINAL + TODAY only (no UPCOMING).
- Live cadence: force-refresh sports only every 60s while a game is live; otherwise dormant (normal cadence). No overnight hammering.
- Favorites sync: once, on TUI open — not per fetch. Persisted to the sports source config in the DB, so the 6am paper LaunchAgent uses the last-synced favorites.
Changes
1. TUI refresh (tui/dashboard.py)
fetch_sources(force=False): request/api/sources?force=1on manualr,?fresh=1on the periodic timer. The server already does the upstream re-fetch behind those params.
2. Sports module + schema (lib/sources/modules/sports.ts, lib/schemas/sources/sports.ts)
- Add a league catalog map: ESPN
sport/leaguepath +back/forwarddays, mirrored from sportsball'sleague.go(table above). - Fetch each configured league across
now-back … now+forward(via the existing per-datecollect()), replacing the flatdaysAhead/ proposeddaysBack. - Payload shape (
league, status, state, home, away, homeScore, awayScore, startTime) is unchanged; only the date range widens.statealready distinguishespre/in/post. - Config still supports explicit
leagues(all games) +teams(favorite filters); windows come from the catalog keyed by league path.
3. Favorites sync on TUI open (tui/dashboard.py)
- In
on_mount(after the server is confirmed up): read~/.config/sportsball/config.json; map each favorite via a small league-key→ESPN-path dict (mlb→baseball/mlb,nba→basketball/nba,wnba→basketball/wnba,nhl→hockey/nhl,nfl→football/nfl,worldcup→soccer/fifa.world) into dashboardteamsentries ({league: <espnPath>, team: <abbr or name>}), thenPATCH /api/sources/{sportsId}once with the updated config. - Skip silently if the file is absent or the server is unreachable. Leave
leaguesuntouched. - The per-league window catalog stays only in the TS module; the TUI mirrors just the 6-entry path map (small, stable).
4. Shared bucketing — FINAL / TODAY / UPCOMING
state=="post"→ FINAL (show score);state=="in"or (prestarting today) → TODAY (live games highlighted);prestarting after today → UPCOMING. Group by league within each section, chronological.
5. TUI render_sports + live poll (tui/dashboard.py)
- Replace the current
live/today/upcomingbuckets (which mislabel finals) with FINAL/TODAY/UPCOMING per §4. - Track the sports source id and
_sports_live(any gamestate=="in"). A 60s timer: when_sports_live,POST /api/sources/{id}/refresh(force-refresh sports only), then re-render just the Scores panel from the response. Dormant otherwise.
6. Paper (app/print/page.tsx)
- Replace the raw group-by-league dump with FINAL + TODAY sections (§4 bucketing), no
UPCOMING.
lib/edition-data.ts:70already clips sports tostartYesterday..endToday— leave as-is.
Out of scope
- Webapp
SportsBody(components/cards/CardBodies.tsx) — untouched; the wider payload flows through harmlessly (yesterday finals land in its existing "Live/Final" bucket). - No coupling to the sportsball binary; no export code added there.
Verification
pnpm typecheck.- Run
dashboard, pressr: snapshots update (not cache); during a live game, scores tick ~every 60s; FINAL shows yesterday's results, UPCOMING shows future games. - Confirm favorites appeared: check the sports source config after opening the TUI.
- Run
morning-paper(no--print) and eyeball the PDF's Scores section: FINAL + TODAY only.