Add implementation plan: terminal refresh + past/upcoming scores
59a52605decd3570cf5cafccfcfc26f42f004ae2
humdrum <me@humdrum.me> · 2026-07-08 15:48
parent 8efd8313
Add implementation plan: terminal refresh + past/upcoming scores Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 files changed
docs/superpowers/plans/2026-07-08-terminal-refresh-and-scores.md +656 −0
@@ -0,0 +1,656 @@
+# 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<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:
+
+```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<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:
+
+```bash
+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**
+
+```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. ✓