▍ humdrum codex / soft
24.3 KB raw

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


File Structure


Task 1: Sports module — per-league fetch windows

Files:

Interfaces:

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.)

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;
}

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.

Run: pnpm typecheck Expected: no errors. (If it flags config.daysAhead, you missed a reference in Step 3.)

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.

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:

Interfaces:

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>
            );
          })}
        </>
      );
    }

Run: pnpm typecheck Expected: no errors.

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>.

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:

Interfaces:

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 []

Find the class attribute block containing _connected: bool = False (~line 1057) and add below it:

    _sports_id: str | None = None
    _sports_live: bool = False

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

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.

Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')" Expected: ok (syntax valid).

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.

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:

Interfaces:

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"

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.

Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')" Expected: ok.

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.

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:

Interfaces:

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)

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)

Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')" Expected: ok.

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.

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:

Interfaces:

In the import block near the top of tui/dashboard.py, add import json (alongside import re, import shlex, etc.).

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",
}

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()

In on_mount, directly after the self.set_interval(60, self.poll_sports_live) line added in Task 5, add:

        self.sync_favorites()

Run: python3 -c "import ast; ast.parse(open('tui/dashboard.py').read()); print('ok')" Expected: ok.

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"}.

git add tui/dashboard.py
git commit -m "TUI: sync sportsball favorites into sports source on startup"

Self-Review

Spec coverage:

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. ✓