repos

Home dashboard: active 15-minute sweep, compression, skeletons, countdown

ad272ca4 by Isaac Bythewood · 27 days ago

Home dashboard: active 15-minute sweep, compression, skeletons, countdown

- Scheduler: new guarded home sweep re-quotes the dashboard's overview
  instruments + all watchlist symbols every 15 minutes, viewer or not,
  so the home page always opens fresh. Session-aware (off-hours only
  ~24h futures/crypto) and rides refresh_quotes (guard + throttle).
  Listed on /health as its own job.
- App-wide brotli/gzip via tower-http CompressionLayer (SSE excluded
  by the default predicate); /api/dashboard drops 20.3KB -> 4.4KB.
- /api/dashboard builds its ~12 chart series concurrently.
- Home: shimmer-skeleton overview cards + watchlist chart placeholders
  (no layout jump, reduced-motion aware); session banner now counts
  down to the next ET session boundary, repainted every 30s.
- Search: aria-current on the active kind pill.
- Health: lede copy reflects the two timed jobs.
- Cleanup: stale fund_nav comment, empty static_src/pages/ dir,
  CLAUDE.md documents the demand-only model's one exception.
modified CLAUDE.md
@@ -22,7 +22,7 @@ There are no tests or linters configured.## The demand-only model (read this first)Nothing is fetched on a timer. With nobody on the site, the app makes **zero** outbound calls. Data is pulled **on demand** the first time a symbol is viewed (or when its stored copy is stale), and live intraday quotes are polled **only** for the symbols a browser is currently watching. This shapes the whole architecture: the scheduler does no network sweeps, history fills in lazily, and every figure on the page carries an honest data-age read ("live", "2m ago", "stale, refreshing", "as of Fri close") — a stale figure is never shown as if fresh.Data is pulled **on demand** the first time a symbol is viewed (or when its stored copy is stale), and live intraday quotes are polled **only** for the symbols a browser is currently watching. **One deliberate exception** (user call, 2026-06-10): the scheduler's *home sweep* re-quotes the home dashboard's instruments (overview set + every watchlist symbol) every 15 minutes whether or not anyone is on the site, so the dashboard always opens fresh; it is session-aware (off-hours only the ~24h futures/crypto are polled) and guard-routed like everything else. Beyond that the architecture is unchanged: no other timed sweeps, history fills in lazily, and every figure on the page carries an honest data-age read ("live", "2m ago", "stale, refreshing", "as of Fri close") — a stale figure is never shown as if fresh.The user considers **never hitting a rate limit** critical. Every outbound call passes through the persistent `EndpointGuard` (see Architecture). Treat the rate-limit-critical path with care; don't add eager fetching.
@@ -34,7 +34,7 @@ The user considers **never hitting a rate limit** critical. Every outbound call**Endpoint guard (`src/guard.rs`):** A persistent, per-endpoint `EndpointGuard` that every outbound data call passes through: a DB-backed reactive circuit breaker (trips on HTTP 429/503 at once or after a failure streak, exponential backoff, half-open probe recovery), a hard per-hour request budget, and request pacing. State lives in the `endpoint_guard` table, so it survives restarts and is shared by the server and the `seed` subcommand. Budgets: 1000 req/hr on the `yahoo` guard, 600 req/hr on `sec`.**Scheduler (`src/scheduler.rs`):** One long-lived tokio task on a 60s tick. Since the demand-only refocus it does **no** timed network fetching. Each tick it only: broadcasts market-session changes (and re-pushes the dashboard summary, a local DB read, on a session flip), runs the demand-driven intraday quote poll (Yahoo quotes for just the symbols a browser is watching, via the stream hub's interest registry, at a ~5-minute per-symbol cadence), and prunes aged `intraday_bars` / `fetch_log` rows. All the old timed sweeps (daily-close, SEC, dividends, fund metadata, NAV, earnings, asset profile, periodic history) were removed; their data is now pulled on demand. The boot seed only reconciles the universe rows from the curated CSV (local, no network).**Scheduler (`src/scheduler.rs`):** One long-lived tokio task on a 60s tick. Each tick it: broadcasts market-session changes (and re-pushes the dashboard summary, a local DB read, on a session flip), runs the demand-driven intraday quote poll (Yahoo quotes for just the symbols a browser is watching, via the stream hub's interest registry, at a ~5-minute per-symbol cadence), runs the **active home sweep** when due (every 15 minutes: re-quotes the dashboard's overview instruments + the union of all watchlist symbols, viewer or not — the one timed network job; session-aware, rides `refresh_quotes` so the per-symbol throttle and guard apply), and prunes aged `intraday_bars` / `fetch_log` rows. All the other old timed sweeps (daily-close, SEC, dividends, fund metadata, NAV, earnings, asset profile, periodic history) were removed; their data is pulled on demand. The boot seed only reconciles the universe rows from the curated CSV (local, no network).**On-demand pull (`backfill_symbol`):** The synchronous per-symbol pull used by the add-symbol route and the on-demand refresh — fetches a viewed symbol's stale/missing fast Yahoo data (quote / intraday / daily history) live behind a loading bar, and its slow SEC data (fundamentals / filings / holdings / NAV) only when missing or stale, each carrying a data-age read. Manual refresh re-pulls everything.
@@ -52,6 +52,8 @@ The user considers **never hitting a rate limit** critical. Every outbound call**Request logging:** `src/middleware.rs` prints `time METHOD STATUS latency path` per request with ANSI-colored status codes, and serves the themed 404.**Compression:** the router wraps everything in tower-http's `CompressionLayer` (brotli/gzip per `Accept-Encoding`); its default predicate skips `text/event-stream`, so `/stream` SSE frames still flush unbuffered.## Design — "Paper Ledger"An old-school accounting ledger reimagined futuristic and modern: warm-paper background, ink-dark text, hairline rules, monospace ledger figures, restrained serif headings. Tokens are CSS custom properties in `base.scss :root`. Built dual-first: desktop is information-dense and *uses* its space, mobile distills to the key signals; neither is an afterthought.
modified Cargo.toml
@@ -8,7 +8,7 @@ axum = { version = "0.8", features = ["macros"] }tokio = { version = "1", features = ["full"] }tokio-stream = { version = "0.1", features = ["sync"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "set-header"] }tower-http = { version = "0.6", features = ["fs", "set-header", "compression-gzip", "compression-br"] }minijinja = { version = "2", features = ["loader", "loop_controls", "json"] }serde = { version = "1", features = ["derive"] }serde_json = "1"
modified frontend/static_src/home/scripts/hero.js
@@ -97,6 +97,62 @@ function fmtClock(ms) {    .toLowerCase();}// ── session countdown ────────────────────────────────────────────────────────// "Market closes in 2h 14m" in the banner: the next boundary on the fixed ET// schedule (no holiday calendar, by design — mirrors market.rs): weekdays// Pre 4:00 → Regular 9:30 → Post 16:00 → Closed 20:00; weekends closed.const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };const PRE_OPEN = 4 * 60;const REG_OPEN = 9 * 60 + 30;const REG_CLOSE = 16 * 60;const POST_CLOSE = 20 * 60;function etNowParts() {  const parts = new Intl.DateTimeFormat("en-US", {    timeZone: "America/New_York",    weekday: "short",    hour: "2-digit",    minute: "2-digit",    hour12: false,  }).formatToParts(new Date());  let wd = 0;  let h = 0;  let m = 0;  for (const p of parts) {    if (p.type === "weekday") wd = WEEKDAYS[p.value] ?? 0;    else if (p.type === "hour") h = parseInt(p.value, 10) % 24;    else if (p.type === "minute") m = parseInt(p.value, 10);  }  return { wd, minutes: h * 60 + m };}function fmtSpan(mins) {  const d = Math.floor(mins / 1440);  const h = Math.floor((mins % 1440) / 60);  const m = mins % 60;  if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;  if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;  return `${Math.max(1, m)}m`;}function nextSessionRead() {  const { wd, minutes } = etNowParts();  if (wd >= 1 && wd <= 5) {    const next = [      [PRE_OPEN, "Pre-market opens"],      [REG_OPEN, "Market opens"],      [REG_CLOSE, "Market closes"],      [POST_CLOSE, "After hours ends"],    ].find(([at]) => minutes < at);    if (next) return `${next[1]} in ${fmtSpan(next[0] - minutes)}`;  }  // Past today's last boundary, or a weekend: count to the next weekday's  // pre-market open (Friday evening → Monday).  const days = wd === 5 ? 3 : wd === 6 ? 2 : 1;  const span = (days - 1) * 1440 + (1440 - minutes) + PRE_OPEN;  return `Pre-market opens in ${fmtSpan(span)}`;}function setText(role, text) {  const el = document.querySelector(`[data-role="${role}"]`);  if (el && text != null) el.textContent = text;
@@ -531,16 +587,15 @@ export function initHero() {    if (clock) setText("reads-asof", "Prices as of " + clock);  }  function patchCountdown() {    setText("session-note", nextSessionRead() + " " + DASH + " all times ET");  }  function patchSession(session) {    const banner = document.querySelector('[data-role="session-banner"]');    if (banner && session) banner.dataset.session = session;    setText("session-label", SESSION_LABELS[session] || "Market closed");    setText(      "session-note",      session === "closed"        ? "Prices update during market hours " + DASH + " ET"        : "US equities " + DASH + " all times ET",    );    patchCountdown();  }  async function refresh() {
@@ -558,13 +613,23 @@ export function initHero() {    patchSession(data.session);  }  patchCountdown();  refresh();  fetch("/api/dashboard/refresh")    .catch(() => {})    .finally(() => refresh());  const timer = setInterval(refresh, 60000);  // The countdown drifts a minute at a time; a 30s repaint keeps it honest  // without waiting on the next dashboard poll.  const clockTimer = setInterval(patchCountdown, 30000);  document.addEventListener("visibilitychange", () => {    if (!document.hidden) refresh();    if (!document.hidden) {      patchCountdown();      refresh();    }  });  window.addEventListener("pagehide", () => {    clearInterval(timer);    clearInterval(clockTimer);  });  window.addEventListener("pagehide", () => clearInterval(timer));}
modified frontend/static_src/home/styles/home.scss
@@ -102,22 +102,69 @@  }}.hero-graph__empty {  position: absolute;  inset: 0;  display: flex;  align-items: center;  justify-content: center;  color: var(--ink-faint);  font-size: var(--fs-sm);}/* Re-assert [hidden] — the display:flex above otherwise beats the UA rule, so   the placeholder would linger over the drawn charts (cf. the symbol page's/* ---------- loading skeletons ---------- *//* Before /api/dashboard answers, the overview grid shows one skeleton card per   slot. display: contents makes the wrapper vanish so each skeleton is a real   grid item — the grid lays out exactly as it will with live cards, no jump. */.ov-skels {  display: contents;}/* Re-assert [hidden] — display:contents otherwise beats the UA rule, so the   skeletons would linger behind the drawn charts (cf. the symbol page's   .ind-btn[hidden] fix). */.hero-graph__empty[hidden] {.ov-skels[hidden] {  display: none;}/* One shimmering placeholder block (a name line, a figure line, the chart   area). The shimmer is a slow ink wash, not a flashy gradient sweep —   restrained enough for the ledger aesthetic. */.ov-skel {  border-radius: var(--radius-sm);  background: rgba(33, 31, 26, 0.07);  animation: ov-skel-pulse 1.6s ease-in-out infinite;}.ov-skel--name {  width: 7rem;  height: 1rem;}.ov-skel--num {  width: 4.5rem;  height: 1rem;}.ov-skel--chart {  animation-delay: 0.2s;}@keyframes ov-skel-pulse {  0%,  100% {    opacity: 1;  }  50% {    opacity: 0.45;  }}@media (prefers-reduced-motion: reduce) {  .ov-skel {    animation: none;  }}/* A watchlist card's chart mount is server-rendered empty and only fills once   hero.js draws into it; until then show the same quiet placeholder wash so   the card doesn't read as broken. :empty stops matching the moment the chart   library mounts. */.ov-card--watch .ov-card__chart:empty {  border-radius: var(--radius-sm);  background: rgba(33, 31, 26, 0.07);  animation: ov-skel-pulse 1.6s ease-in-out infinite;}@media (prefers-reduced-motion: reduce) {  .ov-card--watch .ov-card__chart:empty {    animation: none;  }}/* ---------- one instrument's chart card (overview + watchlist) ---------- */.ov-card {  @include card;
modified src/app.rs
@@ -3,6 +3,7 @@ use minijinja::Environment;use sqlx::SqlitePool;use std::path::PathBuf;use std::sync::Arc;use tower_http::compression::CompressionLayer;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;
@@ -115,5 +116,9 @@ pub fn router(state: AppState) -> Router {        )        .fallback(middleware::not_found)        .layer(axum_middleware::from_fn(middleware::log_requests))        // Compress HTML / JSON / static text on the fly (brotli or gzip per        // Accept-Encoding). tower-http's default predicate already skips        // `text/event-stream`, so `/stream` SSE frames still flush unbuffered.        .layer(CompressionLayer::new())        .with_state(state)}
modified src/routes/health.rs
@@ -215,10 +215,10 @@ fn endpoint_label(endpoint: &str) -> &str {}/// Human label and one-line description per scheduler job. Since the demand-only/// refocus the only timed job is the intraday poll (all the old sweeps were/// removed; deep data is fetched on demand when a page is viewed — see the guard/// usage in the Endpoints section above). An unknown job falls back to its raw/// id and no description./// refocus the timed jobs are the intraday poll and the active home sweep (all/// the old sweeps were removed; deep data is fetched on demand when a page is/// viewed — see the guard usage in the Endpoints section above). An unknown job/// falls back to its raw id and no description.fn job_meta(job: &str) -> (&str, &str) {    match job {        "intraday" => (
@@ -226,6 +226,12 @@ fn job_meta(job: &str) -> (&str, &str) {            "Live quotes from Yahoo for the symbols a browser is viewing, on a \             ~5-minute cadence. Nothing is polled when nobody is on the site.",        ),        "home" => (            "Home sweep",            "Re-quotes the dashboard's overview instruments and every watchlist \             symbol on a 15-minute cadence, viewer or not, so the home page \             always opens fresh. Off-hours only the ~24h instruments are polled.",        ),        "prune" => (            "Prune",            "Local cleanup of aged intraday bars and fetch-log rows (no network).",
@@ -234,13 +240,13 @@ fn job_meta(job: &str) -> (&str, &str) {    }}/// Display order for the jobs list. Only the intraday poll reports a status row/// now; the fallback keeps any future job at the end./// Display order for the jobs list; the fallback keeps any future job at the end.fn job_rank(job: &str) -> u8 {    match job {        "intraday" => 0,        "prune" => 1,        _ => 2,        "home" => 1,        "prune" => 2,        _ => 3,    }}
modified src/routes/home.rs
@@ -4,9 +4,11 @@//! the session's market reads (S&P, volume, VIX, the 50/200-day trend) and the//! browser's personal, editable watchlist. The watchlist is session-scoped (a//! `fin_sid` cookie; see `crate::watchlist`), seeded with starters on a first//! visit. Data is demand-driven: opening this page is what makes the server poll//! the watchlist + baseline symbols (via the stream interest registry); nothing//! is polled when nobody is here.//! visit. The dashboard is the one exception to the demand-only model: the//! scheduler's active home sweep (`scheduler::run_home_sweep_if_due`) keeps its//! instruments fresh on a 15-minute cadence even with nobody on the site, so//! the page always opens current. An open page still gets the faster treatment//! (the stream interest registry's ~5-minute poll plus the on-open refresh).use std::collections::HashMap;
@@ -90,6 +92,20 @@ fn overview_tickers() -> Vec<&'static str> {    out}/// Everything the dashboard reads a quote for: the overview slots (cash +/// futures tickers) plus the VIX and volume-proxy headline reads. The/// scheduler's active home sweep and the on-open refresh both poll exactly/// this set (each adding the watchlist symbols on top).pub(crate) fn dashboard_tickers() -> Vec<&'static str> {    let mut out = overview_tickers();    for t in [VIX, VOLUME_PROXY] {        if !out.contains(&t) {            out.push(t);        }    }    out}/// The overview charts frame exactly one Schwab trading day: extended-hours open/// (7:00 AM ET) through extended-hours close (8:00 PM ET), so each chart shows/// just that day — pre-market, the regular session, and after-hours — and never
@@ -307,26 +323,27 @@ async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response {async fn dashboard_api(State(state): State<AppState>, headers: HeaderMap) -> Response {    let market_session = market::session_at(chrono::Utc::now());    // The fixed market-overview set. The S&P slot leads.    let slots = overview();    let mut series = Vec::with_capacity(slots.len());    for (quote, chart, name, dollar) in slots {        if let Some(s) = overview_series(&state, quote, chart, name, dollar).await {            series.push(s);        }    }    // The session's watchlist, drawn with the same chart treatment as the    // overview. Each is a single-ticker series (the symbol is both quote + chart;    // stocks/ETFs carry their own pre/post bars via Yahoo's includePrePost).    // The fixed market-overview set (the S&P slot leads) and the session's    // watchlist, drawn with the same chart treatment. Each watchlist symbol is    // a single-ticker series (the symbol is both quote + chart; stocks/ETFs    // carry their own pre/post bars via Yahoo's includePrePost). Every series    // is a handful of independent SQLite reads, so they are all built    // concurrently rather than one after another — ~12+ series on a typical    // dashboard, and the response is what gates the page's first chart paint.    let session = watchlist::resolve(&state.pool, &headers).await;    let wl = watchlist::list(&state.pool, &session.sid).await;    let mut watchlist = Vec::with_capacity(wl.len());    for t in &wl {        if let Some(s) = watchlist_series(&state, t).await {            watchlist.push(s);        }    }    let (series, watchlist) = tokio::join!(        futures_util::future::join_all(            overview()                .into_iter()                .map(|(quote, chart, name, dollar)| overview_series(                    &state, quote, chart, name, dollar                )),        ),        futures_util::future::join_all(wl.iter().map(|t| watchlist_series(&state, t))),    );    let series: Vec<Series> = series.into_iter().flatten().collect();    let watchlist: Vec<Series> = watchlist.into_iter().flatten().collect();    let data = DashboardData {        session: market_session.as_str().to_string(),
@@ -364,12 +381,9 @@ async fn dashboard_refresh(State(state): State<AppState>, headers: HeaderMap) ->    // reads — everything the open dashboard shows gets a fresh quote.    let wl = watchlist::list(&state.pool, &session.sid).await;    let mut tickers = wl.clone();    for t in overview_tickers() {    for t in dashboard_tickers() {        tickers.push(t.to_string());    }    for b in [VIX, VOLUME_PROXY] {        tickers.push(b.to_string());    }    let refreshed =        crate::scheduler::refresh_quotes(&state.pool, &state.config, &state.hub, &tickers).await;
modified src/routes/symbols.rs
@@ -1598,8 +1598,9 @@ async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>)        // Only read a price-vs-NAV premium (the tracking factor) against a        // *fresh* NAV: NAV is struck daily, so a stale one makes the premium        // meaningless. We let the factor drop out rather than assert a bogus        // tracking verdict. The daily `fund_nav` job keeps NAV current; when it        // is behind (fresh deploy, guard tripped), tracking simply reads "—".        // tracking verdict. NAV is re-fetched on demand when an ETF page is        // viewed and stale; when it is behind (fresh deploy, guard tripped),        // tracking simply reads "—".        const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;        let nav_fresh =            etf_nav_synced_at.is_some_and(|t| crate::db::now_ms() - t <= NAV_FRESH_MS);
modified src/scheduler.rs
@@ -1,13 +1,17 @@//! Background job scheduler (demand-only).//!//! One long-lived tokio task wakes on a fixed tick. Since the demand-only//! refocus (2026-06-03) it does **no** timed network fetching: with//! nobody on the site it makes zero outbound calls. Each tick it only://! refocus (2026-06-03) the home dashboard's instruments are the **only**//! timed network fetching (the active home sweep, a 2026-06-10 user call);//! everything else is demand-driven. Each tick it://!  - broadcasts a market-session change so open pages update their pill (and//!    re-pushes the dashboard summary, a local DB read, on a session flip);//!  - runs the demand-driven intraday quote poll — Yahoo quotes for just the//!    symbols a browser is currently viewing (the stream hub's interest//!    registry), so nothing is polled when nobody is watching;//!  - runs the active home sweep when due (every 15 minutes): re-quotes the//!    dashboard's overview instruments + all watchlist symbols, viewer or not,//!    so the home page always opens fresh — session-aware and guard-routed;//!  - prunes aged `intraday_bars` and `fetch_log` rows (~daily, local only).//!//! All the old timed sweeps (daily-close, SEC, dividends, fund metadata, NAV,
@@ -53,6 +57,16 @@ const TICK: Duration = Duration::from_secs(60);/// the 5-minute mark isn't skipped to the next tick.const INTRADAY_MIN_INTERVAL_SECS: i64 = 4 * 60 + 45;/// Active home-dashboard sweep cadence (a user call, 2026-06-10): the home/// page's instruments — the market overview, the VIX / volume reads, and every/// watchlist symbol across all sessions — are re-quoted every 15 minutes even/// with nobody on the site, so the dashboard always opens fresh instead of/// waiting on the on-open refresh's round trip to Yahoo. This is the one/// deliberate exception to the demand-only model; it stays session-aware/// (off-hours only the ~24h instruments are polled) and guard-routed, so the/// worst case is a few dozen requests per hour against the 1000/hr budget.const HOME_SWEEP_INTERVAL_SECS: i64 = 15 * 60;/// Prune cadence and the two retention windows it enforces.const PRUNE_INTERVAL_SECS: i64 = 24 * 3600;const INTRADAY_RETENTION_DAYS: i64 = 14;
@@ -110,6 +124,10 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle        // Prune's last-run time is loop-local: a restart simply re-prunes once,        // which is harmless (local-only DELETEs, no network).        let mut last_prune: Option<i64> = None;        // The home sweep's last-run time is loop-local too: a restart sweeps        // once right away (the per-symbol throttle inside `refresh_quotes`        // keeps a quick restart from re-hitting Yahoo for fresh quotes).        let mut last_home_sweep: Option<i64> = None;        // The session last broadcast, so a transition (e.g. into after-hours)        // is pushed to connected clients exactly once.        let mut last_session: Option<market::Session> = None;
@@ -138,6 +156,15 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                tracing::warn!("[scheduler] intraday: {e:#}");            }            // The active home-dashboard sweep: every 15 minutes, re-quote the            // home page's instruments whether or not anyone is watching, so            // the dashboard always opens fresh.            if let Err(e) =                run_home_sweep_if_due(&pool, &config, &hub, session, &mut last_home_sweep).await            {                tracing::warn!("[scheduler] home sweep: {e:#}");            }            if let Err(e) = run_prune_if_due(&pool, &mut last_prune, &hub).await {                tracing::warn!("[scheduler] prune: {e:#}");            }
@@ -158,9 +185,10 @@ async fn register_endpoints(pool: &SqlitePool) -> anyhow::Result<()> {        .await?;    // The demand-only refocus (Phase A) removed every timed sweep. Drop their    // leftover `data_status` rows so `/health` lists only the jobs that still    // run (the demand-driven intraday poll and the local prune); a prod DB    // carries rows from the old jobs that would otherwise show as stale.    sqlx::query("DELETE FROM data_status WHERE job NOT IN ('intraday', 'prune')")    // run (the demand-driven intraday poll, the active home sweep, and the    // local prune); a prod DB carries rows from the old jobs that would    // otherwise show as stale.    sqlx::query("DELETE FROM data_status WHERE job NOT IN ('intraday', 'home', 'prune')")        .execute(pool)        .await?;    EndpointGuard::with_budget(pool.clone(), "yahoo", YAHOO_BUDGET)
@@ -337,6 +365,73 @@ async fn run_intraday(    Ok(())}/// The active home-dashboard sweep (a user call, 2026-06-10; see/// `HOME_SWEEP_INTERVAL_SECS`). Every 15 minutes it re-quotes the full/// dashboard set — the market overview's cash + futures tickers, the VIX and/// volume-proxy reads, and the union of every session's watchlist — without/// requiring a viewer, so the home page always opens on current figures.////// Session-aware like `run_intraday`: inside any trading session everything is/// polled; outside it, only the instruments that trade ~around the clock/// (futures, crypto), since a frozen stock/ETF/index quote would just burn/// budget. Each pull rides `refresh_quotes`, so it is guard-routed, throttled/// per symbol (a quote fresher than ~5 minutes is skipped), and published to/// the hub so any open page live-ticks too.async fn run_home_sweep_if_due(    pool: &SqlitePool,    config: &Config,    hub: &Hub,    session: market::Session,    last: &mut Option<i64>,) -> anyhow::Result<()> {    let now = now_ms();    if let Some(t) = *last {        if (now - t) / 1000 < HOME_SWEEP_INTERVAL_SECS {            return Ok(());        }    }    *last = Some(now);    let mut targets: Vec<String> = crate::routes::home::dashboard_tickers()        .into_iter()        .map(str::to_string)        .collect();    let watchlisted: Vec<String> =        sqlx::query_scalar("SELECT DISTINCT ticker FROM watchlist ORDER BY ticker")            .fetch_all(pool)            .await?;    for t in watchlisted {        if !targets.contains(&t) {            targets.push(t);        }    }    if !session.is_open() {        let around_clock: HashSet<String> =            sqlx::query_scalar("SELECT ticker FROM symbols WHERE kind IN ('future', 'crypto')")                .fetch_all(pool)                .await?                .into_iter()                .collect();        targets.retain(|t| around_clock.contains(t));    }    if targets.is_empty() {        return Ok(());    }    mark_fetching(pool, "home").await?;    notify_health(hub);    let refreshed = refresh_quotes(pool, config, hub, &targets).await;    if refreshed > 0 {        tracing::info!(            "[scheduler] home sweep: {refreshed}/{} refreshed",            targets.len()        );    }    mark_ok(pool, "home", Some(now + HOME_SWEEP_INTERVAL_SECS * 1000)).await?;    notify_health(hub);    Ok(())}/// Replace one stock's dividend history with what Yahoo returned. Yahoo serves/// the canonical, corrected history each call, so a `DELETE` + `INSERT` keeps/// the table honest if a payout is later retracted or restated. `pub(crate)`:
modified templates/pages/health.html
@@ -24,9 +24,10 @@  <p class="health-lede">    This app fetches market data <strong>on demand</strong> &mdash; when you open    a page &mdash; through a request guard per upstream; the only timed job is the    live intraday poll for the symbols on screen. Laid open here: each guard and    its hourly budget, that poll, and a live tail of the fetch log.    a page &mdash; through a request guard per upstream; the timed jobs are the    live intraday poll for the symbols on screen and the 15-minute home sweep    that keeps the dashboard fresh. Laid open here: each guard and its hourly    budget, those jobs, and a live tail of the fetch log.  </p>  {# Shown only while a job is fetching; the page script toggles `hidden`. #}
modified templates/pages/home.html
@@ -24,8 +24,22 @@      <h1 class="hero-graph__title">Market overview</h1>      <span class="hero-graph__note">live value &amp; % change vs prev close</span>    </div>    {# Until /api/dashboard answers, the grid shows one shimmer-skeleton card       per overview slot — same shape as the real cards, so the page doesn't       jump when they land. hero.js hides this wrapper (display: contents, so       the skeletons are real grid items) on the first draw. #}    <div class="overview-grid" data-role="overview-grid">      <p class="hero-graph__empty" data-role="hero-empty">Loading today&rsquo;s session&hellip;</p>      <div class="ov-skels" data-role="hero-empty" aria-hidden="true">        {% for t in overview_tickers %}{% if loop.index <= 6 %}        <div class="ov-card ov-card--skel">          <div class="ov-card__head">            <span class="ov-skel ov-skel--name"></span>            <span class="ov-skel ov-skel--num"></span>          </div>          <div class="ov-card__chart ov-skel ov-skel--chart"></div>        </div>        {% endif %}{% endfor %}      </div>    </div>  </section>
modified templates/pages/search.html
@@ -32,6 +32,7 @@      {"value": "stock",  "label": "Stocks"}    ] %}    <a class="kind-pill{% if opt.value == kind %} is-active{% endif %}"       {% if opt.value == kind %}aria-current="true"{% endif %}       href="/search?q={{ q|urlencode }}{% if opt.value %}&kind={{ opt.value }}{% endif %}">{{ opt.label }}</a>    {% endfor %}  </div>