▍ humdrum codex / soft

Merge scores-now-focus: now-focused favorites tiering + layout cleanup

816c2461a8e246edbdb603654ce129a4701ea78f
humdrum <me@humdrum.me> · 2026-07-08 17:05

parent b0dc1c0f

parent 72967852

Merge scores-now-focus: now-focused favorites tiering + layout cleanup

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

7 files changed

app/print/page.tsx +17 −14
@@ -289,28 +289,31 @@       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";
+      // Order within a tier: yesterday's finals, then today's/live, then upcoming.
+      const rank = (g: G): number => {
+        if (g.state === "post") return 0;
+        if (g.state === "in") return 1;
+        return sameDay(g.startTime) ? 1 : 2;
       };
       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;
+      // The module already scopes to yesterday + today (+ each favorite's next
+      // game); split into Favorites vs Leagues, matching the TUI.
+      const tiers = ([
+        { key: "fav", label: "Favorites", games: p.games.filter((g) => g.favorite) },
+        { key: "lg", label: "Leagues", games: p.games.filter((g) => !g.favorite) },
+      ] as const).filter((s) => s.games.length > 0);
+      if (tiers.length === 0) return null;
       return (
         <>
-          {sections.map((s) => {
+          {tiers.map((s) => {
             const groups: Record<string, G[]> = {};
-            for (const g of s.games) (groups[g.league] ||= []).push(g);
+            const ordered = [...s.games].sort(
+              (a, b) => rank(a) - rank(b) || (a.startTime ?? "").localeCompare(b.startTime ?? ""),
+            );
+            for (const g of ordered) (groups[g.league] ||= []).push(g);
             return (
               <div key={s.key}>
                 <h3>{s.label}</h3>
- → Scores-now-focused-favorites-tiered-personal-page-layout-cleanup.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-036
+title: 'Scores: now-focused + favorites-tiered; personal-page layout cleanup'
+status: To Do
+assignee: []
+created_date: '2026-07-08 23:55'
+labels:
+  - feature
+dependencies: []
+priority: high
+ordinal: 36000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Scores too broad/multi-day. Narrow to yesterday+today (drop per-league back-windows), tag favorite games, render two tiers: Favorites (yesterday finals, today, else next game) then Leagues (yesterday, today). Match paper to TUI. Layout: kill Now Playing, move Markets to row 1, drop Feeds from personal page, widen Scores. Default leagues empty.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Fetch window yesterday..today (+forward only for next-game lookup)
+- [ ] #2 Games tagged favorite when involving a config team
+- [ ] #3 TUI+paper render Favorites then Leagues, each yesterday-finals+today; favorite w/ no game today shows next game
+- [ ] #4 Personal page: Now Playing removed, Markets in row 1, Feeds removed, Scores widened
+<!-- AC:END -->
lib/edition-data.ts +3 −4
@@ -10,7 +10,6 @@ import type { WeatherPayload } from "@/lib/schemas/sources/weather";
 import type { SourceKind } from "@/lib/schemas/source";
 import type { NewsPayload } from "@/lib/schemas/sources/news";
 import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
-import type { SportsPayload } from "@/lib/schemas/sources/sports";
 import type { GitHubPayload } from "@/lib/schemas/sources/github";
 import type { VercelPayload } from "@/lib/schemas/sources/vercel";
 import type { MastodonPayload } from "@/lib/schemas/sources/mastodon";
@@ -65,9 +64,9 @@       const p = payload as CalendarPayload;
       return { ...p, events: p.events.filter((e) => inWindow(e.start, startToday, endToday, false)) };
     }
     case "sports": {
-      // Last night's finals + today's games. Undated games kept (may be live).
-      const p = payload as SportsPayload;
-      return { ...p, games: p.games.filter((g) => inWindow(g.startTime, startYesterday, endToday, true)) };
+      // The sports module already scopes to yesterday + today (+ each favorite's
+      // next game). Pass through so the paper matches the TUI exactly.
+      return payload;
     }
     case "news":
     case "feeds": {
lib/schemas/sources/sports.ts +8 −4
@@ -7,10 +7,11 @@ });
 export type SportsTeam = z.infer<typeof SportsTeam>;
 
 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.
+  // Leagues to show ALL games for (e.g. "basketball/nba" → every game). Empty by
+  // default: a dashboard is about my teams, not full slates. Opt into a league here.
+  leagues: z.array(z.string()).default([]),
+  // Favorite teams — these teams' games always surface (yesterday's result +
+  // today, or the next game when they're not playing today).
   teams: z.array(SportsTeam).default([]),
 });
 export type SportsConfig = z.infer<typeof SportsConfig>;
@@ -27,6 +28,9 @@       away: z.string(),
       homeScore: z.string().nullable(),
       awayScore: z.string().nullable(),
       startTime: z.string().nullable(),
+      // True when the game involves one of my favorite teams (drives the
+      // Favorites vs Leagues split in the TUI and paper).
+      favorite: z.boolean().default(false),
     }),
   ),
 });
lib/sources/modules/sports.ts +73 −39
@@ -21,28 +21,29 @@ }
 
 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 };
+// A dashboard is about *now*: yesterday's results + today's slate. Leagues stay
+// strictly yesterday+today (no future dump). Favorites look a week ahead too, but
+// only so we can surface a team's NEXT game when it isn't playing today.
+const LEAGUE_BACK = 1;
+const LEAGUE_FORWARD = 1;
+const FAV_BACK = 1;
+const FAV_FORWARD = 7;
 
-// YYYYMMDD strings spanning one league's window, oldest first.
-function windowDates(league: string): string[] {
-  const w = LEAGUE_WINDOWS[league] ?? DEFAULT_WINDOW;
+// Local civil day as YYYY-MM-DD (host timezone — matches the TUI's to_local and
+// the paper's new Date(...).toDateString(), which run on the same machine).
+function dayStr(d: Date): string {
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
+function offsetDay(offset: number): Date {
+  const d = new Date();
+  d.setDate(d.getDate() + offset);
+  return d;
+}
+// YYYYMMDD strings over [-back, +forward] days, oldest first.
+function fetchDates(back: number, forward: number): string[] {
   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, ""));
+  for (let off = -back; off <= forward; off++) {
+    out.push(dayStr(offsetDay(off)).replace(/-/g, ""));
   }
   return out;
 }
@@ -58,18 +59,22 @@
   isConfigured: (config) => config.leagues.length > 0 || config.teams.length > 0,
 
   async fetch({ config, signal }) {
-    const collected = new Map<string, Game>();
-
-    // 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.
-    async function collect(league: string, date: string, teamFilter?: string[]) {
+    // Fetch one league's scoreboard for a date and return its games. When
+    // teamFilter is given, keep only games whose home/away matches one of the
+    // team substrings.
+    async function scoreboard(
+      league: string,
+      date: string,
+      teamFilter?: string[],
+    ): Promise<Game[]> {
       const res = await fetch(
         `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard?dates=${date}`,
         { signal },
       );
-      if (!res.ok) return;
+      if (!res.ok) return [];
       const j = (await res.json()) as { events?: ESPNEvent[] };
       const wanted = teamFilter?.map((t) => t.toLowerCase().trim()).filter(Boolean);
+      const out: Game[] = [];
 
       for (const ev of j.events ?? []) {
         const comp = ev.competitions?.[0];
@@ -81,7 +86,7 @@         const blob = `${home?.team.displayName ?? ""} ${home?.team.shortDisplayName ?? ""} ${home?.team.abbreviation ?? ""} ${away?.team.displayName ?? ""} ${away?.team.shortDisplayName ?? ""} ${away?.team.abbreviation ?? ""}`.toLowerCase();
 
         if (wanted && wanted.length && !wanted.some((w) => blob.includes(w))) continue;
 
-        const game: Game = {
+        out.push({
           league,
           status: ev.status?.type?.shortDetail ?? ev.status?.type?.description ?? "",
           state: ev.status?.type?.state ?? null,
@@ -90,25 +95,54 @@           away: name(away),
           homeScore: home?.score ?? null,
           awayScore: away?.score ?? null,
           startTime: ev.date ?? null,
-        };
-        collected.set(gameKey(game), game);
+          favorite: false,
+        });
       }
+      return out;
     }
 
-    // Full leagues (all games), then favorite teams grouped by league.
-    const byLeague = new Map<string, string[]>();
-    for (const { league, team } of config.teams) {
-      byLeague.set(league, [...(byLeague.get(league) ?? []), team]);
+    const today = dayStr(new Date());
+    const yest = dayStr(offsetDay(-1));
+    const dayOf = (g: Game) => (g.startTime ? dayStr(new Date(g.startTime)) : null);
+    const byTime = (a: Game, b: Game) =>
+      (a.startTime ? Date.parse(a.startTime) : 0) - (b.startTime ? Date.parse(b.startTime) : 0);
+
+    // --- Leagues: full slate, yesterday + today only ---
+    const leagueDates = fetchDates(LEAGUE_BACK, LEAGUE_FORWARD);
+    const leagueRaw = (
+      await Promise.all(config.leagues.flatMap((l) => leagueDates.map((d) => scoreboard(l, d))))
+    ).flat();
+    const merged = new Map<string, Game>();
+    for (const g of leagueRaw) {
+      const d = dayOf(g);
+      if (d === yest || d === today) merged.set(gameKey(g), g);
     }
 
-    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))),
-    ]);
+    // --- Favorites: per team, yesterday + today, else the next game ---
+    const favDates = fetchDates(FAV_BACK, FAV_FORWARD);
+    await Promise.all(
+      config.teams.map(async ({ league, team }) => {
+        const raw = (await Promise.all(favDates.map((d) => scoreboard(league, d, [team])))).flat();
+        const seen = new Map<string, Game>();
+        for (const g of raw) seen.set(gameKey(g), g);
+        const games = [...seen.values()].sort(byTime);
 
-    const games = [...collected.values()].sort(
-      (a, b) => (a.startTime ? Date.parse(a.startTime) : 0) - (b.startTime ? Date.parse(b.startTime) : 0),
+        const playsToday = games.some((g) => dayOf(g) === today);
+        for (const g of games) {
+          const d = dayOf(g);
+          if (d === yest || d === today) merged.set(gameKey(g), { ...g, favorite: true });
+        }
+        if (!playsToday) {
+          const next = games.find((g) => {
+            const d = dayOf(g);
+            return d !== null && d > today;
+          });
+          if (next) merged.set(gameKey(next), { ...next, favorite: true });
+        }
+      }),
     );
+
+    const games = [...merged.values()].sort(byTime);
     return { games };
   },
 };
scripts/seed.ts +4 −2
@@ -63,7 +63,7 @@     },
   },
   // Personal RSS — starts empty; add your own feed URLs in Settings → My Feeds.
   { kind: "feeds", label: "My Feeds", enabled: true, refreshSeconds: 1800, size: "tall", config: { feeds: [], limit: 14 } },
-  { kind: "sports", label: "Scores", enabled: false, refreshSeconds: 600, size: "md", config: { leagues: ["basketball/nba"], teams: [] } },
+  { kind: "sports", label: "Scores", enabled: false, refreshSeconds: 600, size: "md", config: { leagues: [], teams: [] } },
   { kind: "github", label: "GitHub", enabled: false, refreshSeconds: 900, size: "md", config: {} },
   { kind: "vercel", label: "Vercel", enabled: false, refreshSeconds: 600, size: "md", config: {} },
   { kind: "mastodon", label: "Mastodon", enabled: false, refreshSeconds: 600, size: "md", config: {} },
@@ -83,7 +83,9 @@   { kind: "hackernews", label: "Hacker News", enabled: true, refreshSeconds: 1800, size: "tall", config: { limit: 10, subreddits: [] } },
   { kind: "uptime", label: "Uptime", enabled: false, refreshSeconds: 600, size: "md", config: { urls: [] } },
   { kind: "obsidian", label: "Obsidian", enabled: true, refreshSeconds: 600, size: "md", config: { vault, limit: 8 } },
   { kind: "links", label: "Links", enabled: true, refreshSeconds: 86_400, size: "wide", config: { links: [] } },
-  { kind: "cider", label: "Now Playing", enabled: true, refreshSeconds: 30, size: "md", config: { host: "http://localhost:10767", appToken: "" } },
+  // Now Playing panel was removed from the dashboard; keep the source seeded but
+  // disabled so it isn't polled for a card that no longer exists.
+  { kind: "cider", label: "Now Playing", enabled: false, refreshSeconds: 30, size: "md", config: { host: "http://localhost:10767", appToken: "" } },
 ];
 
 function main() {
tui/dashboard.py +54 −82
@@ -447,66 +447,71 @@             pass
     return "today"
 
 
+# Sort order within a tier: yesterday's finals, then today/live, then upcoming.
+_GAME_RANK = {"final": 0, "today": 1, "upcoming": 2}
+
+
+def _game_label(g: dict) -> tuple[str, str]:
+    """The left-hand status column for one game — (text, style)."""
+    state = g.get("state")
+    away_sc = g.get("awayScore")
+    home_sc = g.get("homeScore")
+    status = g.get("status", "")
+    if state == "in":
+        # Live: score + current period/inning
+        score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
+        return f"{score}  {status}", "bold yellow"
+    if state == "post":
+        # Final: score + "F"
+        score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
+        final_tag = "F/OT" if "OT" in (status or "") else "F"
+        return f"{score} {final_tag}", "dim"
+    # Scheduled: start time (bare time if today, else weekday + date)
+    st = g.get("startTime")
+    if st:
+        try:
+            d = to_local(st)
+            is_today = d.date() == date.today()
+            return (d.strftime("%-I:%M %p") if is_today else d.strftime("%a %-d  %-I:%M %p")), "dim"
+        except Exception:
+            pass
+    return status, "dim"
+
+
 def render_sports(p: dict) -> Text:
-    all_games = p.get("games", [])
-    if not all_games:
+    games = p.get("games", [])
+    if not 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"}
+    # The module scopes to now (yesterday + today + a favorite's next game) and
+    # tags favorites. Show my teams first, then any opted-in full leagues.
+    tiers = (
+        ("Favorites", [g for g in games if g.get("favorite")]),
+        ("Leagues", [g for g in games if not g.get("favorite")]),
+    )
     t = Text()
     first_section = True
 
-    for key in ("final", "today", "upcoming"):
-        games = buckets[key]
-        if not games:
+    for title, group in tiers:
+        if not group:
             continue
         if not first_section:
             t.append("\n")
         first_section = False
-        t.append(f"{HEADERS[key]}\n", style="bold")
+        t.append(f"{title}\n", style="bold")
 
+        group = sorted(
+            group,
+            key=lambda g: (_GAME_RANK.get(_game_bucket(g), 1), g.get("startTime") or ""),
+        )
         cur_league = None
-        for g in games:
+        for g in group:
             lg = g.get("league", "").split("/")[-1].upper()
             if lg != cur_league:
                 cur_league = lg
                 t.append(f"  {lg}\n", style="dim")
-
-            state = g.get("state")
-            away_sc = g.get("awayScore")
-            home_sc = g.get("homeScore")
-            status = g.get("status", "")
-
-            if state == "in":
-                # Live: score + current period/inning
-                score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
-                lbl = f"{score}  {status}"
-                lbl_style = "bold yellow"
-            elif state == "post":
-                # Final: score + "F"
-                score = f"{away_sc}–{home_sc}" if away_sc is not None and home_sc is not None else "—"
-                final_tag = "F/OT" if "OT" in status or "OT" in (status or "") else "F"
-                lbl = f"{score} {final_tag}"
-                lbl_style = "dim"
-            else:
-                # Scheduled: start time
-                st = g.get("startTime")
-                if st:
-                    try:
-                        d = to_local(st)
-                        is_today = d.date() == date.today()
-                        lbl = d.strftime("%-I:%M %p") if is_today else d.strftime("%a %-d  %-I:%M %p")
-                    except Exception:
-                        lbl = status
-                else:
-                    lbl = status
-                lbl_style = "dim"
-
-            t.append(f"    ")
+            lbl, lbl_style = _game_label(g)
+            t.append("    ")
             t.append(f"{lbl:<18}", style=lbl_style)
             t.append(f"{g.get('away', '?')} @ {g.get('home', '?')}\n")
 
@@ -731,28 +736,6 @@     def set_content(self, content: Text) -> None:
         self._body.update(content)
 
 
-def render_nowplaying(p: dict | None) -> Text:
-    if not p or not p.get("playing") or not p.get("track"):
-        return Text("Nothing playing.", style="dim italic")
-    track = p["track"]
-    t = Text()
-    t.append(f"♫  {track.get('name', '?')}\n", style="bold")
-    t.append(f"   {track.get('artist', '?')}\n")
-    t.append(f"   {track.get('album', '?')}\n", style="dim")
-    dur = track.get("durationMs", 0)
-    cur = track.get("currentMs", 0)
-    if dur > 0:
-        pct = min(1.0, cur / dur)
-        filled = int(pct * 20)
-        bar = "█" * filled + "░" * (20 - filled)
-        def fmt(ms: int) -> str:
-            s = ms // 1000
-            return f"{s // 60}:{s % 60:02d}"
-        t.append(f"\n   {bar}\n", style="dim")
-        t.append(f"   {fmt(cur)} / {fmt(dur)}\n", style="dim")
-    return t
-
-
 # ── kk overlay (native dash menu) ─────────────────────────────────────────────
 
 # Entries safe to run captured (no TTY needed) — everything else suspends the
@@ -1027,16 +1010,13 @@     .row-tall { height: 2fr; }
 
     #weather { width: 1.75fr; }
     #uptime  { width: 0.8fr; }
+    #markets { width: 1.25fr; }
 
-    #nowplaying { width: 1.25fr; }
+    #todos    { width: 1.6fr; }
+    #calendar { width: 1.4fr; }
 
-    #todos    { width: 1.75fr; }
-    #calendar { width: 1.25fr; }
-    #markets  { width: 0.8fr; }
-
-    #sports     { width: 1.75fr; }
+    #sports     { width: 2.5fr; }
     #news       { width: 2fr; }
-    #feeds      { width: 1.25fr; }
     #hackernews { width: 1fr; }
 
     #np-news       { width: 1.25fr; }
@@ -1106,15 +1086,13 @@             with Vertical(id="page-personal"):
                 with Horizontal(classes="row"):
                     yield DashPanel("Weather", "weather")
                     yield DashPanel("Uptime", "uptime")
-                    yield DashPanel("Now Playing", "nowplaying")
+                    yield DashPanel("Markets", "markets")
                 with Horizontal(classes="row"):
                     yield DashPanel("Agenda", "todos")
                     yield DashPanel("Calendar", "calendar")
-                    yield DashPanel("Markets", "markets")
                 with Horizontal(classes="row row-tall"):
                     yield DashPanel("Scores", "sports")
                     yield DashPanel("Headlines", "news")
-                    yield DashPanel("Feeds", "feeds")
                     yield DashPanel("Hacker News", "hackernews")
             with Vertical(id="page-news"):
                 with Horizontal(classes="row"):
@@ -1165,9 +1143,6 @@         panel("sports").set_content(
             render_sports(p) if (p := payload(sources, "sports")) else no_data
         )
         feed_ps = payloads(sources, "feeds")
-        panel("feeds").set_content(
-            render_feeds(feed_ps) if feed_ps else no_data
-        )
         panel("np-feeds").set_content(
             render_feeds(feed_ps, cap=24) if feed_ps else no_data
         )
@@ -1190,9 +1165,6 @@             render_briefs(p) if (p := payload(sources, "briefs")) else no_data
         )
         panel("uptime").set_content(
             render_uptime(p) if (p := payload(sources, "uptime")) else no_data
-        )
-        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"),