# 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 typecheck` plus a targeted runtime check against a running server — not unit tests. Do not add a test runner. - **`pnpm build` is `next 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 in `serverExternalPackages`. Not touched here. - The webapp `components/cards/CardBodies.tsx` `SportsBody` is **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.** Drop `daysAhead` from `SportsConfig` (payload unchanged). - `lib/sources/modules/sports.ts` — **Modify.** Add per-league window catalog; fetch each league across `-back … +forward` days instead of a flat `daysAhead` range. - `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_sources` sends `fresh`/`force`; (b) capture sports source id + live flag in `refresh_data`; (c) rewrite `render_sports`/`_game_bucket` for FINAL/TODAY/UPCOMING; (d) add 60s `poll_sports_live` worker; (e) add `sync_favorites` worker 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}[] }` (no `daysAhead`). Payload shape unchanged: `games: { league, status, state, home, away, homeScore, awayScore, startTime }[]`, where `state ∈ {"pre","in","post"} | null`. Fetch now returns games from `-back … +forward` days per league. - [ ] **Step 1: Drop `daysAhead` from the config schema** In `lib/schemas/sources/sports.ts`, remove the `daysAhead` field so `SportsConfig` is: ```ts 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): ```ts // 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 = { "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: ```ts // 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: ```ts 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: ```bash # 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** ```bash 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:70` already clips the paper's sports games to `yesterday..today`, so `upcoming` is 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: ```tsx 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 = {}; for (const g of s.games) (groups[g.league] ||= []).push(g); return (

{s.label}

{Object.entries(groups).map(([league, games]) => (

{leagueName(league)}

{games.map((g, i) => (

{line(g)} — {g.status}

))}
))}
); })} ); } ``` - [ ] **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: ```bash curl -s localhost:4317/print | grep -oE '

(Final|Today|Upcoming)

' | sort -u ``` Expected: `

Final

` and/or `

Today

`; **no** `

Upcoming

`. - [ ] **Step 4: Commit** ```bash 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: bool` set on every `refresh_data`; `refresh_data(force: bool = False)`. Consumed by Task 5. - [ ] **Step 1: Add `force` to `fetch_sources`** Replace the `fetch_sources` function (currently ~line 162) with: ```python 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: ```python _sports_id: str | None = None _sports_live: bool = False ``` - [ ] **Step 3: Thread `force` through `refresh_data` and capture sports state** Change the `refresh_data` signature (currently `async def refresh_data(self) -> None:`, ~line 1124) to: ```python 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: ```python 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 `r` forces a full refresh** Change `action_refresh` (~line 1406). Its current body calls `self.refresh_data()`; make that call force: ```python 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: ```bash # 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** ```bash 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) -> Text` grouped FINAL/TODAY/UPCOMING. Consumed by Task 5. - [ ] **Step 1: Rewrite `_game_bucket` to split finals out** Replace `_game_bucket` (~line 417) with: ```python 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_sports` bucket 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): ```python 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** ```bash 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, new `poll_sports_live` worker) **Interfaces:** - Consumes: `self._sports_id`, `self._sports_live` (Task 3); `render_sports` (Task 4); server `POST /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`): ```python @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: ```python 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** ```bash 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_PATH` const; `sync_favorites` worker; `on_mount` call) **Interfaces:** - Consumes: `~/.config/sportsball/config.json` (`favorites: [{league,id,abbr,name}]`); server `GET /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`): ```python # 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_favorites` worker** Add this method to the app class (e.g. after `poll_sports_live`): ```python @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: ```python 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: ```bash 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** ```bash 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:70` left 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. ✓