▍ humdrum codex / soft

Merge terminal-refresh-scores: working TUI refresh + past/upcoming scores

b0dc1c0fdc33b1720a314179de1031cac26b03e0
humdrum <me@humdrum.me> · 2026-07-08 16:34

parent fb1e47ac

parent 02199dd6

Merge terminal-refresh-scores: working TUI refresh + past/upcoming scores

- Sports module: per-league ESPN fetch windows (yesterday..forward), drop daysAhead
- Paper /print: Final + Today score sections
- TUI: fresh/force refresh (fixes refresh bug), Final/Today/Upcoming buckets,
  60s live poll while a game is in progress, favorites sync from sportsball on open

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

8 files changed

app/print/page.tsx +39 −13
@@ -286,21 +286,47 @@     }
     case "sports": {
       const p = payload as SportsPayload;
       if (p.games.length === 0) return null;
-      const groups: Record<string, SportsPayload["games"]> = {};
-      for (const g of p.games) (groups[g.league] ||= []).push(g);
+      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 (
         <>
-          {Object.entries(groups).map(([league, games]) => (
-            <div key={league}>
-              <h3>{leagueName(league)}</h3>
-              {games.map((g, i) => (
-                <p className="paper-item" key={i}>
-                  {g.away} {g.awayScore ?? ""} @ {g.home} {g.homeScore ?? ""}{" "}
-                  <span className="src">— {g.status}</span>
-                </p>
-              ))}
-            </div>
-          ))}
+          {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>
+            );
+          })}
         </>
       );
     }
- → Terminal-TUI-refresh-does-not-force-upstream-fetch.md +23 −0
@@ -0,0 +1,23 @@
+---
+id: TASK-034
+title: Terminal TUI refresh does not force upstream fetch
+status: To Do
+assignee: []
+created_date: '2026-07-08 23:02'
+labels:
+  - bug
+dependencies: []
+priority: high
+ordinal: 34000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+fetch_sources() calls bare GET /api/sources = cache only. Server honors ?fresh=1/?force=1 but TUI never sends them, so pressing r re-reads same cache. Fix: send params.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Manual r sends force=1; periodic timer sends fresh=1; snapshots actually update
+<!-- AC:END -->
- → Show-previous-day-scores-today-upcoming-games-in-TUI-and-paper.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-035
+title: Show previous-day scores + today/upcoming games in TUI and paper
+status: To Do
+assignee: []
+created_date: '2026-07-08 23:02'
+labels:
+  - feature
+dependencies: []
+priority: high
+ordinal: 35000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+sports module fetches today..+daysAhead only (never yesterday). Add per-league past/future windows (from sportsball catalog), FINAL/TODAY/UPCOMING bucketing (paper=FINAL+TODAY only), 60s live poll in TUI, favorites sync from ~/.config/sportsball/config.json on TUI open.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Sports module fetches per-league windows incl yesterday
+- [ ] #2 Paper shows FINAL+TODAY sections
+- [ ] #3 TUI shows FINAL/TODAY/UPCOMING and live-polls 60s while a game is in progress
+- [ ] #4 Favorites synced from sportsball config on TUI open
+<!-- AC:END -->
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. ✓
docs/superpowers/specs/2026-07-08-terminal-refresh-and-scores-design.md +118 −0
@@ -0,0 +1,118 @@
+# 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:
+
+1. **TUI refresh doesn't work.** `fetch_sources()` calls bare `GET /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 pressing
+   `r` just re-reads the same cache. Root cause: missing query param.
+
+2. **No previous-day scores, and no live updates.** `lib/sources/modules/sports.ts` fetches
+   only today → `+daysAhead` (never yesterday). `lib/edition-data.ts:70` already *wants*
+   `yesterday..today` for 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.world` | 7 | 7 |
+  | MLB | `baseball/mlb` | 2 | 3 |
+  | NBA | `basketball/nba` | 3 | 4 |
+  | WNBA | `basketball/wnba` | 3 | 4 |
+  | NHL | `hockey/nhl` | 3 | 4 |
+  | NFL | `football/nfl` | 8 | 8 |
+
+- **Favorites** live in `~/.config/sportsball/config.json` as
+  `favorites: [{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=1` on manual `r`, `?fresh=1` on
+  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/league` path + `back`/`forward` days, mirrored
+  from sportsball's `league.go` (table above).
+- Fetch each configured league across `now-back … now+forward` (via the existing per-date
+  `collect()`), replacing the flat `daysAhead` / proposed `daysBack`.
+- Payload shape (`league, status, state, home, away, homeScore, awayScore, startTime`) is
+  unchanged; only the date range widens. `state` already distinguishes `pre`/`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 dashboard `teams` entries
+  (`{league: <espnPath>, team: <abbr or name>}`), then `PATCH /api/sources/{sportsId}` once
+  with the updated config.
+- Skip silently if the file is absent or the server is unreachable. Leave `leagues` untouched.
+- 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 (`pre` starting today) → **TODAY**
+  (live games highlighted); `pre` starting after today → **UPCOMING**. Group by league within
+  each section, chronological.
+
+### 5. TUI `render_sports` + live poll (`tui/dashboard.py`)
+- Replace the current `live/today/upcoming` buckets (which mislabel finals) with
+  FINAL/TODAY/UPCOMING per §4.
+- Track the sports source id and `_sports_live` (any game `state=="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:70` already clips sports to `startYesterday..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`, press `r`: 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.
lib/schemas/sources/sports.ts +0 −2
@@ -12,8 +12,6 @@   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([]),
-  // How many days ahead to fetch schedules (0 = today only).
-  daysAhead: z.number().int().min(0).max(14).default(7),
 });
 export type SportsConfig = z.infer<typeof SportsConfig>;
 
lib/sources/modules/sports.ts +28 −10
@@ -21,6 +21,32 @@ }
 
 const gameKey = (g: Game) => `${g.league}|${g.away}|${g.home}|${g.startTime ?? ""}`;
 
+// 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;
+}
+
 export const sportsModule: SourceModule<Config, Payload> = {
   kind: "sports",
   label: "Scores",
@@ -33,14 +59,6 @@   isConfigured: (config) => config.leagues.length > 0 || config.teams.length > 0,
 
   async fetch({ config, signal }) {
     const collected = new Map<string, Game>();
-
-    // 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));
 
     // Fetch one league's scoreboard for a given date. If teamFilter is given,
     // keep only games whose home/away matches one of those team substrings.
@@ -84,8 +102,8 @@       byLeague.set(league, [...(byLeague.get(league) ?? []), team]);
     }
 
     await Promise.all([
-      ...config.leagues.flatMap((l) => dates.map((d) => collect(l, d))),
-      ...[...byLeague].flatMap(([l, teams]) => dates.map((d) => collect(l, d, teams))),
+      ...config.leagues.flatMap((l) => windowDates(l).map((d) => collect(l, d))),
+      ...[...byLeague].flatMap(([l, teams]) => windowDates(l).map((d) => collect(l, d, teams))),
     ]);
 
     const games = [...collected.values()].sort(
tui/dashboard.py +108 −13
@@ -26,6 +26,7 @@ in the terminal (dash accepts a key argument to skip its gum menu).
 """
 from __future__ import annotations
 
+import json
 import re
 import shlex
 import subprocess
@@ -54,6 +55,17 @@ BASE_URL = "http://localhost:4317"
 REFRESH_SECS = 30
 SYS_REFRESH_SECS = 2.5
 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",
+}
 
 PAGES = [
     ("page-personal", "Personal"),
@@ -159,10 +171,14 @@
 
 # ── Fetching ──────────────────────────────────────────────────────────────────
 
-async def fetch_sources() -> list[dict]:
+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=15) as c:
-            r = await c.get(f"{BASE_URL}/api/sources")
+        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:
@@ -416,10 +432,10 @@
 
 def _game_bucket(g: dict) -> str:
     state = g.get("state")
-    if state == "in":
-        return "live"
     if state == "post":
-        return "live"
+        return "final"
+    if state == "in":
+        return "today"
     # pre or unknown — classify by date
     st = g.get("startTime")
     if st:
@@ -432,19 +448,19 @@     return "today"
 
 
 def render_sports(p: dict) -> Text:
-    all_games = [g for g in p.get("games", []) if within_7_days(g.get("startTime"))]
+    all_games = p.get("games", [])
     if not all_games:
         return Text("No games.", style="dim italic")
 
-    buckets: dict[str, list[dict]] = {"live": [], "today": [], "upcoming": []}
+    buckets: dict[str, list[dict]] = {"final": [], "today": [], "upcoming": []}
     for g in all_games:
         buckets[_game_bucket(g)].append(g)
 
-    HEADERS = {"live": "Live / Final", "today": "Today", "upcoming": "Upcoming"}
+    HEADERS = {"final": "Final", "today": "Today", "upcoming": "Upcoming"}
     t = Text()
     first_section = True
 
-    for key in ("live", "today", "upcoming"):
+    for key in ("final", "today", "upcoming"):
         games = buckets[key]
         if not games:
             continue
@@ -1051,6 +1067,8 @@
     _theme_idx: int = 0
     _page_id: str = "page-personal"
     _connected: bool = False
+    _sports_id: str | None = None
+    _sports_live: bool = False
 
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
@@ -1072,6 +1090,8 @@         table.cursor_type = "none"
 
         self.refresh_data()
         self.set_interval(REFRESH_SECS, self.refresh_data)
+        self.set_interval(60, self.poll_sports_live)
+        self.sync_favorites()
 
     def _q(self, selector: str, expect_type):
         """query_one against the main screen — app.query_one resolves against
@@ -1121,8 +1141,8 @@         yield CommandBar(id="cmd-bar")
         yield Footer()
 
     @work(exclusive=True)
-    async def refresh_data(self) -> None:
-        sources = await fetch_sources()
+    async def refresh_data(self, force: bool = False) -> None:
+        sources = await fetch_sources(force)
         connected = bool(sources)
         no_data = Text("No data.", style="dim italic")
 
@@ -1174,9 +1194,84 @@         )
         panel("nowplaying").set_content(
             render_nowplaying(payload(sources, "cider"))
         )
+        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
         self._connected = connected
         self._update_subtitle()
 
+    @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)
+
+    @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:
+            if not isinstance(f, dict):
+                continue
+            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(force=True)
+
     @work(exclusive=True, thread=True, group="sys")
     def refresh_system(self) -> None:
         stats = self._sys.sample()
@@ -1404,7 +1499,7 @@
     # ── Actions ──────────────────────────────────────────────────────────────
 
     def action_refresh(self) -> None:
-        self.refresh_data()
+        self.refresh_data(force=True)
         if self._page_id == "page-system":
             self.refresh_system()