Terminal Refresh + Past/Upcoming Scores Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the terminal TUI's refresh actually re-fetch upstream data, and show previous-day results + today/upcoming games (with live updates) in both the TUI and the printed morning paper — reusing the sportsball TUI's league windows and favorites.
Architecture: Data comes from the same ESPN public scoreboard API already in use. The
sports source module widens its fetch to per-league day windows (mirrored from sportsball).
The TUI sends the fresh/force query params the server already honors, adds a 60s
sports-only live poll while a game is in progress, and on startup syncs favorite teams from
~/.config/sportsball/config.json. Both the TUI and /print bucket games into
FINAL / TODAY / UPCOMING (paper omits UPCOMING).
Tech Stack: Next.js 16 (App Router) + TypeScript + Zod + Drizzle/SQLite (server);
Python 3.13 + Textual + httpx (tui/dashboard.py); ESPN public JSON API.
Global Constraints
- No test framework in this repo (CLAUDE.md: "No test suite. Verify with
pnpm typecheck- a manual run"). Every task's verification is
pnpm typecheckplus a targeted runtime check against a running server — not unit tests. Do not add a test runner.
- a manual run"). Every task's verification is
pnpm buildisnext build --webpack— never change to plain/Turbopack build. Not needed for these tasks (dev server suffices).- Components use
var(--token)inline styles, never hard-coded colors. - Server-side native deps (
better-sqlite3,puppeteer) stay inserverExternalPackages. Not touched here. - The webapp
components/cards/CardBodies.tsxSportsBodyis out of scope — leave it; the widened payload flows through its existing "Live/Final" bucket harmlessly. - Run the dev server for verification with:
pnpm dev(:4317). Leave it running across tasks in a separate shell.
File Structure
lib/schemas/sources/sports.ts— Modify. DropdaysAheadfromSportsConfig(payload unchanged).lib/sources/modules/sports.ts— Modify. Add per-league window catalog; fetch each league across-back … +forwarddays instead of a flatdaysAheadrange.app/print/page.tsx— Modify. Replace the raw group-by-league sports renderer (case "sports", ~line 286) with FINAL + TODAY bucketed sections.tui/dashboard.py— Modify. (a)fetch_sourcessendsfresh/force; (b) capture sports source id + live flag inrefresh_data; (c) rewriterender_sports/_game_bucketfor FINAL/TODAY/UPCOMING; (d) add 60spoll_sports_liveworker; (e) addsync_favoritesworker on mount; (f)import json.
Task 1: Sports module — per-league fetch windows
Files:
- Modify:
lib/schemas/sources/sports.ts - Modify:
lib/sources/modules/sports.ts
Interfaces:
-
Consumes: nothing new.
-
Produces:
SportsConfig = { leagues: string[]; teams: {league,team}[] }(nodaysAhead). Payload shape unchanged:games: { league, status, state, home, away, homeScore, awayScore, startTime }[], wherestate ∈ {"pre","in","post"} | null. Fetch now returns games from-back … +forwarddays per league. -
Step 1: Drop
daysAheadfrom the config schema
In lib/schemas/sources/sports.ts, remove the daysAhead field so SportsConfig is:
export const SportsConfig = z.object({
// Leagues to show ALL games for (e.g. "basketball/nba" → every playoff game).
leagues: z.array(z.string()).default(["basketball/nba"]),
// Favorite teams — only these teams' games are added, even from leagues not
// listed above. So you can keep all of one league + single teams from others.
teams: z.array(SportsTeam).default([]),
});
(Zod strips unknown keys on parse, so existing stored configs carrying daysAhead still
validate — the value is simply ignored.)
- Step 2: Add the per-league window catalog to the module
In lib/sources/modules/sports.ts, add just above export const sportsModule (after the
gameKey line):
// Per-league fetch windows (days back / forward), mirrored from the sportsball TUI's
// league catalog (~/Developer/Home/sportsball internal/model/league.go). Tuned to each
// sport's cadence so the last result and the next game both surface. Unknown leagues use
// a modest default.
const LEAGUE_WINDOWS: Record<string, { back: number; forward: number }> = {
"soccer/fifa.world": { back: 7, forward: 7 },
"baseball/mlb": { back: 2, forward: 3 },
"basketball/nba": { back: 3, forward: 4 },
"basketball/wnba": { back: 3, forward: 4 },
"hockey/nhl": { back: 3, forward: 4 },
"football/nfl": { back: 8, forward: 8 },
};
const DEFAULT_WINDOW = { back: 1, forward: 7 };
// YYYYMMDD strings spanning one league's window, oldest first.
function windowDates(league: string): string[] {
const w = LEAGUE_WINDOWS[league] ?? DEFAULT_WINDOW;
const out: string[] = [];
for (let off = -w.back; off <= w.forward; off++) {
const d = new Date();
d.setDate(d.getDate() + off);
out.push(d.toISOString().slice(0, 10).replace(/-/g, ""));
}
return out;
}
- Step 3: Use per-league windows in
fetch
In the same file, inside async fetch(...), delete the old flat-date block:
// Build YYYYMMDD strings for today + daysAhead days.
function dateStr(offsetDays: number): string {
const d = new Date();
d.setDate(d.getDate() + offsetDays);
return d.toISOString().slice(0, 10).replace(/-/g, "");
}
const dates = Array.from({ length: (config.daysAhead ?? 7) + 1 }, (_, i) => dateStr(i));
Then replace the await Promise.all([...]) call that references dates with:
await Promise.all([
...config.leagues.flatMap((l) => windowDates(l).map((d) => collect(l, d))),
...[...byLeague].flatMap(([l, teams]) => windowDates(l).map((d) => collect(l, d, teams))),
]);
Leave the rest of fetch (the collect helper, byLeague map, final sort) unchanged.
- Step 4: Typecheck
Run: pnpm typecheck
Expected: no errors. (If it flags config.daysAhead, you missed a reference in Step 3.)
- Step 5: Runtime check — yesterday's finals appear
With pnpm dev running, enable an in-season league and force a refresh, then confirm the
payload contains completed ("post") games dated before today:
# Grab the sports source id.
SID=$(curl -s localhost:4317/api/sources | python3 -c 'import sys,json;print(next(s["source"]["id"] for s in json.load(sys.stdin)["sources"] if s["source"]["kind"]=="sports"))')
# Point it at a league with recent games and enable it.
curl -s -X PATCH localhost:4317/api/sources/$SID -H 'content-type: application/json' \
-d '{"enabled":true,"config":{"leagues":["baseball/mlb"],"teams":[]}}' >/dev/null
# Force a fresh fetch and summarize game states + dates.
curl -s -X POST localhost:4317/api/sources/$SID/refresh | python3 -c '
import sys,json,datetime
g=json.load(sys.stdin)["source"]["snapshot"]["payload"]["games"]
states={}
for x in g: states[x["state"]]=states.get(x["state"],0)+1
print("states:",states)
print("has pre-today game:", any((x.get("startTime") or "")[:10] < datetime.date.today().isoformat() for x in g))
'
Expected: states includes some post (or the season is idle — try basketball/nba,
hockey/nhl depending on date); has pre-today game: True during an active season.
- Step 6: Commit
git add lib/schemas/sources/sports.ts lib/sources/modules/sports.ts
git commit -m "Sports: per-league fetch windows (sportsball parity), drop daysAhead"
Task 2: Morning paper — FINAL + TODAY sections
Files:
- Modify:
app/print/page.tsx(case "sports":, ~line 286)
Interfaces:
-
Consumes:
SportsPayload(Task 1).lib/edition-data.ts:70already clips the paper's sports games toyesterday..today, soupcomingis naturally empty here — the renderer still filters defensively. -
Produces: nothing consumed downstream.
-
Step 1: Replace the sports case
In app/print/page.tsx, replace the entire case "sports": { ... } block with:
case "sports": {
const p = payload as SportsPayload;
if (p.games.length === 0) return null;
type G = SportsPayload["games"][number];
const sameDay = (iso: string | null) =>
iso ? new Date(iso).toDateString() === new Date().toDateString() : true;
const bucketOf = (g: G): "final" | "today" | "upcoming" => {
if (g.state === "post") return "final";
if (g.state === "in") return "today";
return sameDay(g.startTime) ? "today" : "upcoming";
};
const line = (g: G) =>
g.state === "pre"
? `${g.away} @ ${g.home}`
: `${g.away} ${g.awayScore ?? ""} @ ${g.home} ${g.homeScore ?? ""}`.replace(/\s+/g, " ").trim();
// Paper = last results + today's slate only; no future games.
const sections = ([
{ key: "final", label: "Final" },
{ key: "today", label: "Today" },
] as const)
.map((s) => ({ ...s, games: p.games.filter((g) => bucketOf(g) === s.key) }))
.filter((s) => s.games.length > 0);
if (sections.length === 0) return null;
return (
<>
{sections.map((s) => {
const groups: Record<string, G[]> = {};
for (const g of s.games) (groups[g.league] ||= []).push(g);
return (
<div key={s.key}>
<h3>{s.label}</h3>
{Object.entries(groups).map(([league, games]) => (
<div key={league}>
<p className="paper-item"><strong>{leagueName(league)}</strong></p>
{games.map((g, i) => (
<p className="paper-item" key={i}>
{line(g)} <span className="src">— {g.status}</span>
</p>
))}
</div>
))}
</div>
);
})}
</>
);
}
- Step 2: Typecheck
Run: pnpm typecheck
Expected: no errors.
- Step 3: Runtime check — /print renders Final/Today, no Upcoming
With pnpm dev running and the sports source enabled from Task 1:
curl -s localhost:4317/print | grep -oE '<h3>(Final|Today|Upcoming)</h3>' | sort -u
Expected: <h3>Final</h3> and/or <h3>Today</h3>; no <h3>Upcoming</h3>.
- Step 4: Commit
git add app/print/page.tsx
git commit -m "Paper: bucket scores into Final + Today sections"
Task 3: TUI refresh sends fresh/force + captures sports state
Files:
- Modify:
tui/dashboard.py(fetch_sources,refresh_data,action_refresh, class attrs)
Interfaces:
-
Consumes: server
GET /api/sources?fresh=1|force=1(already implemented). -
Produces:
self._sports_id: str | None,self._sports_live: boolset on everyrefresh_data;refresh_data(force: bool = False). Consumed by Task 5. -
Step 1: Add
forcetofetch_sources
Replace the fetch_sources function (currently ~line 162) with:
async def fetch_sources(force: bool = False) -> list[dict]:
# Bare /api/sources returns cached snapshots only. fresh=1 refreshes stale
# sources first; force=1 refreshes every source. Without a param, pressing
# "r" would just re-read the same cache — the old refresh bug.
q = "force=1" if force else "fresh=1"
try:
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(f"{BASE_URL}/api/sources?{q}")
r.raise_for_status()
return r.json().get("sources", [])
except Exception:
return []
- Step 2: Add sports-state class attributes
Find the class attribute block containing _connected: bool = False (~line 1057) and add
below it:
_sports_id: str | None = None
_sports_live: bool = False
- Step 3: Thread
forcethroughrefresh_dataand capture sports state
Change the refresh_data signature (currently async def refresh_data(self) -> None:,
~line 1124) to:
async def refresh_data(self, force: bool = False) -> None:
sources = await fetch_sources(force)
(Delete the old sources = await fetch_sources() line it replaces.) Then, just before the
final self._connected = connected / self._update_subtitle() lines at the end of
refresh_data, insert:
sports_src = next(
(s for s in sources if (s.get("source") or {}).get("kind") == "sports"),
None,
)
if sports_src:
self._sports_id = (sports_src.get("source") or {}).get("id")
snap = sports_src.get("snapshot") or {}
games = (snap.get("payload") or {}).get("games", []) if snap.get("ok") else []
self._sports_live = any(g.get("state") == "in" for g in games)
else:
self._sports_id = None
self._sports_live = False
- Step 4: Manual
rforces a full refresh
Change action_refresh (~line 1406). Its current body calls self.refresh_data(); make
that call force:
def action_refresh(self) -> None:
self.refresh_data(force=True)
Leave any self.refresh_system() call in that method as-is.
- Step 5: Typecheck the Python parses/imports
Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')"
Expected: ok (syntax valid).
- Step 6: Runtime check — refresh actually re-fetches
Start the TUI (dashboard), note a timestamp-bearing panel (e.g. Markets or Weather), press
r, and confirm data updates. Independently confirm the server received a forced fetch:
# Before pressing r, capture a snapshot fetchedAt; after r, it should advance.
curl -s localhost:4317/api/sources | python3 -c 'import sys,json;print([ (s["source"]["kind"], (s.get("snapshot") or {}).get("fetchedAt")) for s in json.load(sys.stdin)["sources"] if s["source"]["kind"] in ("markets","weather")])'
Expected: fetchedAt values are recent (advance after a forced refresh). Quit the TUI with
q.
- Step 7: Commit
git add tui/dashboard.py
git commit -m "TUI: send fresh/force on refresh; capture sports source id + live flag"
Task 4: TUI render_sports — FINAL / TODAY / UPCOMING
Files:
- Modify:
tui/dashboard.py(_game_bucket~line 417,render_sports~line 434)
Interfaces:
-
Consumes: the sports payload (Task 1).
-
Produces:
render_sports(p: dict) -> Textgrouped FINAL/TODAY/UPCOMING. Consumed by Task 5. -
Step 1: Rewrite
_game_bucketto split finals out
Replace _game_bucket (~line 417) with:
def _game_bucket(g: dict) -> str:
state = g.get("state")
if state == "post":
return "final"
if state == "in":
return "today"
# pre or unknown — classify by date
st = g.get("startTime")
if st:
try:
d = to_local(st)
return "today" if d.date() == date.today() else "upcoming"
except Exception:
pass
return "today"
- Step 2: Rewrite
render_sportsbucket keys/headers + drop the 7-day filter
Replace the head of render_sports — the first line and the bucket/header setup — so it
reads (only the changed lines shown; the per-game label loop below stays identical):
def render_sports(p: dict) -> Text:
all_games = p.get("games", [])
if not all_games:
return Text("No games.", style="dim italic")
buckets: dict[str, list[dict]] = {"final": [], "today": [], "upcoming": []}
for g in all_games:
buckets[_game_bucket(g)].append(g)
HEADERS = {"final": "Final", "today": "Today", "upcoming": "Upcoming"}
t = Text()
first_section = True
for key in ("final", "today", "upcoming"):
Changes vs. the current code: all_games no longer filters through within_7_days (the
module now bounds each league's window, and NFL's forward=8 would be wrongly clipped by a
7-day filter); the buckets dict gains a "final" key; HEADERS maps final/today/upcoming;
the loop iterates ("final", "today", "upcoming"). Leave the entire per-game body (the
state == "in" / elif state == "post" / else label logic and the t.append(...) lines)
exactly as it is. Do not remove the within_7_days function — the calendar renderer still
uses it.
- Step 3: Syntax check
Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')"
Expected: ok.
- Step 4: Runtime check — three sections render
Run dashboard with an in-season league configured (Task 1). Confirm the Scores panel shows
a Final section with completed scores (123–120 F), a Today section, and Upcoming
with future dates. Quit with q.
- Step 5: Commit
git add tui/dashboard.py
git commit -m "TUI: render scores as Final / Today / Upcoming"
Task 5: TUI live poll — sports-only force refresh every 60s
Files:
- Modify:
tui/dashboard.py(on_mount~line 1073, newpoll_sports_liveworker)
Interfaces:
-
Consumes:
self._sports_id,self._sports_live(Task 3);render_sports(Task 4); serverPOST /api/sources/{id}/refresh. -
Produces: a 60s interval that force-refreshes only the sports source while a game is live.
-
Step 1: Add the live-poll worker
Add this method next to refresh_data in the app class (e.g. directly after refresh_data):
@work(exclusive=True, group="sports")
async def poll_sports_live(self) -> None:
# While any game is in progress, force-refresh ONLY the sports source so
# live scores tick without re-fetching every other source. Dormant when
# nothing is live.
if not (self._sports_live and self._sports_id):
return
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(f"{BASE_URL}/api/sources/{self._sports_id}/refresh")
r.raise_for_status()
state = r.json().get("source") or {}
except Exception:
return
snap = state.get("snapshot") or {}
p = snap.get("payload") if snap.get("ok") else None
self._q("#sports", DashPanel).set_content(
render_sports(p) if p else Text("No games.", style="dim italic")
)
games = (p or {}).get("games", [])
self._sports_live = any(g.get("state") == "in" for g in games)
- Step 2: Register the 60s timer in
on_mount
In on_mount, directly after the existing line self.set_interval(REFRESH_SECS, self.refresh_data)
(~line 1074), add:
self.set_interval(60, self.poll_sports_live)
- Step 3: Syntax check
Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')"
Expected: ok.
- Step 4: Runtime check — live scores update on their own
During a live game (or simulate by pointing the sports config at a league with a game in
progress), run dashboard and watch the Scores panel: a live game's score should update on
its own within ~60s, with no keypress. Confirm the server logs a repeated
POST /api/sources/{id}/refresh about once a minute. When no game is live, confirm it does
not keep hitting that endpoint. Quit with q.
- Step 5: Commit
git add tui/dashboard.py
git commit -m "TUI: 60s sports-only live poll while a game is in progress"
Task 6: TUI favorites sync from sportsball on startup
Files:
- Modify:
tui/dashboard.py(import json;SPORTSBALL_LEAGUE_PATHconst;sync_favoritesworker;on_mountcall)
Interfaces:
-
Consumes:
~/.config/sportsball/config.json(favorites: [{league,id,abbr,name}]); serverGET /api/sources,PATCH /api/sources/{id};refresh_data(Task 3). -
Produces: one-time PATCH of the sports source
config.teams(+enabled: true) on app open. -
Step 1: Import json
In the import block near the top of tui/dashboard.py, add import json (alongside
import re, import shlex, etc.).
- Step 2: Add the league-key → ESPN-path map
Add near the top-level constants (e.g. just after REPO = Path(__file__).parent.parent):
# sportsball (~/.config/sportsball/config.json) league keys → ESPN sport/league
# paths used by this dashboard's sports source. Mirrors sportsball's league catalog.
SPORTSBALL_LEAGUE_PATH = {
"worldcup": "soccer/fifa.world",
"mlb": "baseball/mlb",
"nba": "basketball/nba",
"wnba": "basketball/wnba",
"nhl": "hockey/nhl",
"nfl": "football/nfl",
}
- Step 3: Add the
sync_favoritesworker
Add this method to the app class (e.g. after poll_sports_live):
@work(exclusive=True, group="favsync")
async def sync_favorites(self) -> None:
# On app open, mirror sportsball's favorite teams into this dashboard's
# sports source config (once — not per fetch). Silently no-op if the file
# is absent, has no favorites, or the server is unreachable.
cfg = Path.home() / ".config" / "sportsball" / "config.json"
try:
favs = json.loads(cfg.read_text()).get("favorites") or []
except Exception:
return
teams = []
for f in favs:
path = SPORTSBALL_LEAGUE_PATH.get(f.get("league"))
team = f.get("abbr") or f.get("name")
if path and team:
teams.append({"league": path, "team": team})
if not teams:
return
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(f"{BASE_URL}/api/sources")
r.raise_for_status()
src = next(
(s for s in r.json().get("sources", [])
if (s.get("source") or {}).get("kind") == "sports"),
None,
)
if not src:
return
sid = (src.get("source") or {}).get("id")
cur = (src.get("source") or {}).get("config") or {}
await c.patch(
f"{BASE_URL}/api/sources/{sid}",
json={"enabled": True, "config": {**cur, "teams": teams}},
)
except Exception:
return
self.refresh_data()
- Step 4: Call it once from
on_mount
In on_mount, directly after the self.set_interval(60, self.poll_sports_live) line added
in Task 5, add:
self.sync_favorites()
- Step 5: Syntax check
Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')"
Expected: ok.
- Step 6: Runtime check — favorites land in the config
Ensure ~/.config/sportsball/config.json has favorites (it does: Cubs, Sky, USA, Brazil…).
Start dashboard, wait a few seconds, quit, then inspect the persisted config:
curl -s localhost:4317/api/sources | python3 -c '
import sys,json
s=next(s for s in json.load(sys.stdin)["sources"] if s["source"]["kind"]=="sports")
print("enabled:", s["source"]["enabled"])
print("teams:", s["source"]["config"].get("teams"))
'
Expected: enabled: True; teams includes e.g. {"league":"baseball/mlb","team":"CHC"},
{"league":"basketball/wnba","team":"CHI"}, {"league":"soccer/fifa.world","team":"USA"}.
- Step 7: Commit
git add tui/dashboard.py
git commit -m "TUI: sync sportsball favorites into sports source on startup"
Self-Review
Spec coverage:
- Working TUI refresh → Task 3 (fresh/force param). ✓
- Previous-day scores fetched → Task 1 (per-league back windows). ✓
- Today + upcoming games → Task 1 (forward windows) + Task 4 (buckets). ✓
- Live score updates in TUI → Task 5 (60s sports-only poll). ✓
- FINAL/TODAY/UPCOMING in TUI → Task 4; paper FINAL+TODAY only → Task 2. ✓
- sportsball parity (same source, league windows) → Task 1. ✓
- Favorites synced once on open → Task 6. ✓
edition-data.ts:70left as-is (already clips yesterday..today) → noted in Task 2. ✓
Placeholder scan: none — every step has concrete code/commands.
Type consistency: SportsConfig loses daysAhead in Task 1; Task 6 PATCHes
config.teams (matches SportsTeam = {league, team}). self._sports_id / self._sports_live
defined in Task 3, consumed in Task 5. render_sports (Task 4) called from Task 5. Bucket
keys final/today/upcoming consistent across _game_bucket, render_sports, and the
print renderer. ✓