Repo move fixes
fb1e47ac314d89d56655bcc9f61ea807e03346b4
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-16 08:09
parent 3596ea42
55 files changed
.claude/settings.local.json +32 −1
@@ -85,7 +85,38 @@ "Bash(sed -n '1,16p' lib/schemas/sources/weather.ts)",
"Bash(sed -n '75,100p' scripts/seed.ts)",
"Bash(sed -n '330,360p' components/cards/CardBodies.tsx)",
"Bash(sed -n '1,20p' components/cards/CardBodies.tsx)",
- "Bash(awk '/^else$/,/^fi$/' ~/bin/morning-briefing)"
+ "Bash(awk '/^else$/,/^fi$/' ~/bin/morning-briefing)",
+ "Bash(uvx --from fonttools python -c ' *)",
+ "Bash(doing --version)",
+ "Bash(tock --version)",
+ "Bash(tock --help)",
+ "Bash(doing \"testing bare entry syntax\")",
+ "Bash(echo \"exit: $?\")",
+ "Bash(tock -f /tmp/tock-test.md start A \"task one\")",
+ "Read(//tmp/**)",
+ "Bash(tock -f /tmp/tock-test.md stop)",
+ "Bash(tock -f /tmp/tock-test.md start B \"task two\")",
+ "Bash(rm /tmp/tock-test.md)",
+ "Bash(tock -f /tmp/tock-test.md start A \"task one\" -t 15:00)",
+ "Bash(tock -f /tmp/tock-test.md start B \"task two\" -t 16:10)",
+ "Bash(tock -f /tmp/tock-test.md start C \"task three\")",
+ "WebSearch",
+ "Bash(git -C /Users/kortum/Developer/Personal-Dashboard log --format='%ae %an' -5)",
+ "Read(//Users/kortum/.config/gita/**)",
+ "Bash(brew install *)",
+ "Read(//Users/kortum/**)",
+ "Bash(zsh -n ~/.zshrc)",
+ "Bash(zsh -n ~/.local/bin/micro-fzf)",
+ "Bash(FZF_DEFAULT_COMMAND='/opt/homebrew/bin/fd --color=always --type f --hidden --exclude .git --exclude .obsidian' /opt/homebrew/bin/fd --type f .)",
+ "Bash(grep -n '| `md`' \"/Users/kortum/Humdrum/Claude/Custom Tooling Index.md\")",
+ "Bash(pnpm --version)",
+ "Bash(pnpm config *)",
+ "Bash(pkill -f \"next start\")",
+ "Bash(pnpm start *)",
+ "Bash(curl -s -X POST http://localhost:4317/api/sources/155344c5-cbba-4689-b21c-4c29a1a3f72a/refresh)",
+ "Bash(curl -s -X POST http://localhost:4317/api/sources/b56e1116-3f5b-4085-b305-4532efd0b6cb/refresh)",
+ "WebFetch(domain:raw.githubusercontent.com)",
+ "mcp__backlog__task_create"
]
}
}
app/api/cider/next/route.ts +20 −0
@@ -0,0 +1,20 @@
+import { withErrors, ok } from "@/lib/api";
+import { requireUser } from "@/lib/auth";
+import { db } from "@/db";
+import { sources } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { CiderConfig } from "@/lib/schemas/sources/cider";
+
+export const POST = withErrors(async (req) => {
+ await requireUser(req);
+ const row = db.select().from(sources).where(eq(sources.kind, "cider")).get();
+ if (!row) return ok({ error: "no cider source" });
+
+ const config = CiderConfig.parse(row.config);
+ const res = await fetch(`${config.host}/api/v1/playback/next`, {
+ method: "POST",
+ headers: { apptoken: config.appToken },
+ });
+ if (!res.ok) throw new Error(`Cider ${res.status}`);
+ return ok({ ok: true });
+});
app/globals.css +2 −0
@@ -205,7 +205,9 @@ font-family: var(--font-mono); font-weight: 700; font-size: 11px;
letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 2px;
}
.paper-wx-cond { font-family: var(--font-display); font-size: 19px; }
+.paper-wx-alert { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border: 1.5px solid #111; border-bottom: none; padding: 4px 8px; background: #eee; }
.paper-wx-hl { font-family: var(--font-mono); font-size: 10px; color: #555; margin-top: 4px; }
+.paper-wx-astro { font-family: var(--font-mono); font-size: 9px; color: #777; margin-top: 2px; }
.paper-wx-strip { flex: 1; display: flex; }
.paper-wx-seg { flex: 1; text-align: center; padding: 10px 4px; border-left: 1px solid #ddd; }
.paper-wx-seg:first-child { border-left: none; }
app/print/page.tsx +65 −35
@@ -87,45 +87,74 @@ if (code <= 86) return "🌨";
return "⛈";
}
+function fmtTime(iso: string) {
+ return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
function WeatherHero({ w }: { w: WeatherPayload }) {
const deg = w.units === "metric" ? "°C" : "°F";
const wind = w.units === "metric" ? "km/h" : "mph";
const today = w.daily[0];
+ const showFeels =
+ w.current.feelsLike != null && Math.abs(w.current.feelsLike - w.current.temp) >= 2;
+ const highPollen = w.pollen.filter((p) => p.level !== "Low");
+
return (
- <div className="paper-wx">
- <div className="paper-wx-now">
- <div className="paper-wx-temp">
- {w.current.temp}
- {deg}
+ <>
+ {w.alerts.map((a, i) => (
+ <div className="paper-wx-alert" key={i}>
+ ⚠ {a.event}{a.headline ? ` — ${a.headline}` : ""}
</div>
- <div>
- <div className="paper-wx-loc">{w.location}</div>
- <div className="paper-wx-cond">{w.current.text}</div>
- {today && (
- <div className="paper-wx-hl">
- H {today.max}
- {deg} · L {today.min}
- {deg} · Wind {w.current.wind} {wind}
- </div>
- )}
+ ))}
+ <div className="paper-wx">
+ <div className="paper-wx-now">
+ <div className="paper-wx-temp">
+ {w.current.temp}
+ {deg}
+ </div>
+ <div>
+ <div className="paper-wx-loc">{w.location}</div>
+ <div className="paper-wx-cond">{w.current.text}</div>
+ {today && (
+ <div className="paper-wx-hl">
+ H {today.max}{deg} · L {today.min}{deg} · Wind {w.current.wind} {wind}
+ {showFeels ? ` · Feels ${w.current.feelsLike}${deg}` : ""}
+ {w.current.humidity != null ? ` · ${w.current.humidity}% RH` : ""}
+ {w.current.uvIndex != null ? ` · UV ${w.current.uvIndex}` : ""}
+ {w.aqi != null ? ` · AQI ${w.aqi} ${w.aqiCategory}` : ""}
+ </div>
+ )}
+ {(w.sunrise || w.sunset || w.moonPhase) && (
+ <div className="paper-wx-astro">
+ {w.sunrise ? `↑ ${fmtTime(w.sunrise)}` : ""}
+ {w.sunset ? ` · ↓ ${fmtTime(w.sunset)}` : ""}
+ {w.moonPhase ? ` · ${w.moonEmoji} ${w.moonPhase}` : ""}
+ </div>
+ )}
+ {highPollen.length > 0 && (
+ <div className="paper-wx-astro">
+ Pollen: {highPollen.map((p) => `${p.label} ${p.level}`).join(" · ")}
+ </div>
+ )}
+ </div>
</div>
- </div>
- {w.parts.length > 0 && (
- <div className="paper-wx-strip">
- {w.parts.map((p) => (
- <div className="paper-wx-seg" key={p.label}>
- <div className="paper-wx-seg-t">{p.label}</div>
- <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
- <div className="paper-wx-seg-d">
- {p.temp}
- {deg}
+ {w.parts.length > 0 && (
+ <div className="paper-wx-strip">
+ {w.parts.map((p) => (
+ <div className="paper-wx-seg" key={p.label}>
+ <div className="paper-wx-seg-t">{p.label}</div>
+ <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
+ <div className="paper-wx-seg-d">
+ {p.temp}
+ {deg}
+ </div>
+ <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
</div>
- <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
- </div>
- ))}
- </div>
- )}
- </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </>
);
}
@@ -156,7 +185,7 @@ <p className="paper-item">
<span className="yr">AQI {p.aqi ?? "—"}</span> {p.category}
{p.pm25 != null && <span className="src"> — PM2.5 {p.pm25}</span>}
</p>
- {p.pollen.map((pl) => (
+ {p.pollen.slice(0, 3).map((pl) => (
<p className="paper-item" key={pl.label}>
{pl.label} <span className="src">— {pl.level}</span>
</p>
@@ -169,7 +198,7 @@ const p = payload as MarketsPayload;
if (p.quotes.length === 0) return null;
return (
<>
- {p.quotes.map((q) => (
+ {p.quotes.slice(0, 6).map((q) => (
<p className="paper-item" key={q.symbol}>
<span className="yr">{q.symbol}</span>{" "}
{q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}
@@ -190,7 +219,7 @@ const p = payload as HackerNewsPayload;
if (p.stories.length === 0) return null;
return (
<>
- {p.stories.map((s, i) => (
+ {p.stories.slice(0, 5).map((s, i) => (
<p className="paper-item" key={i}>
{s.title} <span className="src">— {s.source}</span>
</p>
@@ -334,7 +363,8 @@ );
}
case "todos": {
const p = payload as ThingsPayload;
- if (p.tasks.length === 0) return null;
+ if (p.tasks.length === 0)
+ return <p className="paper-item paper-empty">No tasks today.</p>;
return (
<>
{p.tasks.map((t) => (
- → Scoreboard-page-Golazo-plain-text-Bloomberg-terminal-style.md +31 −0
@@ -0,0 +1,31 @@
+---
+id: TASK-001
+title: 'Scoreboard page: Golazo/plain-text/Bloomberg terminal style'
+status: To Do
+assignee: []
+created_date: '2026-06-11 01:58'
+updated_date: '2026-06-11 02:00'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 1000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+TUI scoreboard — new page (key '4') in dashboard.py showing all live/today/upcoming games in a dense, Bloomberg-terminal-style monospace grid. Rows sorted live→today→upcoming, color-coded by state (live=yellow/bold, final=dim, upcoming=default). Polls /api/sources for sports payload on the same 30s interval as other pages.
+
+Clicking (or pressing Enter on) a game opens a full-screen GameDetailScreen (Textual ModalScreen or push_screen). Detail view: box score, current period/inning/quarter, scoring plays/timeline, player stats table (passing/rushing/shooting/batting etc depending on sport), updates on a shorter interval (15s or configurable). ESC or Q to exit detail and return to scoreboard.
+
+Navigation: up/down arrow keys or j/k to move between games, Enter to open detail, Esc to go back. The game list should be a Textual DataTable or custom OptionList so cursor/selection works naturally.
+
+Reference: existing render_sports() at line 423 in tui/dashboard.py for current game bucketing and color logic — detail view extends this.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Page renders at /scoreboard with live scores in terminal-grid layout; match states visually distinct; auto-refreshes; mobile-readable monospace font
+- [ ] #2 Page 4 shows live scoreboard; games navigable with arrow keys; Enter opens full-screen detail with box score + player stats; auto-refreshes; Esc closes detail
+<!-- AC:END -->
- → TUI-Foofaraw-page-editorial-pipeline-weekly-schedule.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-002
+title: 'TUI Foofaraw page: editorial pipeline + weekly schedule'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:09'
+updated_date: '2026-06-11 02:13'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 2000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page (key '5') in tui/dashboard.py showing the full Foofaraw/QB editorial pipeline status and the week's publishing schedule.
+
+DATA SOURCE: All stats come from direct httpx REST queries to The Pile's Supabase DB (same creds as pile-admin/pile-reader).
+
+Stat panels:
+1. Open submissions — submissions WHERE submittable_status NOT IN ('accepted','completed','declined')
+2. Needs contract — acceptances WHERE status = 'Accepted'
+3. Contract sent, awaiting return — acceptances WHERE status = 'Contract Sent' AND contract_returned_at IS NULL
+4. Ready to pay — acceptances WHERE status = 'Contract Sent' AND contract_returned_at IS NOT NULL AND payout_batch_id IS NULL
+
+Note: stat 4 is only as fresh as the last pile-contracts run (which stamps contract_returned_at), but the data lives in the DB — no subprocess needed to read it.
+
+Weekly schedule: acceptances WHERE publish_date BETWEEN today AND today+7. Group by project_settings.template: 'standard' → Foofaraw, 'qb' → QB. Show date + author + title per row. Bold today/tomorrow.
+
+Navigation: j/k + Enter. Enter on stat panel shows underlying item list. Enter on schedule item shows detail (author, word_count, status, notes, story_url).
+
+Reference: ~/Developer/The-Pile/scripts/contracts-inbox.js (Supabase client + query patterns), ~/Developer/The-Pile/database/acceptances.sql, migrations/013_contracts_inbox.sql (contract_returned_at), migrations/009_payout_tracking.sql (payout_batch_id).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Page 5 shows 4 pipeline counts live from Supabase; week schedule lists Foofaraw+QB posts; auto-refreshes; Enter on stat row opens item list
+<!-- AC:END -->
- → TUI-news-page-split-Briefs-into-4-per-category-panels.md +38 −0
@@ -0,0 +1,38 @@
+---
+id: TASK-003
+title: 'TUI news page: split Briefs into 4 per-category panels'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:16'
+updated_date: '2026-06-11 02:24'
+labels:
+ - bug
+dependencies: []
+priority: medium
+ordinal: 3000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+In tui/dashboard.py, replace the entire page-news layout with exactly four equal DashPanel widgets — one per brief category (Politics, Technology, Sports, Culture). No other panels on this page.
+
+New layout (2×2 grid):
+ Row 1: np-brief-politics, np-brief-tech
+ Row 2: np-brief-sports, np-brief-culture
+
+Remove from page-news entirely: np-news (Headlines), np-feeds (Feeds), np-hackernews (HN). Those stay on page-personal only.
+
+Implementation:
+- Add render_brief_section(section: dict) -> Text for a single BriefSection.
+- In refresh_data(), look up each category by key from the briefs payload and feed its panel.
+- 'No brief.' dim fallback if category missing.
+
+Reference: render_briefs() line 513 tui/dashboard.py; BriefSection shape lib/schemas/sources/briefs.ts.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 page-news shows 4 equal brief panels (Politics/Tech/Sports/Culture); no merged briefs panel; no headlines panel on news page; each panel shows its category's stories independently
+- [ ] #2 page-news is exactly 4 panels in 2x2 grid (Politics/Tech/Sports/Culture); no other widgets on the page
+<!-- AC:END -->
- → TUI-music-page-Apple-Music-now-playing-controls.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-004
+title: 'TUI music page: Apple Music now-playing + controls'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 4000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page (key '6' or next available) in tui/dashboard.py dedicated to Apple Music. Calls existing 'am' shell functions via subprocess (same pattern as kk captured commands).
+
+Panels:
+- Now playing: track, artist, album, elapsed/duration progress bar (poll 'am now' every 5s)
+- Controls row: keybindings for play/pause (space), next (n), prev (p), vol up/down (+/-)
+- Sparkline or ASCII progress bar showing position in track
+- Queue/recent: 'am queue' or similar if available; fall back to recently played
+
+Refresh: 5s interval for now-playing only (faster than the 30s global refresh). Controls dispatch immediately via subprocess without waiting for the refresh cycle.
+
+Reference: KK_CAPTURED and KK_INPUT entries for 'am play', 'am pause', 'am next', 'am prev', 'am now', 'am vol', 'am search', 'am playlist' in tui/dashboard.py — reuse those subprocess patterns.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-day-page-today-only-mission-control.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-005
+title: 'TUI day page: today-only mission control'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 5000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing a single unified view of today — Things tasks and Calendar events interleaved in time order, with a running 'hours left in day' indicator.
+
+Layout:
+- Top strip: date, day-of-week, hours remaining until midnight (or configurable end-of-day)
+- Main column: chronological list mixing calendar events (from calendar payload) and Things tasks (from todos payload). Events show start time; tasks without a time float to a 'Anytime' section at the bottom.
+- Side panel: today's weather summary (temp, conditions, precip chance) pulled from existing weather payload
+
+Data: all from existing /api/sources payload — todos (Things Today), calendar (Calendar.app), weather. No new API calls needed.
+
+Behavior: auto-refresh 60s. Completed Things tasks (if checkable) dim out. Pressing Enter on a Things task triggers the complete action (POST /api/integrations/things/complete, same as main page checkbox).
+
+Distinct from page-personal: page-personal is a multi-source overview; this page is a single-day timeline/focus view.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-git-page-multi-repo-status-via-gita.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-006
+title: 'TUI git page: multi-repo status via gita'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:48'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 6000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing live status of all repos tracked by gita.
+
+Data: run 'gita ll' via subprocess (thread=True worker, refresh on page enter + keybinding). Parse ANSI-stripped output — columns are: name, branch, status flags (*=dirty, ?=untracked, ↑=ahead, ↓=behind, ∅=no remote), last commit message.
+
+Display as DataTable (like sys-procs):
+ Columns: Name | Branch | Status | Last commit
+ Color coding: dirty/untracked rows = yellow, behind = red, ahead = blue, clean = dim
+ Rows sortable or at least grouped: dirty first, then ahead, then clean
+
+Keybindings: r = re-run gita ll, Enter on row = show full 'gita context <name>' or 'git log -5' in a modal.
+
+Note: gita ll output contains ANSI color codes — strip with re.sub or use strip_ansi before parsing. Sample output structure: 'Repo-Name [magenta]branch [flags] last commit message'
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-reading-queue-Goodlinks-unread-list.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-007
+title: 'TUI reading queue: Goodlinks unread list'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:50'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 7000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing unread articles from Goodlinks, read directly from its local SQLite database.
+
+DB path: ~/Library/Group Containers/group.com.ngocluu.goodlinks/Data/data.sqlite
+Table: link
+Unread query: SELECT title, url, author, summary, tags, addedAt FROM link WHERE readAt = 0 AND deletedAt = 0 ORDER BY addedAt DESC LIMIT 50
+
+Use Python's built-in sqlite3 module — no subprocess, no httpx. Query in a thread worker (DB is on local disk, fast).
+
+Display as scrollable list:
+ title (bold), author/domain (dim), tags (accent), added date
+ Summary shown as secondary line (truncated to 1 line)
+
+Keybindings:
+ Enter = open URL in browser (subprocess 'open <url>')
+ m = mark read (UPDATE link SET readAt = unixepoch() WHERE id = ?)
+ r = refresh list
+
+Caveat: Goodlinks DB may be locked briefly when the app is writing. Use sqlite3 in read-only URI mode: sqlite3.connect('file:///path?mode=ro', uri=True) to avoid contention.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-social-page-Mastodon-Bluesky-timeline.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-008
+title: 'TUI social page: Mastodon + Bluesky timeline'
+status: To Do
+assignee: []
+created_date: '2026-06-11 02:50'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 8000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+New page in tui/dashboard.py showing Mastodon and Bluesky home timelines side by side. Both source modules already exist (lib/sources/modules/mastodon.ts, bluesky.ts) and their payloads are available via /api/sources.
+
+Layout: two equal DashPanel columns
+ Left: Mastodon timeline
+ Right: Bluesky timeline
+
+Each panel: scrollable post list — author (bold), timestamp (dim), post body (wrapped). Boost/repost indicated with '↻ boosted by' prefix line.
+
+Refresh: standard 30s global refresh cycle. Posts deduplicated by ID within each panel.
+
+Check payload shape from mastodon.ts and bluesky.ts schemas in lib/schemas/sources/ to confirm field names before implementing.
+<!-- SECTION:DESCRIPTION:END -->
- → TUI-Hash-seeded-poll-jitter-on-startup.md +32 −0
@@ -0,0 +1,32 @@
+---
+id: TASK-009
+title: 'TUI: Hash-seeded poll jitter on startup'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: medium
+ordinal: 9000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+All sources currently refresh simultaneously at t=0 on every startup and every 30s interval, causing a thundering herd against the Next.js API. Add deterministic per-source jitter so each source's first fetch is offset by `hash(source_kind) % MAX_JITTER_MS` milliseconds. The offset should be stable across restarts (same source = same offset), so phase relationships don't change between runs.
+
+Implementation sketch:
+- Write a `_jitter(kind: str, max_ms: int = 8000) -> float` function using `hashlib.md5(kind.encode()).digest()` as seed
+- In `on_mount`, instead of `self.set_interval(REFRESH_SECS, self.refresh_data)`, fire each source's first refresh after its individual jitter offset via `self.set_timer(jitter_secs, lambda: ...)` then start the repeating interval
+- Or: a single interval timer that checks per-source "next due" timestamps rather than refreshing everything at once
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Restarting the TUI does not cause all API calls to fire at the same instant
+- [ ] #2 Same source always gets the same jitter offset across restarts
+- [ ] #3 Max jitter is configurable via a constant at the top of the file
+- [ ] #4 Overall data refresh still completes within REFRESH_SECS of startup
+<!-- AC:END -->
- → TUI-Cache-age-aware-initial-refresh-timer.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-010
+title: 'TUI: Cache-age-aware initial refresh timer'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: medium
+ordinal: 10000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+On startup, the TUI always fires `refresh_data()` immediately even if the Next.js app's snapshots are only seconds old. Instead, read `refreshedAt` from each snapshot and compute how much of the TTL has already elapsed, then wait only the remaining time before the first refresh.
+
+For example: if a source has a 5-minute TTL and its snapshot is 4 minutes old, the TUI should wait ~1 minute before its first fetch, not 5 minutes (or 0).
+
+Implementation:
+- After the initial `fetch_sources()` call, extract `min(refreshedAt)` or per-source `refreshedAt` from snapshots
+- Compute `remaining = refresh_interval - (now - refreshedAt)`
+- Schedule the first repeating timer to fire after `max(0, remaining)` seconds
+- Fall back to immediate refresh if no snapshots exist
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Restarting TUI with fresh data does not immediately re-fetch all sources
+- [ ] #2 Restarting TUI with stale data fetches immediately
+- [ ] #3 Logic degrades gracefully when refreshedAt is missing from snapshot
+<!-- AC:END -->
- → TUI-Per-panel-staleness-badge-in-border-title.md +33 −0
@@ -0,0 +1,33 @@
+---
+id: TASK-011
+title: 'TUI: Per-panel staleness badge in border title'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: low
+ordinal: 11000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Users currently have no way to know how old the data in a panel is. Add a relative timestamp ("2m ago", "just now", "12m ago") to each `DashPanel`'s `border_title` showing when its source's snapshot was last refreshed.
+
+Implementation:
+- Extract `refreshedAt` from each source's snapshot during `refresh_data()`
+- Pass it to `DashPanel` alongside the content (e.g. `set_content(text, refreshed_at=dt)`)
+- `DashPanel` updates `border_title = f"{self._title} {relative_time(refreshed_at)}"`
+- Add a lightweight 60s timer that re-renders titles in place (just the relative time strings) without re-fetching data
+- `relative_time(dt)` → "just now" (<2m), "Xm ago" (<1h), "Xh ago" (≥1h)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Each panel border shows relative age of its data
+- [ ] #2 Timestamps update every minute without re-fetching
+- [ ] #3 Panels without a snapshot (not configured) show no timestamp
+<!-- AC:END -->
- → TUI-Per-source-error-badge-in-panel-title.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-012
+title: 'TUI: Per-source error badge in panel title'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: medium
+ordinal: 12000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a source has `snapshot.ok == false` or is missing entirely, panels currently just show "No data." with no indication of why. Distinguish three states visually:
+
+1. **Not configured** — source disabled or `configured: false` → dim "—" or italic "not configured"
+2. **Fetch error** — source enabled, snapshot exists, `ok: false` → `⚠` in border title + error message body
+3. **No snapshot yet** — source enabled but no snapshot row → "waiting for first refresh…"
+
+Implementation:
+- In `payload()`, return a richer sentinel or pass state flags through
+- Update each `DashPanel`'s border title with `⚠` prefix when `ok: false`
+- Show the `snapshot.error` field in the panel body when available
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Errored sources show ⚠ in panel border
+- [ ] #2 Error message from snapshot is displayed in panel body
+- [ ] #3 Not-configured sources are visually distinct from errored sources
+- [ ] #4 Healthy panels are unchanged
+<!-- AC:END -->
- → TUI-Tab-focus-cycling-between-panels.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-013
+title: 'TUI: Tab focus cycling between panels'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: high
+ordinal: 13000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Currently no panel is focusable — Tab does nothing and all panels render identically. Add keyboard focus cycling so users can select a panel to interact with (scroll, navigate items, zoom).
+
+Implementation:
+- `DashPanel` already has `:focus` CSS (`border: round $accent`). Need to make panels focusable: set `can_focus = True` on `DashPanel`
+- Add `Tab` / `Shift+Tab` bindings that cycle focus through panels on the active page
+- Focused panel gets the accent border; unfocused panels use the standard panel border
+- Focus state resets when switching pages (first panel on new page gets focus, or no focus)
+- Scroll within a focused panel works naturally since `DashPanel` extends `ScrollableContainer`
+
+This is a prerequisite for: keyboard-navigable items (TASK-007), in-panel search (TASK-009), panel zoom (TASK-006), quick open link (TASK-008).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Tab cycles focus forward through panels on the active page
+- [ ] #2 Shift+Tab cycles backward
+- [ ] #3 Focused panel has accent-colored border
+- [ ] #4 Switching pages resets focus
+- [ ] #5 Existing scroll behavior within panels is unaffected
+<!-- AC:END -->
- → TUI-Panel-zoom-—-expand-focused-panel-full-screen.md +38 −0
@@ -0,0 +1,38 @@
+---
+id: TASK-014
+title: 'TUI: Panel zoom — expand focused panel full-screen'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies:
+ - TASK-005
+priority: medium
+ordinal: 14000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a panel is focused, pressing `z` should expand it to fill the full content area temporarily. This is especially useful for News, Calendar, Sports, and Feeds panels which are cramped at normal size.
+
+Implementation:
+- Bind `z` when a panel has focus (or as an app-level binding that acts on the focused panel)
+- Push a `ModalScreen` or use CSS to show only the focused panel at full size
+- Zoomed panel still polls/updates normally
+- `Esc` or `z` again collapses back to the normal layout
+- Zoomed state is displayed with a "[zoomed — Esc to close]" hint in the border title
+
+Depends on: TASK-005 (tab focus cycling)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 z on a focused panel expands it full-screen
+- [ ] #2 Esc collapses back to normal layout
+- [ ] #3 Data continues to update in zoomed state
+- [ ] #4 Zoom hint visible in border title
+- [ ] #5 Works on all 3 pages
+<!-- AC:END -->
- → TUI-Keyboard-navigable-items-within-panels.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-015
+title: 'TUI: Keyboard-navigable items within panels'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies:
+ - TASK-005
+priority: high
+ordinal: 15000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Currently panels render `Static` text — arrow keys scroll the container but don't select individual items. Upgrade content-rich panels (News, Feeds, HN, Calendar, Todos, Sports) to support item selection via up/down arrow keys when the panel is focused.
+
+Implementation options:
+- Replace `Static` in those panels with a `ListView` or `OptionList` widget where each item is selectable
+- Or keep `Static` for rendering but track a `_selected_idx` in the panel and re-render with a highlighted row on arrow key events
+
+The selected item should be visually highlighted (reverse or accent color). Selection state resets on data refresh (or preserves by title/id if feasible).
+
+Depends on: TASK-005 (tab focus)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Up/down arrow keys move selection within a focused panel
+- [ ] #2 Selected item is visually highlighted
+- [ ] #3 Applicable panels: News, Feeds, HN, Calendar, Todos, Sports
+- [ ] #4 Non-applicable panels (Weather, CPU, etc.) unaffected
+- [ ] #5 Selection resets on page change or data refresh
+<!-- AC:END -->
- → TUI-Open-selected-item-URL-in-browser.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-016
+title: 'TUI: Open selected item URL in browser'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies:
+ - TASK-007
+priority: medium
+ordinal: 16000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+News, Feeds, and HN items already carry URLs that are clickable via `app.open_link`. Add a keyboard path: when an item is selected (via arrow keys), pressing `o` or `Enter` opens its URL in the default browser.
+
+Implementation:
+- In the item-navigation layer (from TASK-007), store the URL alongside each selectable item
+- Bind `o` (and `Enter` where it won't conflict) to `app.open_url(selected_item.url)`
+- Show a brief toast notification: "Opening: {title[:50]}"
+- Items without URLs (e.g. calendar events, todos) do nothing on `o` / show a "no link" toast
+
+Depends on: TASK-007 (keyboard-navigable items)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 o or Enter on a selected news/feed/HN item opens its URL in the browser
+- [ ] #2 Toast confirms the open action
+- [ ] #3 Items without URLs show a graceful no-op or message
+- [ ] #4 Existing mouse-click link behavior unchanged
+<!-- AC:END -->
- → TUI-In-panel-search-filter-with.md +41 −0
@@ -0,0 +1,41 @@
+---
+id: TASK-017
+title: 'TUI: In-panel search/filter with /'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies:
+ - TASK-005
+ - TASK-007
+priority: medium
+ordinal: 17000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When a panel is focused, pressing `/` opens an inline filter input at the bottom of the panel. Typing filters the panel's items in real time (case-insensitive substring match). `Esc` clears the filter and closes the input. `Enter` locks the filter (input hides, filtered items remain visible with a `[filter: "…"]` badge in the border title).
+
+Applicable panels: News, Feeds, HN, Sports, Calendar.
+
+Implementation:
+- Add a `filter: str = ""` attribute to `DashPanel` or the item-layer widget
+- Pressing `/` focuses an `Input` widget docked at the bottom of the panel
+- `on_input_changed` re-renders the content filtered to matching items
+- `Esc` clears `filter` and dismisses the input
+- `Enter` keeps the filter active but hides the input; border title shows `[…]`
+
+Depends on: TASK-005 (focus), TASK-007 (item navigation)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 / when panel is focused opens inline filter input
+- [ ] #2 Items filter in real time as user types
+- [ ] #3 Esc clears filter and hides input
+- [ ] #4 Active filter shown in border title
+- [ ] #5 Works in News, Feeds, HN, Sports, Calendar panels
+<!-- AC:END -->
- → TUI-Tabbed-stack-panel-for-News-Feeds-HN.md +40 −0
@@ -0,0 +1,40 @@
+---
+id: TASK-018
+title: 'TUI: Tabbed stack panel for News/Feeds/HN'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 18000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The News page currently shows 4 narrow columns: Headlines, Briefs, Feeds, HN. Collapse these into a single full-width tabbed panel where `,` / `.` (or number keys) cycle between tabs. This mirrors Glint's "stack" widget concept.
+
+Tabs: **Headlines** | **Briefs** | **Feeds** | **HN**
+
+Each tab renders the same content as the current panel, but with the full page width available. The active tab label is highlighted in the panel border title (e.g. `News [ Headlines | Briefs | Feeds | HN ]`).
+
+The Personal page keeps its current layout; only `page-news` changes.
+
+Implementation:
+- Replace the 4-panel `Horizontal` in `page-news` with a single `DashPanel` wrapping a `ContentSwitcher`
+- Border title shows tab strip
+- `,` / `.` bindings cycle tabs (or `1`-`4` number keys scoped to the news page)
+- Each tab's content uses the existing render functions with higher `cap` values
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 News page shows a single tabbed panel instead of 4 columns
+- [ ] #2 Tab labels visible in panel border
+- [ ] #3 Comma/period or number keys cycle tabs
+- [ ] #4 Each tab shows correct content at full page width
+- [ ] #5 Personal page layout unchanged
+<!-- AC:END -->
- → TUI-Inline-summary-overlay-for-news-feed-items.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-019
+title: 'TUI: Inline summary overlay for news/feed items'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies:
+ - TASK-007
+priority: low
+ordinal: 19000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Briefs items have a `summary` field. News and feeds items may carry summaries too depending on source config. When a user selects an item with a summary and presses `Space`, show a popup overlay with the full summary text.
+
+Implementation:
+- Create a `SummaryScreen(ModalScreen)` that displays the item title + full summary in a scrollable box
+- Bind `Space` on a selected item to push `SummaryScreen` if `item.summary` is non-empty
+- Overlay is 80% width, auto height, dismissible with `Esc` or `Space`
+- If no summary available, show a brief "no summary" toast instead of a blank overlay
+
+Depends on: TASK-007 (keyboard-navigable items)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Space on a selected item with a summary opens the overlay
+- [ ] #2 Overlay shows full title and summary text
+- [ ] #3 Overlay is scrollable for long summaries
+- [ ] #4 Esc dismisses the overlay
+- [ ] #5 Items without summaries show a toast, not an empty overlay
+<!-- AC:END -->
- → TUI-7-day-weather-forecast-sparkline.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-020
+title: 'TUI: 7-day weather forecast sparkline'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 20000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The weather panel currently shows a plain text list of 3-day forecasts (`Mon 72° / 58°`). Replace or supplement this with a visual 7-day forecast bar using Textual's `Sparkline` widget or Unicode block characters — one column per day showing high/low temp range.
+
+Implementation:
+- Use the `daily[]` array from the weather payload (already fetched, has `max`, `min`, `date`, `precipProb`)
+- Render a mini bar chart: each day as a labeled column with a filled block representing the temp range, colored by high temp (cool→warm gradient)
+- Show day abbreviation + high/low below each bar
+- Precipitation probability shown as a `%` label or bar fill change when ≥ 20%
+- Textual `Sparkline` could show just highs; a custom `Static` Rich table gives more control over high/low ranges
+
+Weather payload already has up to 7 days of `daily` data.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Weather panel shows a visual 7-day forecast
+- [ ] #2 Each day shows abbreviated name, high, low
+- [ ] #3 Precipitation probability indicated when >= 20%
+- [ ] #4 Chart fits within the panel without horizontal scrolling at typical terminal widths
+- [ ] #5 Falls back gracefully if fewer than 7 days available
+<!-- AC:END -->
- → TUI-7-day-calendar-week-view.md +46 −0
@@ -0,0 +1,46 @@
+---
+id: TASK-021
+title: 'TUI: 7-day calendar week view'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 21000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The Calendar panel currently shows a flat list of upcoming events sorted by time. Add a 7-day grid view as an alternative display mode (toggled with `v` when the panel is focused): one column per day, events listed vertically within each day column.
+
+Layout sketch:
+```
+Mon Tue Wed Thu Fri Sat Sun
+Jun 9 Jun 10 Jun 11 Jun 12 Jun 13 Jun 14 Jun 15
+────── ────── ────── ────── ────── ────── ──────
+9:00am All day 2:00pm
+Standup Release Dentist
+```
+
+Implementation:
+- Group `events[]` from the calendar payload by `event.start` date into 7 buckets (today + 6 days)
+- Render as a `Horizontal` of 7 equal-width columns inside the panel
+- All-day events appear at top of their column
+- Time-specific events show time + title
+- `v` toggles between list view (current) and week grid view
+- Week view default or list view default is a constant at top of file
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 v toggles between list and week-grid view in Calendar panel
+- [ ] #2 Week grid shows 7 days starting from today
+- [ ] #3 All-day events appear at top of their column
+- [ ] #4 Timed events show time + title
+- [ ] #5 Today's column is visually highlighted
+- [ ] #6 Falls back to list view if fewer than 2 events in the window
+<!-- AC:END -->
- → TUI-Now-Playing-transport-control-keybindings.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-022
+title: 'TUI: Now Playing transport control keybindings'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 22000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The Now Playing panel shows current track info but has no playback controls. Add keyboard shortcuts to control Apple Music without opening the kk menu.
+
+Proposed bindings (scoped so they don't conflict with global bindings):
+- `p` — play/pause (`am play` / `am pause`, or `am toggle`)
+- `Shift+N` — next track (`am next`)
+- `Shift+P` — previous track (`am prev`)
+
+These should work globally (not require panel focus) since music control is a frequent action.
+
+Implementation:
+- Add to `DashboardApp.BINDINGS`
+- Each binding calls `run_command("am play")` etc. via the existing worker — fire-and-forget, no output shown
+- On success: brief toast "⏸ Paused" / "▶ Playing" / "⏭ Next"
+- Show transport controls as static hints below the progress bar in `render_nowplaying`: `p play/pause ⇧N next ⇧P prev`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 p toggles play/pause
+- [ ] #2 Shift+N skips to next track
+- [ ] #3 Shift+P goes to previous track
+- [ ] #4 Toast notification confirms each action
+- [ ] #5 Controls hint visible in Now Playing panel
+- [ ] #6 Bindings work regardless of which panel is focused
+<!-- AC:END -->
- → TUI-Countdown-to-next-calendar-event-in-subtitle.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-023
+title: 'TUI: Countdown to next calendar event in subtitle'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: low
+ordinal: 23000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The subtitle bar currently shows `● 2:30 PM · Personal · flexoki`. Append the next upcoming calendar event and time until it starts: `● 2:30 PM · Standup in 42m · Personal · flexoki`.
+
+Implementation:
+- After `refresh_data()`, find the soonest upcoming (non-all-day) event from the calendar payload with `start > now`
+- Compute `delta = start - now` → format as "in Xm" (<1h), "in Xh Ym" (≥1h), "now" (<2m)
+- Update `_update_subtitle()` to include the event name (truncated to ~20 chars) and countdown
+- Refresh the countdown display every 60s via an existing or new lightweight timer (no re-fetch needed, just recompute from cached event data)
+- If no upcoming event within 24h, omit the field
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Subtitle shows next event name and time-until when one exists within 24h
+- [ ] #2 Countdown updates every minute without re-fetching
+- [ ] #3 No event within 24h: subtitle unchanged from current format
+- [ ] #4 All-day events excluded from the countdown
+- [ ] #5 Event name truncated to avoid subtitle overflow
+<!-- AC:END -->
- → TUI-Weather-alert-banner-at-top-of-Personal-page.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-024
+title: 'TUI: Weather alert banner at top of Personal page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 24000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+When the weather payload has active `alerts[]` or AQI > 100, show a persistent single-line banner at the top of the Personal page (above the first row of panels).
+
+Banner format: `⚠ Freeze Warning · until 9:00 AM · AQI 142 Unhealthy`
+
+Behavior:
+- Only shown when at least one condition is active
+- Styled bold red (alert) or dark orange (AQI only)
+- Dismissible with `x` until the next data refresh re-evaluates
+- Survives panel refreshes — only clears when the underlying alert is gone
+- Multiple alerts: show the first + "and N more"
+
+Implementation:
+- Add a `Static` widget above the `ContentSwitcher`, initially `display: none`
+- In `refresh_data()`, check weather payload for alerts and AQI; show/hide and update the widget accordingly
+- Track dismissed state in a set of alert event names; compare against current alerts on refresh
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Banner appears when alerts[] is non-empty or AQI > 100
+- [ ] #2 Banner is hidden when no alert conditions are active
+- [ ] #3 x dismisses the banner until next data refresh
+- [ ] #4 Multiple alerts show count
+- [ ] #5 Banner does not appear on News or System pages
+<!-- AC:END -->
- → TUI-Per-core-CPU-bars-on-System-page.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-025
+title: 'TUI: Per-core CPU bars on System page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: low
+ordinal: 25000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The System page shows aggregate CPU% but no per-core breakdown. Add a per-core bar section to the CPU panel or as a new row between the sparkline and the process table.
+
+Implementation:
+- `psutil.cpu_percent(percpu=True)` returns a list of per-core percentages
+- Add this call to `SystemStats.sample()` → `"cores_pct": [...]`
+- Render as a grid of labeled bars: `C0 [████░░░░] 42% C1 [██░░░░░░] 18% …`
+- Use Unicode block chars (`█`, `░`) for bars, scaled to a fixed width (e.g. 8 chars)
+- Color: green < 50%, yellow 50–80%, red > 80%
+- Layout: 2 or 4 cores per row depending on terminal width / core count
+- Place in the existing `sys-cpu` panel below the aggregate line, or as a dedicated row
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Per-core CPU percentages shown as labeled bars
+- [ ] #2 Bars are color-coded by utilization level
+- [ ] #3 Layout adapts to core count (wraps to multiple rows)
+- [ ] #4 Updates on same SYS_REFRESH_SECS interval as existing system stats
+- [ ] #5 Fits within sys-cpu panel without requiring scroll on standard terminal widths
+<!-- AC:END -->
- → TUI-Network-I-O-history-sparkline-on-System-page.md +35 −0
@@ -0,0 +1,35 @@
+---
+id: TASK-026
+title: 'TUI: Network I/O history sparkline on System page'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: low
+ordinal: 26000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The CPU sparkline tracks 60 samples of CPU history. Add equivalent sparklines for network bytes-in and bytes-out on the System page.
+
+Implementation:
+- Add `net_down_history: deque[float]` and `net_up_history: deque[float]` (maxlen=60) to `SystemStats`
+- Append `down` and `up` values in `sample()` (use 0.0 when delta is unavailable)
+- Add two `Sparkline` widgets in a row below the CPU sparkline or beside it: "Net ↓" and "Net ↑"
+- Border titles show current rate: "Net ↓ 1.2 MB/s"
+- Match the existing sparkline styling (`color: $accent`, round border)
+- Update via the same `_apply_system()` path
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Two sparklines (download, upload) visible on System page
+- [ ] #2 Border titles show current rate in human-readable format
+- [ ] #3 History tracks 60 samples at SYS_REFRESH_SECS interval
+- [ ] #4 Layout doesn't push process table off screen
+<!-- AC:END -->
- → TUI-Sort-process-table-by-memory-with-m-key.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-027
+title: 'TUI: Sort process table by memory with m key'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: low
+ordinal: 27000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The process table on the System page is always sorted by CPU%. Add a toggle so pressing `m` when on the System page (or when the process table is focused) switches sort between CPU% (default) and RSS memory.
+
+Implementation:
+- Add `_proc_sort: str = "cpu"` to `DashboardApp` (or a `SystemPage` widget if pages are refactored)
+- Bind `m` scoped to the system page: `action_toggle_proc_sort()`
+- `_apply_system()` sorts `procs` by the current sort field before adding rows
+- Process table border title reflects active sort: "Processes [CPU]" or "Processes [Mem]"
+- `m` key hint visible in the table border or `Footer`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 m toggles process table sort between CPU% and RSS memory
+- [ ] #2 Current sort mode visible in table border title
+- [ ] #3 Sort persists until toggled again or page changes
+- [ ] #4 Table re-sorts immediately on keypress without waiting for next sample
+<!-- AC:END -->
- → TUI-Persist-theme-selection-across-restarts.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-028
+title: 'TUI: Persist theme selection across restarts'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: low
+ordinal: 28000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Theme resets to `flexoki` every time the TUI starts. Save the selected theme index to a local state file so the last-used theme is restored on next launch.
+
+State file location: `~/.local/share/dashboard/tui_state.json` (create dir if needed).
+
+Implementation:
+- On `action_next_theme()`, write `{"theme_idx": N, "page_id": "..."}` to the state file
+- On `on_mount()`, read the file if it exists and restore `_theme_idx` before applying the theme
+- Use `json` + `pathlib.Path`; silently ignore missing or malformed state file
+- A single state file shared with TASK-022 (page persistence)
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Selected theme persists after quitting and relaunching the TUI
+- [ ] #2 Missing or corrupt state file falls back to flexoki without error
+- [ ] #3 State file is human-readable JSON
+<!-- AC:END -->
- → TUI-Persist-last-active-page-across-restarts.md +34 −0
@@ -0,0 +1,34 @@
+---
+id: TASK-029
+title: 'TUI: Persist last active page across restarts'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies:
+ - TASK-021
+priority: low
+ordinal: 29000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+The TUI always opens on the Personal page. Save and restore the last active page alongside theme state.
+
+State file location: `~/.local/share/dashboard/tui_state.json` (shared with TASK-021).
+
+Implementation:
+- On `action_page()`, update the state file with `"page_id": page_id`
+- On `on_mount()`, read the file and call `action_page(saved_page_id)` after layout is ready
+- Validate that `saved_page_id` is in `PAGES` before applying; fall back to `page-personal`
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 TUI reopens on the page that was active when it was last closed
+- [ ] #2 Invalid saved page falls back to Personal without error
+- [ ] #3 Shares state file with theme persistence (TASK-021)
+<!-- AC:END -->
- → TUI-Preserve-panel-scroll-position-across-data-refreshes.md +33 −0
@@ -0,0 +1,33 @@
+---
+id: TASK-030
+title: 'TUI: Preserve panel scroll position across data refreshes'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies: []
+priority: medium
+ordinal: 30000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Every time `refresh_data()` runs, all `DashPanel` content is replaced via `set_content()`, which resets the scroll position to the top. If a user has scrolled halfway through a news feed, a background refresh jumps them back to the top.
+
+Implementation:
+- Before `set_content()`, save `panel.scroll_y` (or `panel.scroll_offset`)
+- After `set_content()` (which replaces the `Static`), restore the saved scroll position via `panel.scroll_to(y=saved_y, animate=False)`
+- Only restore if the new content height ≥ saved scroll position (avoid restoring to a position that no longer exists after content shrinks)
+- Apply to all `DashPanel` instances
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Scroll position is maintained across automatic background refreshes
+- [ ] #2 Manual r refresh also preserves scroll
+- [ ] #3 Scroll resets to top when the panel's source kind changes or content is significantly shorter
+- [ ] #4 No visible scroll jump during updates
+<!-- AC:END -->
- → TUI-Source-status-page-page-4.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-031
+title: 'TUI: Source status page (page 4)'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 31000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a 4th page accessible via `4` key showing a status table of all sources — useful for debugging stale data, misconfigured sources, and forced refreshes.
+
+Columns: Kind | Enabled | Last Refreshed | Status (ok/error/unconfigured) | Error message (truncated)
+
+Features:
+- `r` on a selected row calls `POST /api/sources/{id}/refresh` to force-refresh that source
+- Rows color-coded: green = ok, red = error, dim = disabled
+- Auto-refreshes the table every 30s alongside the main data refresh
+- Selected row shows full error in a detail area below the table
+
+Implementation:
+- Add `("page-status", "Status")` to `PAGES`
+- Add `4` binding
+- Use `DataTable` widget for the table
+- Fetch from existing `/api/sources` endpoint; all data already available
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 4 key switches to the Status page
+- [ ] #2 Table shows all sources with kind, enabled state, last refresh time, and ok/error status
+- [ ] #3 r on selected row triggers a force-refresh for that source
+- [ ] #4 Error messages visible for failed sources
+- [ ] #5 Page updates on the standard refresh interval
+<!-- AC:END -->
- → TUI-Quick-add-shortcuts-for-task-and-scratch-note.md +37 −0
@@ -0,0 +1,37 @@
+---
+id: TASK-032
+title: 'TUI: Quick-add shortcuts for task and scratch note'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - feature
+dependencies: []
+priority: medium
+ordinal: 32000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Creating a Things task or scratch note currently requires opening the kk overlay, filtering to the entry, then dealing with an input prompt — 3 interactions. Add direct single-key shortcuts that jump straight to the input.
+
+Proposed bindings:
+- `n` → native `KkInputScreen("new task", "task title…")` → runs `task <title>` (Things 3 task creation)
+- `S` (Shift+s) → native `KkInputScreen("scratch", "what's on your mind?")` → runs the existing `_scratch_cmd()`
+
+These bypass the kk menu entirely. They use the existing `KkInputScreen` modal and `run_command()` worker.
+
+Check that `n` and `S` don't conflict with any existing global bindings before landing.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 n key opens a native text prompt and creates a Things task on submit
+- [ ] #2 Shift+S opens a native text prompt and appends a timestamped scratch note
+- [ ] #3 Both bindings work from any page
+- [ ] #4 Empty submit is a no-op (no command run)
+- [ ] #5 Confirmation toast on success
+- [ ] #6 Bindings shown in Footer or help overlay
+<!-- AC:END -->
- → TUI-Auto-select-dark-light-theme-based-on-macOS-appearance.md +44 −0
@@ -0,0 +1,44 @@
+---
+id: TASK-033
+title: 'TUI: Auto-select dark/light theme based on macOS appearance'
+status: To Do
+assignee: []
+created_date: '2026-06-12 04:21'
+labels:
+ - tui
+ - enhancement
+dependencies:
+ - TASK-021
+priority: low
+ordinal: 33000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+On startup, detect macOS Dark Mode and choose the matching theme variant automatically, rather than always defaulting to `flexoki`.
+
+Detection:
+```python
+import subprocess
+result = subprocess.run(
+ ["defaults", "read", "-g", "AppleInterfaceStyle"],
+ capture_output=True, text=True
+)
+is_dark = result.returncode == 0 and "Dark" in result.stdout
+```
+
+Startup behavior:
+- If state file (TASK-021) has a saved theme: use it as-is (user explicitly chose it)
+- If no saved theme: pick `flexoki-dark` when Dark Mode is on, `flexoki` when off
+- `t` cycling still works normally and saves to state file
+- A `--no-auto-theme` CLI flag or constant at top of file disables this behavior
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 First launch with no saved theme picks dark variant in Dark Mode, light variant otherwise
+- [ ] #2 Saved theme from state file takes priority over auto-detection
+- [ ] #3 Detection failure (non-macOS, permission error) falls back to flexoki silently
+- [ ] #4 Auto-detection runs synchronously before the app loop starts (no flash of wrong theme)
+<!-- AC:END -->
components/cards/CardBodies.tsx +206 −27
@@ -19,6 +19,7 @@ import type { UptimePayload } from "@/lib/schemas/sources/uptime";
import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
import type { SportsPayload } from "@/lib/schemas/sources/sports";
import type { LinksPayload } from "@/lib/schemas/sources/links";
+import type { CiderPayload } from "@/lib/schemas/sources/cider";
import type { SourceKind } from "@/lib/schemas/source";
const muted = { color: "var(--text-muted)" };
@@ -28,22 +29,80 @@ function dayName(iso: string) {
return new Date(iso + "T00:00:00").toLocaleDateString(undefined, { weekday: "short" });
}
+function fmtTime(iso: string) {
+ return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+}
+
+function aqiColor(v: number | null): string {
+ if (v == null) return "inherit";
+ if (v <= 50) return "var(--green)";
+ if (v <= 100) return "var(--yellow)";
+ if (v <= 150) return "var(--orange)";
+ if (v <= 200) return "var(--red)";
+ return "var(--purple-400)";
+}
+
+function uvLabel(v: number): string {
+ if (v <= 2) return "Low";
+ if (v <= 5) return "Moderate";
+ if (v <= 7) return "High";
+ if (v <= 10) return "Very High";
+ return "Extreme";
+}
+
function WeatherBody({ p, row }: { p: WeatherPayload; row: boolean }) {
const deg = p.units === "metric" ? "°C" : "°F";
const wind = p.units === "metric" ? "km/h" : "mph";
+ const showFeels =
+ p.current.feelsLike != null && Math.abs(p.current.feelsLike - p.current.temp) >= 2;
+ const highPollen = p.pollen.filter((pl) => pl.level !== "Low");
+
const current = (
<div className={row ? "shrink-0" : ""}>
+ {p.alerts.map((a, i) => (
+ <div
+ key={i}
+ className="text-xs px-2 py-0.5 mb-1.5 rounded"
+ style={{ background: "var(--red-200)", color: "var(--text)" }}
+ >
+ ⚠ {a.event}
+ </div>
+ ))}
<div className="flex items-baseline gap-3">
<span className="text-4xl" style={{ fontFamily: "var(--font-display)" }}>
{p.current.temp}
{deg}
</span>
<span style={muted}>{p.current.text}</span>
+ {showFeels && (
+ <span className="text-xs" style={faint}>
+ feels {p.current.feelsLike}
+ {deg}
+ </span>
+ )}
</div>
<p className="text-xs mt-1" style={faint}>
- {p.location} · wind {p.current.wind} {wind}
- {p.current.humidity != null ? ` · ${p.current.humidity}% humidity` : ""}
+ {p.location} · {p.current.wind} {wind}
+ {p.current.humidity != null ? ` · ${p.current.humidity}% RH` : ""}
+ {p.current.uvIndex != null ? ` · UV ${p.current.uvIndex} ${uvLabel(p.current.uvIndex)}` : ""}
+ {p.aqi != null && (
+ <span style={{ color: aqiColor(p.aqi) }}>
+ {" "}· AQI {p.aqi} {p.aqiCategory}
+ </span>
+ )}
</p>
+ {(p.sunrise || p.sunset || p.moonPhase) && (
+ <p className="text-xs mt-0.5" style={faint}>
+ {p.sunrise ? `↑ ${fmtTime(p.sunrise)}` : ""}
+ {p.sunset ? ` · ↓ ${fmtTime(p.sunset)}` : ""}
+ {p.moonPhase ? ` · ${p.moonEmoji} ${p.moonPhase}` : ""}
+ </p>
+ )}
+ {highPollen.length > 0 && (
+ <p className="text-xs mt-0.5" style={faint}>
+ Pollen: {highPollen.map((pl) => `${pl.label} ${pl.level}`).join(" · ")}
+ </p>
+ )}
</div>
);
const forecast = (
@@ -248,18 +307,31 @@ </ul>
);
}
+function calEventLabel(iso: string, allDay: boolean): string {
+ const d = new Date(iso);
+ const now = new Date();
+ const todayStr = now.toDateString();
+ const isToday = d.toDateString() === todayStr;
+ const datePart = isToday
+ ? "Today"
+ : d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
+ if (allDay) return datePart;
+ const timePart = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
+ return `${datePart} ${timePart}`;
+}
+
function CalendarBody({ p }: { p: CalendarPayload }) {
- if (p.events.length === 0) return <p className="text-sm" style={faint}>No upcoming events.</p>;
+ const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
+ const events = p.events.filter((e) => new Date(e.start).getTime() <= cutoff);
+ if (events.length === 0) return <p className="text-sm" style={faint}>No upcoming events.</p>;
return (
<ul className="space-y-1">
- {p.events.map((e, i) => (
- <li key={i} className="text-sm flex justify-between gap-3">
- <span className="truncate">{e.summary}</span>
- <span className="text-xs whitespace-nowrap" style={faint}>
- {e.allDay
- ? new Date(e.start).toLocaleDateString(undefined, { month: "short", day: "numeric" })
- : shortTime(e.start)}
+ {events.map((e, i) => (
+ <li key={i} className="text-sm flex gap-2">
+ <span className="text-xs whitespace-nowrap shrink-0" style={faint}>
+ {calEventLabel(e.start, e.allDay)}
</span>
+ <span className="truncate">{e.summary}</span>
</li>
))}
</ul>
@@ -270,27 +342,62 @@ function leagueName(path: string) {
return (path.split("/").pop() ?? path).toUpperCase();
}
-function SportsBody({ p }: { p: SportsPayload }) {
- if (p.games.length === 0) return <p className="text-sm" style={faint}>No games.</p>;
- const groups: Record<string, SportsPayload["games"]> = {};
- for (const g of p.games) (groups[g.league] ||= []).push(g);
+type Game = SportsPayload["games"][number];
+
+function gameBucket(g: Game): "live" | "today" | "upcoming" {
+ if (g.state === "in" || g.state === "post") return "live";
+ if (g.startTime) {
+ const d = new Date(g.startTime);
+ return d.toDateString() === new Date().toDateString() ? "today" : "upcoming";
+ }
+ return "today";
+}
+
+function gameLabel(g: Game): { text: string; isLive: boolean; isFinal: boolean } {
+ const isLive = g.state === "in";
+ const isFinal = g.state === "post";
+ if (isLive || isFinal) {
+ const score = g.awayScore != null && g.homeScore != null ? `${g.awayScore}–${g.homeScore}` : "—";
+ const suffix = isFinal
+ ? (g.status.includes("OT") ? " F/OT" : " F")
+ : ` ${g.status}`;
+ return { text: score + suffix, isLive, isFinal };
+ }
+ if (g.startTime) {
+ const d = new Date(g.startTime);
+ const isToday = d.toDateString() === new Date().toDateString();
+ const datePart = isToday
+ ? ""
+ : d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " ";
+ return { text: datePart + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), isLive, isFinal };
+ }
+ return { text: g.status, isLive, isFinal };
+}
+
+const BUCKET_LABELS = { live: "Live / Final", today: "Today", upcoming: "Upcoming" } as const;
+function SportsSection({ label, games }: { label: string; games: Game[] }) {
+ const groups: Record<string, Game[]> = {};
+ for (const g of games) (groups[g.league] ||= []).push(g);
return (
- <div className="space-y-3">
- {Object.entries(groups).map(([league, games]) => (
+ <div className="space-y-2">
+ <h4 className="text-xs font-bold uppercase tracking-widest" style={muted}>{label}</h4>
+ {Object.entries(groups).map(([league, gs]) => (
<div key={league}>
- <h4 className="text-xs uppercase tracking-widest mb-1" style={muted}>
- {leagueName(league)}
- </h4>
+ <h5 className="text-xs uppercase tracking-widest mb-1" style={muted}>{leagueName(league)}</h5>
<ul className="space-y-1">
- {games.map((g, i) => (
- <li key={i} className="text-sm flex justify-between gap-3">
- <span className="truncate">
- {g.away} {g.awayScore ?? ""} @ {g.home} {g.homeScore ?? ""}
- </span>
- <span className="text-xs whitespace-nowrap" style={faint}>{g.status}</span>
- </li>
- ))}
+ {gs.map((g, i) => {
+ const { text, isLive } = gameLabel(g);
+ return (
+ <li key={i} className="text-sm flex gap-2">
+ <span
+ className="text-xs whitespace-nowrap shrink-0"
+ style={isLive ? { color: "var(--text-accent)" } : faint}
+ >{text}</span>
+ <span className="truncate">{g.away} @ {g.home}</span>
+ </li>
+ );
+ })}
</ul>
</div>
))}
@@ -298,6 +405,24 @@ </div>
);
}
+function SportsBody({ p }: { p: SportsPayload }) {
+ const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
+ const games = p.games.filter((g) => !g.startTime || new Date(g.startTime).getTime() <= cutoff);
+ if (games.length === 0) return <p className="text-sm" style={faint}>No games.</p>;
+
+ const buckets: Record<"live" | "today" | "upcoming", Game[]> = { live: [], today: [], upcoming: [] };
+ for (const g of games) buckets[gameBucket(g)].push(g);
+
+ const sections = (["live", "today", "upcoming"] as const).filter((k) => buckets[k].length > 0);
+ return (
+ <div className="space-y-3">
+ {sections.map((k) => (
+ <SportsSection key={k} label={BUCKET_LABELS[k]} games={buckets[k]} />
+ ))}
+ </div>
+ );
+}
+
function LinksBody({ p }: { p: LinksPayload }) {
if (p.links.length === 0) return <p className="text-sm" style={faint}>No links yet.</p>;
return (
@@ -418,6 +543,58 @@ </ul>
);
}
+function CiderBody({ p }: { p: CiderPayload }) {
+ if (!p.playing || !p.track) {
+ return <p className="text-sm" style={faint}>Nothing playing.</p>;
+ }
+ const { name, artist, album, artworkUrl, durationMs, currentMs } = p.track;
+ const pct = durationMs > 0 ? Math.min(100, (currentMs / durationMs) * 100) : 0;
+
+ function fmtDuration(ms: number) {
+ const s = Math.floor(ms / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+ }
+
+ async function skipNext() {
+ await fetch("/api/cider/next", { method: "POST" });
+ }
+
+ return (
+ <div className="flex gap-3 h-full items-start">
+ {artworkUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={artworkUrl}
+ alt={album}
+ width={72}
+ height={72}
+ className="rounded shrink-0"
+ style={{ objectFit: "cover" }}
+ />
+ )}
+ <div className="flex flex-col gap-0.5 min-w-0 flex-1">
+ <p className="font-semibold text-sm leading-snug truncate">{name}</p>
+ <p className="text-sm truncate" style={muted}>{artist}</p>
+ <p className="text-xs truncate" style={faint}>{album}</p>
+ <div className="mt-2 h-1 rounded-full overflow-hidden" style={{ background: "var(--border)" }}>
+ <div className="h-full rounded-full" style={{ width: `${pct}%`, background: "var(--text-accent)" }} />
+ </div>
+ <div className="flex justify-between items-center mt-1">
+ <span className="text-xs tabular-nums" style={faint}>{fmtDuration(currentMs)}</span>
+ <button
+ onClick={skipNext}
+ className="text-xs px-2 py-0.5 rounded"
+ style={{ background: "var(--bg-elevated)", color: "var(--text-muted)", border: "1px solid var(--border)" }}
+ >
+ next ›
+ </button>
+ <span className="text-xs tabular-nums" style={faint}>{fmtDuration(durationMs)}</span>
+ </div>
+ </div>
+ </div>
+ );
+}
+
export function CardBody({
kind,
payload,
@@ -479,6 +656,8 @@ case "sports":
return <SportsBody p={payload as SportsPayload} />;
case "links":
return <LinksBody p={payload as LinksPayload} />;
+ case "cider":
+ return <CiderBody p={payload as CiderPayload} />;
default:
return (
<pre className="text-xs overflow-auto" style={faint}>
lib/edition-data.ts +3 −3
@@ -105,15 +105,15 @@ // what matters first thing in the morning. Weather is the lead paragraph and is
// not listed here. Kinds not listed fall to the end (in source order). `links`
// is intentionally absent — it never belongs in the printed edition.
const PRINT_ORDER: SourceKind[] = [
- "aqi", // air quality — pairs with the weather hero
"calendar", // today's events
"todos", // agenda / tasks
- "markets", // watchlist
"sports", // scores
"feeds", // my feeds
- "hackernews", // tech reading
+ "news", // headlines
"onthisday",
"obsidian", // from the vault
+ "hackernews", // tech reading
+ "markets", // watchlist
"briefs", // news briefs — flow last, as trailing column cards
];
lib/pdf.ts +1 −1
@@ -16,7 +16,7 @@ // Make the data current before snapshotting it into the paper.
await Promise.all(
listSources()
.filter((s) => s.enabled)
- .map((s) => ensureFresh(s)),
+ .map((s) => ensureFresh(s, { force: true })),
);
mkdirSync(EDITIONS_DIR, { recursive: true });
lib/schemas/source.ts +1 −0
@@ -21,6 +21,7 @@ "uptime",
"obsidian",
"todos",
"links",
+ "cider",
] as const;
export const SourceKind = z.enum(SOURCE_KINDS);
lib/schemas/sources/briefs.ts +1 −0
@@ -25,6 +25,7 @@ export const BriefStory = z.object({
title: z.string(),
summary: z.string(),
sources: z.array(z.string()).default([]),
+ link: z.string().optional(), // first source article URL
});
export const BriefSection = z.object({
lib/schemas/sources/cider.ts +22 −0
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+export const CiderConfig = z.object({
+ host: z.string().default("http://localhost:10767"),
+ appToken: z.string().default(""),
+});
+export type CiderConfig = z.infer<typeof CiderConfig>;
+
+export const CiderTrack = z.object({
+ name: z.string(),
+ artist: z.string(),
+ album: z.string(),
+ artworkUrl: z.string().nullable(),
+ durationMs: z.number(),
+ currentMs: z.number(),
+});
+
+export const CiderPayload = z.object({
+ playing: z.boolean(),
+ track: CiderTrack.nullable(),
+});
+export type CiderPayload = z.infer<typeof CiderPayload>;
lib/schemas/sources/sports.ts +4 −0
@@ -12,6 +12,8 @@ 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>;
@@ -20,6 +22,8 @@ games: z.array(
z.object({
league: z.string(),
status: z.string(),
+ // "pre" = scheduled, "in" = live, "post" = final
+ state: z.string().nullable().default(null),
home: z.string(),
away: z.string(),
homeScore: z.string().nullable(),
lib/schemas/sources/weather.ts +23 −0
@@ -26,17 +26,40 @@ text: z.string(),
precipProb: z.number().nullable(),
});
+export const WeatherAlert = z.object({
+ event: z.string(),
+ severity: z.string(), // "Extreme" | "Severe" | "Moderate" | "Minor" | "Unknown"
+ headline: z.string().optional(),
+ ends: z.string().nullable().optional(),
+});
+
+export const WeatherPollen = z.object({
+ label: z.string(),
+ value: z.number(),
+ level: z.string(), // "Low" | "Moderate" | "High" | "Very High"
+});
+
export const WeatherPayload = z.object({
location: z.string(),
units: z.enum(["metric", "imperial"]),
current: z.object({
temp: z.number(),
+ feelsLike: z.number().nullable().default(null),
code: z.number(),
text: z.string(),
wind: z.number(),
humidity: z.number().nullable(),
+ uvIndex: z.number().nullable().default(null),
}),
daily: z.array(WeatherDay),
parts: z.array(WeatherPart).default([]),
+ sunrise: z.string().nullable().default(null),
+ sunset: z.string().nullable().default(null),
+ moonPhase: z.string().default(""),
+ moonEmoji: z.string().default(""),
+ aqi: z.number().nullable().default(null),
+ aqiCategory: z.string().default(""),
+ pollen: z.array(WeatherPollen).default([]),
+ alerts: z.array(WeatherAlert).default([]),
});
export type WeatherPayload = z.infer<typeof WeatherPayload>;
lib/sources/modules/briefs.ts +2 −1
@@ -13,7 +13,7 @@ category?: string;
stories?: Array<{
title?: string;
summary?: string;
- articles?: Array<{ publication?: string }>;
+ articles?: Array<{ publication?: string; link?: string }>;
}>;
}
@@ -76,6 +76,7 @@ summary: s.summary ?? "",
sources: [
...new Set((s.articles ?? []).map((a) => a.publication).filter((p): p is string => !!p)),
],
+ link: (s.articles ?? []).find((a) => a.link)?.link,
}));
return { key, name, date: hit?.date ?? null, stories };
}),
lib/sources/modules/cider.ts +59 −0
@@ -0,0 +1,59 @@
+import type { SourceModule } from "../registry";
+import {
+ CiderConfig,
+ CiderPayload,
+ type CiderConfig as Config,
+ type CiderPayload as Payload,
+} from "@/lib/schemas/sources/cider";
+
+export const ciderModule: SourceModule<Config, Payload> = {
+ kind: "cider",
+ label: "Now Playing",
+ keyless: false,
+ defaultRefreshSeconds: 30,
+ configSchema: CiderConfig,
+ payloadSchema: CiderPayload,
+
+ isConfigured: (config) => config.appToken.length > 0,
+
+ async fetch({ config, signal }) {
+ const res = await fetch(`${config.host}/api/v1/playback/now-playing`, {
+ headers: { apptoken: config.appToken },
+ signal,
+ });
+
+ if (res.status === 204) return { playing: false, track: null };
+ if (!res.ok) throw new Error(`Cider ${res.status}`);
+
+ const j = (await res.json()) as {
+ status?: string;
+ info?: {
+ name?: string;
+ artistName?: string;
+ albumName?: string;
+ artwork?: { url?: string };
+ durationInMillis?: number;
+ currentPlaybackTime?: number;
+ };
+ };
+
+ if (j.status !== "ok" || !j.info) return { playing: false, track: null };
+
+ const info = j.info;
+ const artRaw = info.artwork?.url ?? null;
+ // Replace {w}x{h} template Apple Music uses in artwork URLs
+ const artworkUrl = artRaw ? artRaw.replace("{w}", "300").replace("{h}", "300") : null;
+
+ return {
+ playing: true,
+ track: {
+ name: info.name ?? "Unknown",
+ artist: info.artistName ?? "Unknown",
+ album: info.albumName ?? "Unknown",
+ artworkUrl,
+ durationMs: (info.durationInMillis ?? 0),
+ currentMs: Math.round((info.currentPlaybackTime ?? 0) * 1000),
+ },
+ };
+ },
+};
lib/sources/modules/index.ts +2 −0
@@ -17,6 +17,7 @@ import { calendarModule } from "./calendar";
import { sportsModule } from "./sports";
import { thingsModule } from "./things";
import { linksModule } from "./links";
+import { ciderModule } from "./cider";
// Source modules register here. "todos" has no module — it's local data served
// by /api/todos. Credentialed modules show "needs config" until their env keys
@@ -40,4 +41,5 @@ calendarModule as SourceModule,
sportsModule as SourceModule,
thingsModule as SourceModule,
linksModule as SourceModule,
+ ciderModule as SourceModule,
];
lib/sources/modules/sports.ts +16 −7
@@ -15,7 +15,7 @@ score?: string;
}
interface ESPNEvent {
date?: string;
- status?: { type?: { shortDetail?: string; description?: string } };
+ status?: { type?: { shortDetail?: string; description?: string; state?: string } };
competitions?: Array<{ competitors?: ESPNCompetitor[] }>;
}
@@ -34,11 +34,19 @@
async fetch({ config, signal }) {
const collected = new Map<string, Game>();
- // Fetch one league's scoreboard. If teamFilter is given, keep only games
- // whose home/away matches one of those team substrings.
- async function collect(league: string, teamFilter?: string[]) {
+ // 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.
+ async function collect(league: string, date: string, teamFilter?: string[]) {
const res = await fetch(
- `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard`,
+ `https://site.api.espn.com/apis/site/v2/sports/${league}/scoreboard?dates=${date}`,
{ signal },
);
if (!res.ok) return;
@@ -58,6 +66,7 @@
const game: Game = {
league,
status: ev.status?.type?.shortDetail ?? ev.status?.type?.description ?? "",
+ state: ev.status?.type?.state ?? null,
home: name(home),
away: name(away),
homeScore: home?.score ?? null,
@@ -75,8 +84,8 @@ byLeague.set(league, [...(byLeague.get(league) ?? []), team]);
}
await Promise.all([
- ...config.leagues.map((l) => collect(l)),
- ...[...byLeague].map(([l, teams]) => collect(l, teams)),
+ ...config.leagues.flatMap((l) => dates.map((d) => collect(l, d))),
+ ...[...byLeague].flatMap(([l, teams]) => dates.map((d) => collect(l, d, teams))),
]);
const games = [...collected.values()].sort(
lib/sources/modules/weather.ts +147 −19
@@ -19,7 +19,6 @@ 99: "Thunderstorm + hail",
};
const codeText = (c: number) => WMO[c] ?? "—";
-// Resolve location from source config → settings → env. Returns null if none set.
function resolveLocation(config: Config): { lat: number; lon: number; label: string } | null {
if (config.lat != null && config.lon != null) {
return { lat: config.lat, lon: config.lon, label: config.label ?? "Custom" };
@@ -32,6 +31,52 @@ if (envLat && envLon) return { lat: Number(envLat), lon: Number(envLon), label: "Home" };
return null;
}
+function aqiCategory(v: number | null): string {
+ if (v == null) return "";
+ if (v <= 50) return "Good";
+ if (v <= 100) return "Moderate";
+ if (v <= 150) return "Unhealthy (sensitive)";
+ if (v <= 200) return "Unhealthy";
+ if (v <= 300) return "Very Unhealthy";
+ return "Hazardous";
+}
+
+function pollenLevel(v: number): string {
+ if (v < 10) return "Low";
+ if (v < 30) return "Moderate";
+ if (v < 70) return "High";
+ return "Very High";
+}
+
+const POLLEN_KEYS: Array<[string, string]> = [
+ ["grass_pollen", "Grass"],
+ ["birch_pollen", "Birch"],
+ ["alder_pollen", "Alder"],
+ ["ragweed_pollen", "Ragweed"],
+ ["mugwort_pollen", "Mugwort"],
+ ["olive_pollen", "Olive"],
+];
+
+const SEVERITY_RANK: Record<string, number> = {
+ Extreme: 4, Severe: 3, Moderate: 2, Minor: 1, Unknown: 0,
+};
+
+function moonPhase(date: Date): { phase: string; emoji: string } {
+ // Reference new moon: Jan 6 2000 18:14 UTC
+ const refNewMoon = Date.UTC(2000, 0, 6, 18, 14, 0);
+ const synodicPeriod = 29.53058867;
+ const days = (date.getTime() - refNewMoon) / 86_400_000;
+ const pos = ((days % synodicPeriod) + synodicPeriod) % synodicPeriod;
+ if (pos < 1.85) return { phase: "New Moon", emoji: "🌑" };
+ if (pos < 7.38) return { phase: "Waxing Crescent", emoji: "🌒" };
+ if (pos < 9.22) return { phase: "First Quarter", emoji: "🌓" };
+ if (pos < 14.77) return { phase: "Waxing Gibbous", emoji: "🌔" };
+ if (pos < 16.61) return { phase: "Full Moon", emoji: "🌕" };
+ if (pos < 22.15) return { phase: "Waning Gibbous", emoji: "🌖" };
+ if (pos < 23.99) return { phase: "Last Quarter", emoji: "🌗" };
+ return { phase: "Waning Crescent", emoji: "🌘" };
+}
+
export const weatherModule: SourceModule<Config, Payload> = {
kind: "weather",
label: "Weather",
@@ -50,34 +95,54 @@ const units = getSettings().units;
const tempUnit = units === "metric" ? "celsius" : "fahrenheit";
const windUnit = units === "metric" ? "kmh" : "mph";
- const url = new URL("https://api.open-meteo.com/v1/forecast");
- url.searchParams.set("latitude", String(place.lat));
- url.searchParams.set("longitude", String(place.lon));
- url.searchParams.set(
+ const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast");
+ weatherUrl.searchParams.set("latitude", String(place.lat));
+ weatherUrl.searchParams.set("longitude", String(place.lon));
+ weatherUrl.searchParams.set(
"current",
- "temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m",
+ "temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,uv_index",
);
- url.searchParams.set(
+ weatherUrl.searchParams.set(
"daily",
- "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max",
+ "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max,sunrise,sunset",
);
- url.searchParams.set(
+ weatherUrl.searchParams.set(
"hourly",
"temperature_2m,weather_code,precipitation_probability",
);
- url.searchParams.set("temperature_unit", tempUnit);
- url.searchParams.set("wind_speed_unit", windUnit);
- url.searchParams.set("timezone", "auto");
- url.searchParams.set("forecast_days", "3");
+ weatherUrl.searchParams.set("temperature_unit", tempUnit);
+ weatherUrl.searchParams.set("wind_speed_unit", windUnit);
+ weatherUrl.searchParams.set("timezone", "auto");
+ weatherUrl.searchParams.set("forecast_days", "3");
- const res = await fetch(url, { signal });
- if (!res.ok) throw new Error(`Open-Meteo ${res.status}`);
- const j = (await res.json()) as {
+ const aqiUrl = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
+ aqiUrl.searchParams.set("latitude", String(place.lat));
+ aqiUrl.searchParams.set("longitude", String(place.lon));
+ aqiUrl.searchParams.set("timezone", "auto");
+ aqiUrl.searchParams.set("current", "us_aqi,pm2_5");
+ aqiUrl.searchParams.set("hourly", POLLEN_KEYS.map(([k]) => k).join(","));
+
+ const alertsUrl =
+ `https://api.weather.gov/alerts/active?point=${place.lat},${place.lon}`;
+
+ const [weatherRes, aqiRes, alertsRes] = await Promise.all([
+ fetch(weatherUrl, { signal }),
+ fetch(aqiUrl, { signal }).catch(() => null),
+ fetch(alertsUrl, {
+ signal,
+ headers: { "User-Agent": "personal-dashboard/1.0" },
+ }).catch(() => null),
+ ]);
+
+ if (!weatherRes.ok) throw new Error(`Open-Meteo ${weatherRes.status}`);
+ const j = (await weatherRes.json()) as {
current: {
temperature_2m: number;
+ apparent_temperature: number;
weather_code: number;
wind_speed_10m: number;
relative_humidity_2m: number;
+ uv_index: number | null;
};
daily: {
time: string[];
@@ -85,6 +150,8 @@ temperature_2m_max: number[];
temperature_2m_min: number[];
weather_code: number[];
precipitation_probability_max: (number | null)[];
+ sunrise: string[];
+ sunset: string[];
};
hourly?: {
time: string[];
@@ -94,6 +161,56 @@ precipitation_probability: (number | null)[];
};
};
+ // --- AQI + pollen ---
+ let aqi: number | null = null;
+ let aqiCat = "";
+ const pollen: Payload["pollen"] = [];
+ if (aqiRes?.ok) {
+ const aq = (await aqiRes.json()) as {
+ current?: { time?: string; us_aqi?: number | null; pm2_5?: number | null };
+ hourly?: { time: string[] } & Record<string, (number | null)[]>;
+ };
+ aqi = aq.current?.us_aqi ?? null;
+ aqiCat = aqiCategory(aqi);
+ if (aq.hourly?.time?.length) {
+ const nowHour = (aq.current?.time ?? aq.hourly.time[0]).slice(0, 13);
+ let idx = aq.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour);
+ if (idx < 0) idx = 0;
+ for (const [key, label] of POLLEN_KEYS) {
+ const v = aq.hourly[key]?.[idx];
+ if (typeof v === "number" && v > 0) {
+ pollen.push({ label, value: Math.round(v), level: pollenLevel(v) });
+ }
+ }
+ }
+ }
+
+ // --- NWS alerts (US only; non-US locations 404 or error → empty) ---
+ const alerts: Payload["alerts"] = [];
+ if (alertsRes?.ok) {
+ const al = (await alertsRes.json()) as {
+ features?: Array<{
+ properties: {
+ event?: string;
+ severity?: string;
+ headline?: string;
+ ends?: string | null;
+ };
+ }>;
+ };
+ const raw = (al.features ?? [])
+ .filter((f) => f.properties.event)
+ .map((f) => ({
+ event: f.properties.event!,
+ severity: f.properties.severity ?? "Unknown",
+ headline: f.properties.headline,
+ ends: f.properties.ends ?? null,
+ }));
+ raw.sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0));
+ alerts.push(...raw.slice(0, 3));
+ }
+
+ // --- Daily ---
const daily = j.daily.time.map((date, i) => ({
date,
max: Math.round(j.daily.temperature_2m_max[i]),
@@ -103,9 +220,7 @@ text: codeText(j.daily.weather_code[i]),
precipProb: j.daily.precipitation_probability_max[i] ?? null,
}));
- // Today split into four day-parts for the printed weather strip. Open-Meteo
- // hourly times are local ("auto" tz), formatted "YYYY-MM-DDTHH:00". Pick the
- // hour nearest each anchor (8am / 12pm / 6pm / 10pm) within today only.
+ // --- Day-parts strip ---
const today = j.daily.time[0];
const parts: Payload["parts"] = [];
if (j.hourly && today) {
@@ -131,19 +246,32 @@ precipProb: j.hourly.precipitation_probability[best.i] ?? null,
});
}
}
+
+ // --- Moon phase ---
+ const moon = moonPhase(new Date());
return {
location: place.label,
units,
current: {
temp: Math.round(j.current.temperature_2m),
+ feelsLike: Math.round(j.current.apparent_temperature),
code: j.current.weather_code,
text: codeText(j.current.weather_code),
wind: Math.round(j.current.wind_speed_10m),
humidity: j.current.relative_humidity_2m ?? null,
+ uvIndex: j.current.uv_index != null ? Math.round(j.current.uv_index) : null,
},
daily,
parts,
+ sunrise: j.daily.sunrise?.[0] ?? null,
+ sunset: j.daily.sunset?.[0] ?? null,
+ moonPhase: moon.phase,
+ moonEmoji: moon.emoji,
+ aqi,
+ aqiCategory: aqiCat,
+ pollen,
+ alerts,
};
},
};
package.json +2 −2
@@ -5,8 +5,8 @@ "private": true,
"type": "module",
"scripts": {
"dev": "SERWIST_SUPPRESS_TURBOPACK_WARNING=1 next dev --turbopack -p 4317",
- "build": "next build --webpack",
- "start": "next start -p 4317",
+ "build": "PATH=/opt/homebrew/opt/node@22/bin:$PATH next build --webpack",
+ "start": "PATH=/opt/homebrew/opt/node@22/bin:$PATH next start -p 4317",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
pnpm-workspace.yaml +5 −0
@@ -0,0 +1,5 @@
+allowBuilds:
+ better-sqlite3: true
+ esbuild: true
+ puppeteer: true
+ sharp: true
pnpm.yaml +6 −0
@@ -0,0 +1,6 @@
+onlyBuiltDependencies:
+ - better-sqlite3
+ - puppeteer
+ - esbuild
+ - "@tailwindcss/oxide"
+ - sharp
scripts/seed.ts +1 −0
@@ -83,6 +83,7 @@ { 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: "" } },
];
function main() {
tui/dashboard.py +1422 −0
@@ -0,0 +1,1422 @@
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.13"
+# dependencies = ["textual>=1.0,<2.0", "httpx>=0.27", "psutil>=6"]
+# ///
+"""
+Personal Dashboard TUI — polls the Next.js app (localhost:4317) and renders
+source snapshots full-screen. Auto-starts the production server if needed.
+
+Usage: uv run tui/dashboard.py
+Keys: 1/2/3 = pages [ ] = cycle pages : = command bar k = kk menu
+ r = refresh t = cycle theme q = quit
+
+Command bar (:): runs through `zsh -ic` so aliases/functions (tock, doing,
+note, daily-append, task…) all work. Prefix with ! to suspend the TUI and run
+interactively in the terminal — needed for gum prompts and full TUIs.
+
+kk overlay (k): native fuzzy-filter version of the dash menu, parsed live from
+the entries array in ~/.zshrc. Picks resolve in tiers, staying inside the TUI
+when possible: KK_INPUT entries (scratch, note, dict, am search/vol) get a
+native text prompt; kk_choices entries (periodic notes, dayreview, daydata,
+zine) get a native two-option pick; "am playlist" fetches the list and picks
+natively; KK_CAPTURED entries run straight through. Only real TUIs and complex
+gum flows (task, event, md, vault, newsboat, …) suspend and run `dash "<key>"`
+in the terminal (dash accepts a key argument to skip its gum menu).
+"""
+from __future__ import annotations
+
+import re
+import shlex
+import subprocess
+import time
+from collections import deque
+from datetime import date, datetime
+from pathlib import Path
+
+import httpx
+import psutil
+from textual import work
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, ScrollableContainer, Vertical
+from textual.screen import ModalScreen
+from textual.theme import Theme
+from textual.widgets import (
+ ContentSwitcher, DataTable, Footer, Header, Input, OptionList,
+ Sparkline, Static,
+)
+from textual.widgets.option_list import Option
+from rich.style import Style
+from rich.text import Text
+
+BASE_URL = "http://localhost:4317"
+REFRESH_SECS = 30
+SYS_REFRESH_SECS = 2.5
+REPO = Path(__file__).parent.parent
+
+PAGES = [
+ ("page-personal", "Personal"),
+ ("page-news", "News"),
+ ("page-system", "System"),
+]
+
+THEME_NAMES = ["flexoki", "flexoki-dark", "humdrum", "humdrum-dark"]
+
+# Owner palettes — mirrored from Donuts/themes.py + shared app kit tokens.css
+_THEMES: dict[str, Theme] = {
+ "flexoki": Theme(
+ name="flexoki",
+ primary="#205EA6",
+ secondary="#24837B",
+ warning="#AD8301",
+ error="#AF3029",
+ success="#66800B",
+ accent="#5E409D",
+ background="#FFFCF0",
+ surface="#F2F0E5",
+ panel="#CECDC3",
+ foreground="#100F0F",
+ dark=False,
+ ),
+ "flexoki-dark": Theme(
+ name="flexoki-dark",
+ primary="#4385BE",
+ secondary="#3AA99F",
+ warning="#D0A215",
+ error="#D14D41",
+ success="#879A39",
+ accent="#8B7EC8",
+ background="#100F0F",
+ surface="#1C1B1A",
+ panel="#282726",
+ foreground="#CECDC3",
+ dark=True,
+ ),
+ "humdrum": Theme(
+ name="humdrum",
+ primary="#0F80EA",
+ secondary="#0054A6",
+ warning="#985F00",
+ error="#BA333C",
+ success="#258200",
+ accent="#7550C2",
+ background="#F5F3EE",
+ surface="#FFFFFF",
+ panel="#C3BFB3",
+ foreground="#2A2825",
+ dark=False,
+ ),
+ "humdrum-dark": Theme(
+ name="humdrum-dark",
+ primary="#0F80EA",
+ secondary="#63A8F7",
+ warning="#CB9D2A",
+ error="#ED807E",
+ success="#75B966",
+ accent="#AB92F0",
+ background="#1F1D1A",
+ surface="#282622",
+ panel="#32302C",
+ foreground="#E8E5DD",
+ dark=True,
+ ),
+}
+
+
+# ── Server bootstrap ──────────────────────────────────────────────────────────
+
+def ensure_server() -> bool:
+ """Start the production Next.js server if not already responding."""
+ try:
+ httpx.get(f"{BASE_URL}/api/health", timeout=2)
+ return True
+ except Exception:
+ pass
+
+ build_id = REPO / ".next" / "BUILD_ID"
+ if not build_id.exists():
+ print(f"No production build at {REPO}. Run: pnpm build")
+ return False
+
+ print("Starting dashboard server…", flush=True)
+ subprocess.Popen(
+ ["pnpm", "start"],
+ cwd=REPO,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ for _ in range(45):
+ time.sleep(1)
+ try:
+ httpx.get(f"{BASE_URL}/api/health", timeout=1)
+ return True
+ except Exception:
+ pass
+ print("Server did not start in time.")
+ return False
+
+
+# ── Fetching ──────────────────────────────────────────────────────────────────
+
+async def fetch_sources() -> list[dict]:
+ try:
+ async with httpx.AsyncClient(timeout=15) as c:
+ r = await c.get(f"{BASE_URL}/api/sources")
+ r.raise_for_status()
+ return r.json().get("sources", [])
+ except Exception:
+ return []
+
+
+def payload(sources: list[dict], kind: str) -> dict | None:
+ for s in sources:
+ src = s.get("source") or {}
+ snap = s.get("snapshot") or {}
+ if src.get("kind") == kind and src.get("enabled") and snap.get("ok"):
+ return snap.get("payload")
+ return None
+
+
+def payloads(sources: list[dict], *kinds: str) -> list[dict]:
+ out = []
+ for s in sources:
+ src = s.get("source") or {}
+ snap = s.get("snapshot") or {}
+ if src.get("kind") in kinds and src.get("enabled") and snap.get("ok"):
+ if p := snap.get("payload"):
+ out.append(p)
+ return out
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+_7DAYS_MS = 7 * 24 * 60 * 60 * 1000
+
+
+def to_local(iso: str) -> datetime:
+ """Parse ISO string (may be UTC/tz-aware) and return local naive datetime."""
+ d = datetime.fromisoformat(iso)
+ if d.tzinfo is not None:
+ d = d.astimezone().replace(tzinfo=None)
+ return d
+
+
+def within_7_days(iso: str | None) -> bool:
+ if not iso:
+ return True
+ try:
+ t = datetime.fromisoformat(iso).timestamp() * 1000
+ return t <= (datetime.now().timestamp() * 1000 + _7DAYS_MS)
+ except Exception:
+ return True
+
+
+def hm(iso: str) -> str:
+ try:
+ return to_local(iso).strftime("%-I:%M %p")
+ except Exception:
+ return iso[:5] if len(iso) >= 5 else iso
+
+
+def event_label(iso: str, all_day: bool) -> str:
+ try:
+ d = to_local(iso)
+ is_today = d.date() == date.today()
+ date_part = "Today" if is_today else d.strftime("%a %b %-d")
+ return date_part if all_day else f"{date_part} {d.strftime('%-I:%M %p')}"
+ except Exception:
+ return iso[:16]
+
+
+def day_abbr(iso: str) -> str:
+ try:
+ return date.fromisoformat(iso).strftime("%a")
+ except Exception:
+ return iso[:3]
+
+
+def aqi_style(v: int | None) -> str:
+ if v is None: return "dim"
+ if v <= 50: return "green"
+ if v <= 100: return "yellow"
+ if v <= 150: return "dark_orange"
+ if v <= 200: return "red"
+ return "magenta"
+
+
+def uv_label(v: int | None) -> str:
+ if v is None: return ""
+ if v <= 2: return "Low"
+ if v <= 5: return "Moderate"
+ if v <= 7: return "High"
+ if v <= 10: return "Very High"
+ return "Extreme"
+
+
+# ── Renderers ─────────────────────────────────────────────────────────────────
+
+def render_weather(p: dict) -> Text:
+ t = Text()
+ units = p.get("units", "imperial")
+ deg = "°C" if units == "metric" else "°F"
+ wu = "km/h" if units == "metric" else "mph"
+ cur = p.get("current", {})
+
+ for a in p.get("alerts", []):
+ t.append(f"⚠ {a['event']}\n", style="bold red")
+
+ t.append(f"{cur.get('temp', '—')}{deg}", style="bold yellow")
+ t.append(f" {cur.get('text', '')}\n")
+
+ details: list[str] = []
+ feels = cur.get("feelsLike")
+ if feels is not None and abs(feels - (cur.get("temp") or feels)) >= 2:
+ details.append(f"Feels {feels}{deg}")
+ if cur.get("wind"):
+ details.append(f"Wind {cur['wind']} {wu}")
+ if cur.get("humidity") is not None:
+ details.append(f"{cur['humidity']}% RH")
+ if cur.get("uvIndex") is not None:
+ details.append(f"UV {cur['uvIndex']} {uv_label(cur['uvIndex'])}")
+ if details:
+ t.append(" · ".join(details) + "\n", style="dim")
+
+ if (aqi := p.get("aqi")) is not None:
+ t.append(f"AQI {aqi} ", style=aqi_style(aqi))
+ t.append(f"{p.get('aqiCategory', '')}\n", style="dim")
+
+ astro: list[str] = []
+ if p.get("sunrise"): astro.append(f"↑ {hm(p['sunrise'])}")
+ if p.get("sunset"): astro.append(f"↓ {hm(p['sunset'])}")
+ if p.get("moonPhase"): astro.append(f"{p.get('moonEmoji', '')} {p['moonPhase']}")
+ if astro:
+ t.append(" · ".join(astro) + "\n", style="dim")
+
+ high_pollen = [pl for pl in p.get("pollen", []) if pl.get("level") != "Low"]
+ if high_pollen:
+ t.append("Pollen: " + " · ".join(
+ f"{pl['label']} {pl['level']}" for pl in high_pollen
+ ) + "\n", style="dim")
+
+ t.append("\n")
+ for d in p.get("daily", [])[:3]:
+ t.append(f"{day_abbr(d.get('date', '')):<4}", style="bold dim")
+ t.append(f" {d.get('max', '—')}° / {d.get('min', '—')}°")
+ if prob := d.get("precipProb"):
+ t.append(f" {prob}%", style="blue")
+ t.append("\n")
+ return t
+
+
+def render_calendar(p: dict) -> Text:
+ events = [e for e in p.get("events", []) if within_7_days(e.get("start"))]
+ if not events:
+ return Text("No upcoming events.", style="dim italic")
+ t = Text()
+ for e in events:
+ label = event_label(e.get("start", ""), e.get("allDay", False))
+ t.append(f"{label:<22} ", style="dim")
+ t.append(f"{e.get('summary', '?')}\n")
+ return t
+
+
+def render_todos(p: dict) -> Text:
+ tasks = p.get("tasks", [])
+ if not tasks:
+ return Text("Nothing today.", style="dim italic")
+ t = Text()
+ for task in tasks:
+ t.append("☐ ")
+ t.append(task.get("title", "?"))
+ if proj := task.get("project"):
+ t.append(f" {proj}", style="dim")
+ t.append("\n")
+ return t
+
+
+def render_markets(p: dict) -> Text:
+ quotes = p.get("quotes", [])
+ if not quotes:
+ return Text("No quotes.", style="dim italic")
+ t = Text()
+ for q in quotes:
+ pct = q.get("changePct")
+ price = q.get("price")
+ t.append(f"{q.get('symbol', '?'):<6}", style="bold")
+ if price is not None:
+ t.append(f" {price:>12,.2f}")
+ if pct is not None:
+ color = "green" if pct >= 0 else "red"
+ t.append(f" {'+'if pct>=0 else ''}{pct:.2f}%", style=color)
+ t.append("\n")
+ return t
+
+
+def _link(url: str) -> Style:
+ safe = url.replace('"', "%22").replace("'", "%27")
+ return Style(underline=True, meta={"@click": f'app.open_link("{safe}")'})
+
+
+def render_news(ps: list[dict], cap: int = 12) -> Text:
+ t, seen, n = Text(), set(), 0
+ for p in ps:
+ for item in p.get("items", []):
+ if n >= cap:
+ break
+ title = item.get("title", "?")
+ if title in seen:
+ continue
+ seen.add(title)
+ n += 1
+ url = item.get("link", "")
+ t.append("• ", style="dim")
+ t.append(f"{title}\n", style=_link(url) if url else "")
+ if src := item.get("source"):
+ t.append(f" {src}\n", style="dim")
+ if n == 0:
+ return Text("No headlines.", style="dim italic")
+ return t
+
+
+def render_feeds(ps: list[dict], cap: int = 12) -> Text:
+ t, seen, n = Text(), set(), 0
+ for p in ps:
+ for item in p.get("items", []):
+ if n >= cap:
+ break
+ title = item.get("title", "?")
+ if title in seen:
+ continue
+ seen.add(title)
+ n += 1
+ url = item.get("link", "")
+ t.append("• ", style="dim")
+ t.append(f"{title}\n", style=_link(url) if url else "")
+ if src := item.get("source") or item.get("feedTitle"):
+ t.append(f" {src}\n", style="dim")
+ if n == 0:
+ return Text("No feed items.", style="dim italic")
+ return t
+
+
+def render_hackernews(p: dict, cap: int = 10) -> Text:
+ stories = p.get("stories", [])[:cap]
+ if not stories:
+ return Text("No stories.", style="dim italic")
+ t = Text()
+ for s in stories:
+ url = s.get("url") or ""
+ t.append("• ", style="dim")
+ t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "")
+ if src := s.get("source"):
+ t.append(f" {src}\n", style="dim")
+ return t
+
+
+def _game_bucket(g: dict) -> str:
+ state = g.get("state")
+ if state == "in":
+ return "live"
+ if state == "post":
+ return "live"
+ # 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"
+
+
+def render_sports(p: dict) -> Text:
+ all_games = [g for g in p.get("games", []) if within_7_days(g.get("startTime"))]
+ if not all_games:
+ return Text("No games.", style="dim italic")
+
+ buckets: dict[str, list[dict]] = {"live": [], "today": [], "upcoming": []}
+ for g in all_games:
+ buckets[_game_bucket(g)].append(g)
+
+ HEADERS = {"live": "Live / Final", "today": "Today", "upcoming": "Upcoming"}
+ t = Text()
+ first_section = True
+
+ for key in ("live", "today", "upcoming"):
+ games = buckets[key]
+ if not games:
+ continue
+ if not first_section:
+ t.append("\n")
+ first_section = False
+ t.append(f"{HEADERS[key]}\n", style="bold")
+
+ cur_league = None
+ for g in games:
+ 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" ")
+ t.append(f"{lbl:<18}", style=lbl_style)
+ t.append(f"{g.get('away', '?')} @ {g.get('home', '?')}\n")
+
+ return t
+
+
+def render_uptime(p: dict) -> Text:
+ sites = p.get("sites", [])
+ if not sites:
+ return Text("No sites configured.", style="dim italic")
+ t = Text()
+ for s in sites:
+ ok = s.get("ok", False)
+ url = s.get("url", "?")
+ label = url.replace("https://", "").replace("http://", "").rstrip("/")
+ status_style = "green" if ok else "bold red"
+ status_icon = "●" if ok else "○"
+ t.append(f"{status_icon} ", style=status_style)
+ t.append(f"{label:<40}")
+ if ms := s.get("ms"):
+ t.append(f" {ms}ms", style="dim")
+ elif not ok:
+ if code := s.get("status"):
+ t.append(f" HTTP {code}", style="red")
+ else:
+ t.append(" down", style="red")
+ t.append("\n")
+ return t
+
+
+def render_briefs(p: dict) -> Text:
+ cats = [c for c in p.get("categories", []) if c.get("stories")]
+ if not cats:
+ return Text("No briefs.", style="dim italic")
+ t = Text()
+ for cat in cats:
+ t.append(f"{cat.get('name', '?').upper()}\n", style="bold")
+ for s in cat.get("stories", [])[:3]:
+ url = s.get("link") or ""
+ t.append(" • ", style="dim")
+ t.append(f"{s.get('title', '?')}\n", style=_link(url) if url else "")
+ if summ := s.get("summary"):
+ t.append(f" {summ[:120]}\n", style="dim")
+ return t
+
+
+# ── System stats ──────────────────────────────────────────────────────────────
+
+def _duration(secs: float) -> str:
+ secs = int(secs)
+ d, rem = divmod(secs, 86400)
+ h, rem = divmod(rem, 3600)
+ m, s = divmod(rem, 60)
+ if d:
+ return f"{d}d {h}h {m}m"
+ if h:
+ return f"{h}h {m}m"
+ if m:
+ return f"{m}m {s}s"
+ return f"{s}s"
+
+
+def _rate(bps: float | None) -> str:
+ if bps is None:
+ return "—"
+ if bps >= 1024 * 1024:
+ return f"{bps / (1024 * 1024):.1f} MB/s"
+ if bps >= 1024:
+ return f"{bps / 1024:.1f} KB/s"
+ return f"{bps:.0f} B/s"
+
+
+def _gb(n: float) -> float:
+ return n / 2**30
+
+
+def _idle_seconds() -> float | None:
+ """Seconds since last keyboard/mouse input, via IOKit's HIDIdleTime."""
+ try:
+ out = subprocess.run(
+ ["ioreg", "-c", "IOHIDSystem"],
+ capture_output=True, text=True, timeout=3,
+ ).stdout
+ if m := re.search(r'"HIDIdleTime" = (\d+)', out):
+ return int(m.group(1)) / 1e9
+ except Exception:
+ pass
+ return None
+
+
+class SystemStats:
+ """Collects local host metrics. sample() is blocking — call off the UI thread."""
+
+ def __init__(self) -> None:
+ psutil.cpu_percent(interval=None) # prime; next call returns a real delta
+ self.cpu_history: deque[float] = deque([0.0] * 60, maxlen=60)
+ self._last_net = psutil.net_io_counters()
+ self._last_net_t = time.monotonic()
+
+ def sample(self) -> dict:
+ cpu = psutil.cpu_percent(interval=None)
+ self.cpu_history.append(cpu)
+
+ net = psutil.net_io_counters()
+ now = time.monotonic()
+ dt = now - self._last_net_t
+ down = up = None
+ if dt > 0.5:
+ down = max(0.0, (net.bytes_recv - self._last_net.bytes_recv) / dt)
+ up = max(0.0, (net.bytes_sent - self._last_net.bytes_sent) / dt)
+ self._last_net, self._last_net_t = net, now
+
+ procs = []
+ for pr in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]):
+ info = pr.info
+ if info.get("cpu_percent") is None:
+ continue
+ procs.append(info)
+ procs.sort(key=lambda i: i["cpu_percent"], reverse=True)
+
+ return {
+ "cpu": cpu,
+ "load": psutil.getloadavg(),
+ "cores": psutil.cpu_count() or 0,
+ "mem": psutil.virtual_memory(),
+ "disk": psutil.disk_usage("/"),
+ "down": down,
+ "up": up,
+ "battery": psutil.sensors_battery(),
+ "uptime": time.time() - psutil.boot_time(),
+ "idle": _idle_seconds(),
+ "procs": procs[:12],
+ "history": list(self.cpu_history),
+ }
+
+
+def render_cpu(s: dict) -> Text:
+ t = Text()
+ t.append(f"{s['cpu']:.1f}", style="bold yellow")
+ t.append(" %\n")
+ l1, l5, l15 = s["load"]
+ t.append(f"Load {l1:.2f} · {l5:.2f} · {l15:.2f}\n", style="dim")
+ t.append(f"{s['cores']} cores\n", style="dim")
+ return t
+
+
+def render_memdisk(s: dict) -> Text:
+ mem, disk = s["mem"], s["disk"]
+ t = Text()
+ t.append(f"{mem.percent:.0f}", style="bold yellow")
+ t.append(" % mem\n")
+ t.append(f"{_gb(mem.used):.1f} / {_gb(mem.total):.0f} GB\n", style="dim")
+ t.append("\n")
+ t.append(f"{_gb(disk.free):.0f} GB", style="bold")
+ t.append(" disk free\n")
+ t.append(f"{_gb(disk.used):.0f} GB used · {disk.percent:.0f}%\n", style="dim")
+ return t
+
+
+def render_net(s: dict) -> Text:
+ t = Text()
+ t.append("↓ ", style="green")
+ t.append(f"{_rate(s['down'])}\n", style="bold")
+ t.append("↑ ", style="blue")
+ t.append(f"{_rate(s['up'])}\n", style="bold")
+ return t
+
+
+def render_power(s: dict) -> Text:
+ t = Text()
+ if batt := s["battery"]:
+ t.append(f"{batt.percent:.0f}", style="bold yellow")
+ t.append(" % battery")
+ if batt.power_plugged:
+ t.append(" ⚡", style="yellow")
+ t.append("\n")
+ if batt.power_plugged or batt.secsleft == psutil.POWER_TIME_UNLIMITED:
+ t.append("on AC\n", style="dim")
+ elif batt.secsleft == psutil.POWER_TIME_UNKNOWN:
+ t.append("—\n", style="dim")
+ else:
+ t.append(f"{_duration(batt.secsleft)} left\n", style="dim")
+ else:
+ t.append("No battery\n", style="dim italic")
+ t.append("\n")
+ t.append("Up ", style="dim")
+ t.append(_duration(s["uptime"]))
+ t.append("\n")
+ if (idle := s["idle"]) is not None:
+ t.append("Away ", style="dim")
+ t.append(_duration(idle))
+ t.append("\n")
+ return t
+
+
+# ── Widget ────────────────────────────────────────────────────────────────────
+
+class DashPanel(ScrollableContainer):
+ DEFAULT_CSS = """
+ DashPanel {
+ border: round $panel;
+ border-title-color: $primary;
+ padding: 0 1;
+ height: 100%;
+ scrollbar-size: 1 1;
+ }
+ DashPanel:focus {
+ border: round $accent;
+ border-title-color: $accent;
+ }
+ """
+
+ def __init__(self, title: str, panel_id: str, **kwargs):
+ super().__init__(id=panel_id, **kwargs)
+ self.border_title = title
+ self._body = Static("")
+
+ def compose(self) -> ComposeResult:
+ yield self._body
+
+ 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
+# TUI and runs `dash "<key>"` interactively (gum prompts, fzf pickers, TUIs).
+KK_CAPTURED = {
+ "day", "today", "week", "todos", "tasks", "weather", "claude-status",
+ "installed", "yesterday-note", "morning-paper --print",
+ "am play", "am pause", "am next", "am prev", "am now",
+}
+
+# Entries whose only interactivity is one text prompt — replicated natively.
+# key → (placeholder, command builder, allow_empty)
+def _scratch_cmd(text: str) -> str:
+ stamp = datetime.now().strftime("%I:%M %p")
+ return f"daily-append {shlex.quote(f'- `{stamp}` – {text}')}"
+
+
+KK_INPUT: dict[str, tuple[str, object, bool]] = {
+ "scratch": ("what's on your mind?", _scratch_cmd, False),
+ "note": ("the thought (AI names/files it)", lambda t: f"note {shlex.quote(t)}", False),
+ "dict": ("word", lambda t: f"dict {shlex.quote(t)}", False),
+ "am search": ("search library", lambda t: f"am search {shlex.quote(t)}", False),
+ "am vol": ("volume 0-100 (blank = show)",
+ lambda t: f"am vol {shlex.quote(t)}" if t else "am vol", True),
+}
+
+# Long-running captured commands (claude generation, puppeteer) get more rope.
+KK_SLOW_TIMEOUT = 600
+
+
+def kk_choices(key: str) -> list[tuple[str, str]] | None:
+ """(command, label) pairs for entries that branch on a small pick."""
+ if key in ("weeknotes", "monthnotes", "quarternotes", "yearnotes"):
+ period = key.removesuffix("notes")
+ return [(key, f"this {period}"), (f"{key} last", f"last {period}")]
+ if key == "dayreview":
+ return [("_dayreview", "yesterday"), ("_dayreview today", "today")]
+ if key == "daydata":
+ return [("_daydata", "yesterday"), ("_daydata today", "today")]
+ if key == "zine":
+ now = datetime.now()
+ this = now.strftime("%Y-%m")
+ nxt = f"{now.year + (now.month == 12):04d}-{(now.month % 12) + 1:02d}"
+ return [(f"_zine {this}", "this month"), (f"_zine {nxt}", "next month")]
+ return None
+
+
+def load_dash_entries() -> list[tuple[str, str]]:
+ """Parse the dash() entries array from ~/.zshrc → (key, label) pairs."""
+ try:
+ text = (Path.home() / ".zshrc").read_text()
+ except Exception:
+ return []
+ body = re.search(r"^dash\(\)\s*\{(.+?)^\}", text, re.S | re.M)
+ if not body:
+ return []
+ arr = re.search(r"entries=\((.*?)\n\s*\)", body.group(1), re.S)
+ if not arr:
+ return []
+ out: list[tuple[str, str]] = []
+ for line in arr.group(1).splitlines():
+ if m := re.match(r'^\s*"([^|"]+)\|(.*)"\s*$', line):
+ key, label = m.group(1), m.group(2)
+ if key == "dashboard": # that's this app — skip
+ continue
+ out.append((key, label))
+ return out
+
+
+class KkFilterInput(Input):
+ def on_key(self, event) -> None:
+ if event.key == "escape":
+ event.stop()
+ event.prevent_default()
+ self.screen.dismiss(None)
+ elif event.key in ("up", "down"):
+ event.stop()
+ event.prevent_default()
+ lst = self.screen.query_one(OptionList)
+ if event.key == "up":
+ lst.action_cursor_up()
+ else:
+ lst.action_cursor_down()
+
+
+class KkScreen(ModalScreen[str | None]):
+ """Native fuzzy-filter version of the dash/kk gum menu."""
+
+ BINDINGS = [Binding("escape", "close", "Close", show=False)]
+ DEFAULT_CSS = """
+ KkScreen { align: center middle; }
+ #kk-box {
+ width: 84;
+ height: auto;
+ max-height: 80%;
+ background: $surface;
+ border: round $accent;
+ border-title-color: $accent;
+ padding: 0 1;
+ }
+ #kk-box Input { border: round $panel; }
+ #kk-list {
+ height: auto;
+ max-height: 24;
+ background: $surface;
+ scrollbar-size: 1 1;
+ }
+ """
+
+ def __init__(self, entries: list[tuple[str, str]], title: str = "kk — pick a command"):
+ super().__init__()
+ self._entries = entries
+ self._title = title
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="kk-box"):
+ yield KkFilterInput(placeholder="filter…", id="kk-filter")
+ yield OptionList(id="kk-list")
+
+ def on_mount(self) -> None:
+ self.query_one("#kk-box", Vertical).border_title = self._title
+ self._refilter("")
+ self.query_one("#kk-filter", KkFilterInput).focus()
+
+ def _refilter(self, query: str) -> None:
+ lst = self.query_one(OptionList)
+ lst.clear_options()
+ words = query.lower().split()
+ for key, label in self._entries:
+ hay = f"{key} {label}".lower()
+ if all(w in hay for w in words):
+ lst.add_option(Option(Text(label, no_wrap=True), id=key))
+ if lst.option_count:
+ lst.highlighted = 0
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ self._refilter(event.value)
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ lst = self.query_one(OptionList)
+ if lst.option_count and lst.highlighted is not None:
+ self.dismiss(lst.get_option_at_index(lst.highlighted).id)
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ self.dismiss(event.option.id)
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+class KkPromptInput(Input):
+ def on_key(self, event) -> None:
+ if event.key == "escape":
+ event.stop()
+ event.prevent_default()
+ self.screen.dismiss(None)
+
+
+class KkInputScreen(ModalScreen[str | None]):
+ """Single text prompt — native replacement for an entry's gum input."""
+
+ BINDINGS = [Binding("escape", "close", "Close", show=False)]
+ DEFAULT_CSS = """
+ KkInputScreen { align: center middle; }
+ #kkp-box {
+ width: 84;
+ height: auto;
+ background: $surface;
+ border: round $accent;
+ border-title-color: $accent;
+ padding: 0 1;
+ }
+ #kkp-box Input { border: round $panel; }
+ """
+
+ def __init__(self, title: str, placeholder: str, allow_empty: bool = False):
+ super().__init__()
+ self._title = title
+ self._placeholder = placeholder
+ self._allow_empty = allow_empty
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="kkp-box"):
+ yield KkPromptInput(placeholder=self._placeholder, id="kkp-input")
+
+ def on_mount(self) -> None:
+ self.query_one("#kkp-box", Vertical).border_title = self._title
+ self.query_one("#kkp-input", KkPromptInput).focus()
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ value = event.value.strip()
+ if value or self._allow_empty:
+ self.dismiss(value)
+ else:
+ self.dismiss(None)
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
+# ── Command bar ───────────────────────────────────────────────────────────────
+
+class CommandInput(Input):
+ """Input with Esc-to-close and up/down history, delegated to the app."""
+
+ def on_key(self, event) -> None:
+ if event.key == "escape":
+ event.stop()
+ event.prevent_default()
+ self.app.hide_command_bar()
+ elif event.key == "up":
+ event.stop()
+ event.prevent_default()
+ self.app.history_nav(-1)
+ elif event.key == "down":
+ event.stop()
+ event.prevent_default()
+ self.app.history_nav(1)
+
+
+class CommandBar(Vertical):
+ DEFAULT_CSS = """
+ CommandBar {
+ height: auto;
+ display: none;
+ background: $surface;
+ padding: 0 1;
+ }
+ CommandBar.visible { display: block; }
+ CommandBar Input { border: round $accent; }
+ CommandBar #cmd-output-wrap {
+ height: auto;
+ max-height: 12;
+ scrollbar-size: 1 1;
+ }
+ CommandBar #cmd-output { padding: 0 2; }
+ """
+
+ def compose(self) -> ComposeResult:
+ yield CommandInput(
+ id="cmd-input",
+ placeholder="command… (zsh, aliases work · !cmd = interactive in terminal · Esc = close)",
+ )
+ with ScrollableContainer(id="cmd-output-wrap"):
+ yield Static("", id="cmd-output")
+
+
+# ── App ───────────────────────────────────────────────────────────────────────
+
+class DashboardApp(App):
+ TITLE = "Personal Dashboard"
+ BINDINGS = [
+ ("1", "page('page-personal')", "Personal"),
+ ("2", "page('page-news')", "News"),
+ ("3", "page('page-system')", "System"),
+ Binding("[", "cycle_page(-1)", "Prev page", show=False),
+ Binding("]", "cycle_page(1)", "Next page", show=False),
+ Binding(":", "command_bar", "Cmd", key_display=":"),
+ ("k", "kk_menu", "kk"),
+ ("r", "refresh", "Refresh"),
+ ("t", "next_theme", "Theme"),
+ ("q", "quit", "Quit"),
+ ]
+ CSS = """
+ Screen { background: $background; }
+
+ ContentSwitcher { height: 1fr; }
+ #page-personal, #page-news, #page-system { height: 100%; }
+
+ .row { height: 1fr; }
+ .row-tall { height: 2fr; }
+
+ #weather { width: 1.75fr; }
+ #uptime { width: 0.8fr; }
+
+ #nowplaying { width: 1.25fr; }
+
+ #todos { width: 1.75fr; }
+ #calendar { width: 1.25fr; }
+ #markets { width: 0.8fr; }
+
+ #sports { width: 1.75fr; }
+ #news { width: 2fr; }
+ #feeds { width: 1.25fr; }
+ #hackernews { width: 1fr; }
+
+ #np-news { width: 1.25fr; }
+ #np-briefs { width: 1fr; }
+ #np-feeds { width: 1fr; }
+ #np-hackernews { width: 1fr; }
+
+ #sys-cpu, #sys-memdisk, #sys-net, #sys-power { width: 1fr; }
+
+ .sys-spark-row { height: 4; }
+ #sys-sparkline {
+ height: 100%;
+ margin: 0 0;
+ padding: 0 1;
+ border: round $panel;
+ border-title-color: $primary;
+ color: $accent;
+ }
+ #sys-procs {
+ width: 100%;
+ height: 100%;
+ border: round $panel;
+ border-title-color: $primary;
+ scrollbar-size: 1 1;
+ }
+ """
+
+ _theme_idx: int = 0
+ _page_id: str = "page-personal"
+ _connected: bool = False
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._cmd_history: list[str] = []
+ self._hist_idx = 0
+
+ def on_mount(self) -> None:
+ for t in _THEMES.values():
+ self.register_theme(t)
+ self.theme = THEME_NAMES[self._theme_idx]
+
+ self._sys = SystemStats()
+ self._sys_timer = self.set_interval(
+ SYS_REFRESH_SECS, self.refresh_system, pause=True
+ )
+ table = self._q("#sys-procs", DataTable)
+ table.add_columns("PID", "Name", "CPU %", "Mem")
+ table.cursor_type = "none"
+
+ self.refresh_data()
+ self.set_interval(REFRESH_SECS, self.refresh_data)
+
+ def _q(self, selector: str, expect_type):
+ """query_one against the main screen — app.query_one resolves against
+ the TOP of the screen stack, so timer/worker lookups crash with
+ NoMatches while a modal (kk overlay) is open."""
+ return self.screen_stack[0].query_one(selector, expect_type)
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ with ContentSwitcher(initial="page-personal", id="switcher"):
+ with Vertical(id="page-personal"):
+ with Horizontal(classes="row"):
+ yield DashPanel("Weather", "weather")
+ yield DashPanel("Uptime", "uptime")
+ yield DashPanel("Now Playing", "nowplaying")
+ 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"):
+ yield DashPanel("Headlines", "np-news")
+ yield DashPanel("Briefs", "np-briefs")
+ with Horizontal(classes="row"):
+ yield DashPanel("Feeds", "np-feeds")
+ yield DashPanel("Hacker News", "np-hackernews")
+ with Vertical(id="page-system"):
+ with Horizontal(classes="row"):
+ yield DashPanel("CPU", "sys-cpu")
+ yield DashPanel("Memory · Disk", "sys-memdisk")
+ yield DashPanel("Network", "sys-net")
+ yield DashPanel("Power · Uptime", "sys-power")
+ with Horizontal(classes="sys-spark-row"):
+ spark = Sparkline([], id="sys-sparkline", summary_function=max)
+ spark.border_title = "CPU history"
+ yield spark
+ with Horizontal(classes="row row-tall"):
+ procs = DataTable(id="sys-procs")
+ procs.border_title = "Processes"
+ yield procs
+ yield CommandBar(id="cmd-bar")
+ yield Footer()
+
+ @work(exclusive=True)
+ async def refresh_data(self) -> None:
+ sources = await fetch_sources()
+ connected = bool(sources)
+ no_data = Text("No data.", style="dim italic")
+
+ def panel(pid: str) -> DashPanel:
+ return self._q(f"#{pid}", DashPanel)
+
+ panel("weather").set_content(
+ render_weather(p) if (p := payload(sources, "weather")) else no_data
+ )
+ panel("calendar").set_content(
+ render_calendar(p) if (p := payload(sources, "calendar")) else no_data
+ )
+ panel("todos").set_content(
+ render_todos(p) if (p := payload(sources, "todos")) else no_data
+ )
+ panel("markets").set_content(
+ render_markets(p) if (p := payload(sources, "markets")) else no_data
+ )
+ 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
+ )
+ news_ps = payloads(sources, "news")
+ panel("news").set_content(
+ render_news(news_ps) if news_ps else no_data
+ )
+ panel("np-news").set_content(
+ render_news(news_ps, cap=24) if news_ps else no_data
+ )
+ hn_p = payload(sources, "hackernews")
+ panel("hackernews").set_content(
+ render_hackernews(hn_p) if hn_p else no_data
+ )
+ panel("np-hackernews").set_content(
+ render_hackernews(hn_p, cap=20) if hn_p else no_data
+ )
+ panel("np-briefs").set_content(
+ 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"))
+ )
+ self._connected = connected
+ self._update_subtitle()
+
+ @work(exclusive=True, thread=True, group="sys")
+ def refresh_system(self) -> None:
+ stats = self._sys.sample()
+ self.call_from_thread(self._apply_system, stats)
+
+ def _apply_system(self, s: dict) -> None:
+ def panel(pid: str) -> DashPanel:
+ return self._q(f"#{pid}", DashPanel)
+
+ panel("sys-cpu").set_content(render_cpu(s))
+ panel("sys-memdisk").set_content(render_memdisk(s))
+ panel("sys-net").set_content(render_net(s))
+ panel("sys-power").set_content(render_power(s))
+
+ self._q("#sys-sparkline", Sparkline).data = s["history"]
+
+ table = self._q("#sys-procs", DataTable)
+ table.clear(columns=False)
+ for pr in s["procs"]:
+ mem = pr.get("memory_info")
+ mem_lbl = f"{mem.rss / 2**20:.0f} MB" if mem else "—"
+ table.add_row(
+ str(pr["pid"]),
+ (pr.get("name") or "?")[:40],
+ f"{pr['cpu_percent']:.1f}",
+ mem_lbl,
+ )
+
+ def _update_subtitle(self) -> None:
+ now = datetime.now().strftime("%-I:%M %p")
+ dot = "●" if self._connected else "○ offline"
+ page_name = dict(PAGES)[self._page_id]
+ self.sub_title = f"{dot} {now} · {page_name} · {THEME_NAMES[self._theme_idx]}"
+
+ def action_page(self, page_id: str) -> None:
+ if page_id == self._page_id:
+ return
+ self._q("#switcher", ContentSwitcher).current = page_id
+ self._page_id = page_id
+ if page_id == "page-system":
+ self._sys_timer.resume()
+ self.refresh_system()
+ else:
+ self._sys_timer.pause()
+ self._update_subtitle()
+
+ def action_cycle_page(self, delta: int) -> None:
+ ids = [pid for pid, _ in PAGES]
+ idx = (ids.index(self._page_id) + delta) % len(ids)
+ self.action_page(ids[idx])
+
+ # ── Command bar ──────────────────────────────────────────────────────────
+
+ def action_command_bar(self) -> None:
+ bar = self._q("#cmd-bar", CommandBar)
+ bar.add_class("visible")
+ self._q("#cmd-input", CommandInput).focus()
+
+ def hide_command_bar(self) -> None:
+ self._q("#cmd-bar", CommandBar).remove_class("visible")
+ self.set_focus(None)
+
+ def action_kk_menu(self) -> None:
+ entries = load_dash_entries()
+ if not entries:
+ self.notify("couldn't parse dash entries from ~/.zshrc", severity="error")
+ return
+
+ def picked(key: str | None) -> None:
+ if key:
+ self._kk_dispatch(key)
+
+ self.push_screen(KkScreen(entries), picked)
+
+ def _kk_run(self, cmd: str, timeout: int = 60) -> None:
+ self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim"))
+ self.run_command(cmd, timeout=timeout)
+
+ def _kk_dispatch(self, key: str) -> None:
+ # one text prompt → command (scratch, note, dict, am search/vol)
+ if key in KK_INPUT:
+ placeholder, build, allow_empty = KK_INPUT[key]
+
+ def submitted(value: str | None) -> None:
+ if value is not None:
+ self._kk_run(build(value), timeout=KK_SLOW_TIMEOUT)
+
+ self.push_screen(KkInputScreen(key, placeholder, allow_empty), submitted)
+ return
+
+ # small fixed choice → command (periodic notes, dayreview, daydata, zine)
+ if choices := kk_choices(key):
+
+ def chose(cmd: str | None) -> None:
+ if cmd:
+ self._kk_run(cmd, timeout=KK_SLOW_TIMEOUT)
+
+ self.push_screen(KkScreen(choices, title=key), chose)
+ return
+
+ # dynamic list pick
+ if key == "am playlist":
+ self._kk_playlist_flow()
+ return
+
+ if key in KK_CAPTURED:
+ timeout = KK_SLOW_TIMEOUT if key == "morning-paper --print" else 60
+ self._kk_run(key, timeout=timeout)
+ return
+
+ # real TUIs / complex gum flows (task, event, md, vault, newsboat, …)
+ self._run_interactive(f"dash {shlex.quote(key)}")
+
+ @work(thread=True, group="cmd", exclusive=True)
+ def _kk_playlist_flow(self) -> None:
+ try:
+ res = subprocess.run(
+ ["zsh", "-ic", "am playlists"],
+ capture_output=True, text=True, timeout=30,
+ stdin=subprocess.DEVNULL, start_new_session=True,
+ )
+ names = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
+ except Exception:
+ names = []
+ self.call_from_thread(self._kk_playlist_pick, names)
+
+ def _kk_playlist_pick(self, names: list[str]) -> None:
+ if not names:
+ self.notify("no playlists found", severity="warning")
+ return
+
+ def chose(name: str | None) -> None:
+ if name:
+ self._kk_run(f"am playlist {shlex.quote(name)}")
+
+ choices = [(n, n) for n in names]
+ self.push_screen(KkScreen(choices, title="pick a playlist"), chose)
+
+ def history_nav(self, delta: int) -> None:
+ if not self._cmd_history:
+ return
+ self._hist_idx = max(0, min(len(self._cmd_history), self._hist_idx + delta))
+ inp = self._q("#cmd-input", CommandInput)
+ if self._hist_idx == len(self._cmd_history):
+ inp.value = ""
+ else:
+ inp.value = self._cmd_history[self._hist_idx]
+ inp.cursor_position = len(inp.value)
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ if event.input.id != "cmd-input":
+ return
+ cmd = event.value.strip()
+ event.input.value = ""
+ if not cmd:
+ self.hide_command_bar()
+ return
+ self._cmd_history.append(cmd)
+ self._hist_idx = len(self._cmd_history)
+ if cmd.startswith("!"):
+ self._run_interactive(cmd[1:].strip() or "dash")
+ else:
+ self._set_cmd_output(Text(f"$ {cmd}\nrunning…", style="dim"))
+ self.run_command(cmd)
+
+ def _run_interactive(self, cmd: str) -> None:
+ """Suspend the TUI and run cmd with the real terminal (gum menus, TUIs)."""
+ with self.suspend():
+ subprocess.run(["zsh", "-ic", cmd])
+ self.hide_command_bar()
+ self.notify(f"! {cmd}", title="ran in terminal", timeout=3)
+ self.refresh()
+ self.refresh_data()
+
+ _ZSH_NOISE = re.compile(
+ r"can't change option|stdin isn't a terminal|no job control|"
+ r"inappropriate ioctl|not a terminal"
+ )
+
+ @work(exclusive=True, thread=True, group="cmd")
+ def run_command(self, cmd: str, timeout: int = 60) -> None:
+ try:
+ # stdin=DEVNULL + new session: no controlling TTY, so interactive
+ # zsh (needed for aliases/functions) can't grab the terminal or
+ # SIGTTIN-suspend the dashboard's process group.
+ res = subprocess.run(
+ ["zsh", "-ic", cmd],
+ capture_output=True, text=True, timeout=timeout,
+ stdin=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ raw = (res.stdout + res.stderr).strip()
+ out = "\n".join(
+ ln for ln in raw.splitlines() if not self._ZSH_NOISE.search(ln)
+ ).strip()
+ code = res.returncode
+ except subprocess.TimeoutExpired:
+ out, code = f"timed out after {timeout}s (interactive? try !{cmd})", 1
+ self.call_from_thread(self._finish_cmd, cmd, out, code)
+
+ def _finish_cmd(self, cmd: str, out: str, code: int) -> None:
+ lines = out.splitlines()
+ if code == 0 and len(lines) <= 3:
+ # quick success — toast it and get out of the way
+ self.hide_command_bar()
+ self._set_cmd_output(Text(""))
+ msg = out if out else "done"
+ self.notify(msg[:200], title=f"✓ {cmd}"[:60], timeout=4)
+ else:
+ # long output worth reading, or a failure — show it in the bar
+ # (kk picks run with the bar hidden, so make sure it's visible)
+ text = Text()
+ text.append(f"$ {cmd}\n", style="bold")
+ text.append(Text.from_ansi(out) if out else Text("(no output)", style="dim italic"))
+ if code != 0:
+ text.append(f"\nexit {code}", style="bold red")
+ self._set_cmd_output(text)
+ self._q("#cmd-bar", CommandBar).add_class("visible")
+ self.refresh() # full repaint clears any terminal residue
+ self.refresh_data()
+
+ def _set_cmd_output(self, text: Text) -> None:
+ self._q("#cmd-output", Static).update(text)
+
+ # ── Actions ──────────────────────────────────────────────────────────────
+
+ def action_refresh(self) -> None:
+ self.refresh_data()
+ if self._page_id == "page-system":
+ self.refresh_system()
+
+ def action_next_theme(self) -> None:
+ self._theme_idx = (self._theme_idx + 1) % len(THEME_NAMES)
+ self.theme = THEME_NAMES[self._theme_idx]
+ self._update_subtitle()
+
+ def action_open_link(self, url: str) -> None:
+ self.open_url(url)
+
+
+if __name__ == "__main__":
+ ensure_server()
+ DashboardApp().run()