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