repos

Phase 7: health verdict, two-tier footer, live market summary

645b3514 by Isaac Bythewood · 1 month ago

modified PLAN.md
@@ -34,7 +34,7 @@ commit + auto-deploy (`git push server master`) and a clean breakpoint.## Status_Last updated: 2026-05-30 (Phase 6 done on dev; commit + deploy pending)__Last updated: 2026-05-30 (Phase 7 done on dev; commit + deploy pending)_**Major refactor in progress (the "distill + ETF-first" rewrite).** This planwas fully rewritten 2026-05-30 from a sprawling 3,700-line resume doc into this
@@ -54,10 +54,56 @@ focused roadmap. The decisions driving it are in the Decisions log under- **Everything gets distilled** into a fast-scannable, dual-first (mobile +  desktop) design while keeping the futuristic-clean "Paper Ledger" look.**Current work:** Phases 1–5 are **done, committed, and deployed** (Phase 5 islive at `656e21d`). **Phase 6 is done on dev** (symbol-page distillation + liveintraday); commit + deploy pending. Next: **Phase 7 (health/systems pagedistillation + footer expansion + final polish).****Current work:** Phases 1–6 are **done, committed, and deployed** (Phase 6 islive at `aa8c5f3`). **Phase 7 is done on dev** (health verdict + two-tier footer+ live market summary); commit + deploy pending. With Phase 7 the roadmap is**complete** — remaining items are all in Backlog / parked.Phase 7 outcome (health distillation + footer + live breadth):- **Systems verdict on `/health`.** A one-line plain read distilling the whole  page — `● All systems normal · both data sources healthy · N jobs on schedule  · last fetch 4m ago` — sits above the Endpoints/Jobs/Log detail. Built  client-side in `health.js` (`renderVerdict`) from the same snapshot that drives  the detail, so it stays live and never disagrees with it. Tone is the worst  thing on the page: a tripped breaker or errored job → bad (red); a recovering  breaker or stale job → warn (amber, pulsing dot); else ok (green). A mid-fetch  job is normal and doesn't darken the tone (the existing "Fetching now —" banner  names it). Verified on dev: reads green "All systems normal · both data sources  healthy · 10 jobs on schedule · fetching now" at desktop + mobile.- **Two-tier footer (Paper Ledger).** Replaced the single-line footer with the  house pattern: a `footer__grid` (About + data attribution / Pages / Elsewhere,  `// LABEL` mono headers) over a slim `.footer-bar` (`© {{ now.year }} Isaac  Bythewood · Some rights reserved` + a GitHub icon link to  `github.com/overshard/finance`). Modeled on `repos` but in Paper Ledger tokens  (warm-paper wash, hairline rules, ink text). The stale-Stooq attribution (fixed  early in Phase 4) folds into the About column as "Market data via Yahoo Finance  · fundamentals & filings via SEC EDGAR · prices are delayed · not investment  advice." The `.footer-bar` now carries the mobile bottom-nav clearance (it's the  last element before the fixed nav); the desktop block trims it back. Verified:  3-up at desktop, About-spans-full + 2-up links at mobile, copyright bar clears  the bottom nav.- **Live market summary (the Phase-5 deferral).** The hero verdict + headline  figures + breadth were page-load snapshots; now a new `src/summary.rs` owns the  verdict vocabulary (`market_verdict` + `vix_tone`, moved out of `home.rs` so the  page render and the live push share one wording) and a cheap  `market_summary(pool, session)` (two aggregate reads: breadth over the curated  stocks + the lead index / VIX, resolving last-vs-prev exactly as the home  queries do). A new `StreamEvent::Summary` carries it; the scheduler publishes it  after an intraday sweep that touched a **pulse ticker** (the broad index or  ^VIX — the curated stocks behind breadth only move at the daily close, so a  sweep that hit only a viewed stock page is skipped), after `daily_close`, and on  a session flip (the lead index swaps cash↔future). `base/stream.js`  re-broadcasts it as a `finance:summary` window event; a new `home.js` patches  the hero verdict/detail/figures + breadth counts/bar in place — no second  EventSource. **The live push couldn't be exercised on dev** (closed-market  weekend, no quotes stream — same caveat as Phase 6's live tick); it runs first  in prod during market hours. The patcher itself was verified structurally by  dispatching a synthetic `finance:summary`: the verdict, all three figures, the  breadth counts, and the proportion-bar widths all updated correctly.- **Verified on dev:** `cargo build` + `vite build` clean; home + health render at  desktop (1280) and mobile (390) with no console errors; the new footer and  health verdict read correctly; the synthetic-summary patch updates every hero +  breadth node (screenshots reviewed, then deleted).Phase 6 outcome (symbol-page distillation + live intraday on chart):- **1D / 1W intraday range buttons.** Two new ranges at the front of the chart's
@@ -395,7 +441,18 @@ Kill the rate-limit problem at the root.  Order: price/change → verdict → chart.- ✅ Fixed a latent `.ind-btn[hidden]` override bug found in passing.### Phase 7 — Health/systems page distillation + final polish pass### Phase 7 — Health/systems page distillation + final polish pass  ✅ DONE on dev (commit + deploy pending)- ✅ **`/health` distilled with a top systems verdict** (the chosen shape): a  one-line plain read above the existing detail, computed live in `health.js`.- ✅ **Two-tier footer built** (`footer__grid` + `.footer-bar`, Paper Ledger), data  attribution folded into the About column.- ✅ **Live market summary** (the Phase-5 hero/breadth deferral): `src/summary.rs`  + `StreamEvent::Summary` + scheduler publishes + `home.js` patches. Live path  runs first in prod (closed-market weekend can't stream on dev).- Light cohesion only (per the scope decision); no full cross-page audit.- See Status + the 2026-05-30 Phase 7 decisions log for the full outcome.Original Phase 7 brief (kept for reference):- Distill `/health` and overall cross-page cohesion; one closing UI polish pass.- **Expand the footer to match the sibling-project pattern.** Finance currently  has a single-line footer; the user wants it grown "a lot" to match how the
@@ -454,6 +511,32 @@ Kill the rate-limit problem at the root.## Decisions log**2026-05-30 — Phase 7 (health distillation + footer + live breadth) design forksresolved.** Answered 3 clarifying questions before building:1. **/health distillation = add a top "verdict" summary** (like the dashboard   hero): a one-line plain-language systems read at the top (`● All systems   normal · both data sources healthy · N jobs on schedule · last fetch 4m ago`),   keeping the existing Endpoints / Background jobs / Activity-log detail below.   Computed client-side in `health.js` from the snapshot so it stays live.2. **Build live breadth now** (not parked). The Phase-5 hero verdict + breadth   were page-load snapshots; add a server-pushed market-summary event on the   stream hub so the verdict sentence, the headline figures, and the breadth   counts/bar recompute intraday instead of going stale beside the live-ticking   index chips. (Runs first in prod during market hours — a closed-market weekend   can't stream quotes on dev, like Phase 6's live tick.)3. **Polish scope = footer + health properly, light cohesion only.** Build the   two-tier footer (modeled on `repos`' `footer__grid`, adapted to Paper Ledger)   and the health verdict; do a light spacing/heading cohesion sweep only where   quick, rather than a full cross-page audit.Implementation shape: a new `src/summary.rs` owns the verdict vocabulary(`market_verdict` + `vix_tone`, moved out of `home.rs` so page-load and live pushshare one wording) and a cheap `market_summary(pool, session)` (two aggregatereads: breadth over curated stocks + the lead index / VIX). A new`StreamEvent::Summary` carries it; the scheduler publishes it after an intradaysweep that touched a pulse ticker (the broad index or ^VIX) and after daily_close.`base/stream.js` re-broadcasts it as a `finance:summary` window event; a new`home.js` patches the hero + breadth DOM in place.**2026-05-30 — Phase 6 (symbol-page distillation + live intraday).** Answered 3design forks before building:1. **Intraday = dedicated 1D + 1W range buttons** (not a single live candle
modified frontend/static_src/base/scripts/stream.js
@@ -244,6 +244,18 @@ export function initStream() {      // it is purely a nudge to re-pull /api/health.      window.dispatchEvent(new Event("finance:health"));    });    es.addEventListener("summary", (e) => {      // Re-broadcast the recomputed dashboard summary (Phase 7) so the home      // page can patch its hero verdict + breadth without a second connection.      // Only the dashboard listens; every other page ignores it.      try {        window.dispatchEvent(          new CustomEvent("finance:summary", { detail: JSON.parse(e.data) }),        );      } catch {        /* ignore a malformed frame */      }    });  }  connect();
modified frontend/static_src/base/styles/base.scss
@@ -437,22 +437,111 @@ main {  flex: 1 0 auto;}/* Two-tier footer (Phase 7): a multi-column upper block (About / Pages /   Elsewhere) over a slim copyright bar. Mirrors the sibling apps' shape in   Paper Ledger tokens — warm-paper wash, hairline rules, `// LABEL` mono   column headers. */.footer {  border-top: 1px solid var(--rule);  background: var(--paper-edge);  margin-top: 52px;  /* the extra bottom room clears the fixed bottom nav on phones; the     desktop block below trims it back since desktop has no bottom nav */  padding: 22px 16px calc(22px + 64px + env(safe-area-inset-bottom));  margin-top: 56px;  padding-top: 40px;  padding-bottom: 36px;}.footer__grid {  display: grid;  gap: 36px;  grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr) minmax(0, 1fr);  @media (max-width: 640px) {    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);    gap: 28px 24px;  }}.footer__label {  font-family: var(--font-mono);  font-size: var(--fs-2xs);  font-weight: 600;  letter-spacing: 0.1em;  text-transform: uppercase;  color: var(--ink-faint);  font-size: 0.8rem;  text-align: center;  margin-bottom: 14px;}.footer a {  color: var(--ink-dim);  text-decoration: underline;  text-underline-offset: 2px;.footer__about {  /* the lede column spans the full width before the link columns wrap under it */  @media (max-width: 640px) {    grid-column: 1 / -1;  }  p {    color: var(--ink-dim);    font-size: var(--fs-sm);    line-height: 1.65;    margin: 0 0 12px;    max-width: 52ch;  }}.footer__attrib {  color: var(--ink-faint);  font-size: var(--fs-xs);  span { color: var(--ink-dim); }  strong { color: var(--ink-dim); font-weight: 600; }}.footer__col {  ul {    list-style: none;    margin: 0;    padding: 0;  }  li { margin-bottom: 9px; }  a {    color: var(--ink-dim);    font-size: var(--fs-sm);    text-decoration: none;    transition: color 150ms ease;    &:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 2px; }  }}/* Slim copyright bar, like blog/status/analytics/repos. It is the last element   before the fixed mobile bottom-nav, so it carries the nav clearance on   phones; the desktop block below trims it back. */.footer-bar {  border-top: 1px solid var(--rule);  background: var(--paper-edge);  padding: 14px 0 calc(14px + 64px + env(safe-area-inset-bottom));  color: var(--ink-faint);  font-size: var(--fs-xs);}.footer-bar__row {  display: flex;  align-items: center;  justify-content: space-between;  gap: 16px;  @media (max-width: 480px) {    flex-direction: column;    gap: 10px;    text-align: center;  }}.footer-bar__link {  display: inline-flex;  color: var(--ink-faint);  transition: color 150ms ease;  &:hover { color: var(--ink); }}/* ---------- empty state ---------- */
@@ -766,9 +855,9 @@ button:focus-visible {    display: none;  }  /* no bottom nav on desktop, so the footer needs no clearance for it */  .footer {    padding-bottom: 22px;  /* no bottom nav on desktop, so the copyright bar needs no clearance for it */  .footer-bar {    padding-bottom: 14px;  }  .wrap {
modified frontend/static_src/health/scripts/health.js
@@ -169,6 +169,68 @@ function renderLog(list) {  return `<div class="log"><ul class="logrows">${rows.join("")}</ul></div>`;}// The top systems verdict (Phase 7): distil the whole snapshot into one plain// read — overall tone, a headline, and a supporting clause. Tone is the worst// thing on the page: a tripped breaker or an errored job is bad; a recovering// breaker or a stale job is working; otherwise all-clear. A mid-fetch job is// normal and does not darken the tone (the live banner below names it).function renderVerdict(snap) {  const el = $('[data-role="verdict"]');  if (!el) return;  const eps = snap.endpoints || [];  const jobs = snap.jobs || [];  const log = snap.log || [];  const epOpen = eps.filter((e) => e.state === "open").length;  const epHalf = eps.filter((e) => e.state === "half_open").length;  const epHealthy = eps.filter((e) => e.state === "closed").length;  const jobErr = jobs.filter((j) => j.state === "error").length;  const jobStale = jobs.filter((j) => j.state === "stale").length;  const fetching = jobs.filter((j) => j.state === "fetching").length;  let tone, head;  if (epOpen || jobErr) {    tone = "bad";    head = "Data flow degraded";  } else if (epHalf || jobStale) {    tone = "warn";    head = "Recovering";  } else {    tone = "ok";    head = "All systems normal";  }  // Sources clause: "both data sources healthy" reads best at the usual two  // (Yahoo + SEC), with a fraction when any is down.  let srcPart;  if (!eps.length) {    srcPart = "no sources contacted yet";  } else if (epHealthy === eps.length) {    srcPart = eps.length === 2 ? "both data sources healthy" : `all ${eps.length} data sources healthy`;  } else {    srcPart = `${epHealthy}/${eps.length} data sources healthy`;  }  // Jobs clause: how many are on schedule (anything not errored or stale).  let jobPart;  if (!jobs.length) {    jobPart = "no jobs yet";  } else if (jobErr || jobStale) {    jobPart = `${jobs.length - jobErr - jobStale}/${jobs.length} jobs on schedule`;  } else {    jobPart = `${jobs.length} jobs on schedule`;  }  const parts = [srcPart, jobPart];  if (fetching) parts.push("fetching now");  else if (log.length) parts.push(`last fetch ${ago(log[0].started_at)}`);  el.dataset.tone = tone;  el.hidden = false;  $('[data-role="verdict-head"]').textContent = head;  $('[data-role="verdict-detail"]').textContent = parts.join(` ${DASH} `);}function renderBanner(jobs) {  const banner = $('[data-role="banner"]');  if (!banner) return;
@@ -194,6 +256,7 @@ export function initHealth() {  function render(snap) {    current = snap;    renderVerdict(snap);    $('[data-role="endpoints"]').innerHTML = renderEndpoints(snap.endpoints);    $('[data-role="jobs"]').innerHTML = renderJobs(snap.jobs);    $('[data-role="log"]').innerHTML = renderLog(snap.log);
modified frontend/static_src/health/styles/health.scss
@@ -14,6 +14,65 @@  font-size: 0.6rem;}/* ---------- systems verdict (Phase 7) ----------   The distilled top read: a coloured dot + headline + one-line clause. Tones   reuse the semantic good/ok/bad tokens; filled live by the page script. */.health-verdict {  display: flex;  align-items: center;  gap: 14px;  margin: 18px 0 6px;  padding: 14px 16px;  border: 1px solid var(--rule);  border-radius: var(--radius);  background: var(--surface);}.health-verdict[hidden] {  display: none;}.health-verdict__dot {  flex: none;  width: 11px;  height: 11px;  border-radius: 50%;  background: var(--ink-faint);}.health-verdict__head {  margin: 0;  font-size: 1.05rem;  font-weight: 600;  letter-spacing: -0.01em;}.health-verdict__detail {  margin: 2px 0 0;  color: var(--ink-dim);  font-size: 0.85rem;}.health-verdict[data-tone="ok"] {  border-color: var(--up-soft);  .health-verdict__dot { background: var(--up); }}.health-verdict[data-tone="warn"] {  border-color: var(--warn);  background: var(--warn-soft);  .health-verdict__dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }}.health-verdict[data-tone="bad"] {  border-color: var(--down);  background: var(--down-soft);  .health-verdict__dot { background: var(--down); }}/* ---------- "fetching now" banner ---------- */.health-banner {  display: flex;
modified frontend/static_src/home/index.js
@@ -1,4 +1,7 @@// Markets dashboard. The live stream runs from the base entry and patches// the sparkline cards in place; this page entry only ships the dashboard's// styles.// Markets dashboard. The live stream runs from the base entry and patches the// sparkline cards in place; this entry ships the dashboard styles and the// Phase-7 live patcher for the hero verdict + breadth band.import "./styles/home.scss";import { initSummary } from "./scripts/home.js";initSummary();
added frontend/static_src/home/scripts/home.js
@@ -0,0 +1,89 @@// Markets dashboard — live hero verdict + breadth (Phase 7).//// The hero's plain-language verdict, its headline figures, and the breadth band// are server-rendered at page load and then kept live: the scheduler recomputes// a market `summary` as the indexes tick intraday, base/stream.js re-broadcasts// it as a `finance:summary` window event, and this patches those nodes in place// from it — no second EventSource. The sparkline cards already stream live via// the base quote patcher; this closes the gap the verdict + breadth left (they// used to be frozen page-load snapshots, see PLAN.md Phase 5's known limitation).//// Patch targets carry data-role hooks in home.html. A node that wasn't rendered// at load (a figure that was null then) is simply skipped — the patcher refines// what's on the page, it doesn't build missing rows.const DASH = "·";// Mirrors the server-side `pct` filter and base/stream.js's fmtPct.function fmtPct(n) {  if (n == null || Number.isNaN(n)) return DASH;  return (    n.toLocaleString("en-US", {      minimumFractionDigits: 2,      maximumFractionDigits: 2,      signDisplay: "exceptZero",    }) + "%"  );}function fmtInt(n) {  return (n ?? 0).toLocaleString("en-US");}function setText(role, text) {  const el = document.querySelector(`[data-role="${role}"]`);  if (el) el.textContent = text;}function setWidth(role, pct) {  const el = document.querySelector(`[data-role="${role}"]`);  if (el && pct != null) el.style.width = `${pct}%`;}// Set the semantic move class (green/red/flat) from a percentage change.function setMove(el, pct) {  el.classList.remove("is-up", "is-down", "is-flat");  if (pct == null || Number.isNaN(pct)) el.classList.add("is-flat");  else el.classList.add(pct >= 0 ? "is-up" : "is-down");}function applySummary(s) {  if (!s) return;  // Hero verdict + supporting clause.  setText("hero-verdict", s.verdict);  setText("hero-detail", s.detail);  // Headline figures.  const broad = document.querySelector('[data-role="hero-broad"]');  if (broad && s.broad_pct != null) {    broad.textContent = fmtPct(s.broad_pct);    setMove(broad, s.broad_pct);  }  const b = s.breadth || {};  if (b.pct_green != null) setText("hero-green", `${b.pct_green}%`);  if (s.vix_label) setText("hero-vix", s.vix_label);  // Breadth band: the counts, the share-green label, and the proportion bar.  setText("breadth-adv", fmtInt(b.advancers));  setText("breadth-dec", fmtInt(b.decliners));  setText("breadth-flat", fmtInt(b.unchanged));  if (b.pct_green != null) setText("breadth-pct", `${b.pct_green}% green`);  setWidth("breadth-up", b.up_w);  setWidth("breadth-flat-seg", b.flat_w);  setWidth("breadth-down", b.down_w);  const bar = document.querySelector('[data-role="breadth-bar"]');  if (bar && b.total != null) {    bar.setAttribute(      "aria-label",      `${b.advancers} advancing, ${b.decliners} declining of ${b.total} stocks`,    );  }}export function initSummary() {  // Only the dashboard carries the hero; elsewhere this is a no-op.  if (!document.querySelector('[data-role="hero"]')) return;  window.addEventListener("finance:summary", (e) => applySummary(e.detail));}
modified src/main.rs
@@ -11,6 +11,7 @@ mod routes;mod scheduler;mod seed;mod stream;mod summary;mod templates;pub use app::{AppState, Config};
modified src/routes/home.rs
@@ -17,6 +17,7 @@ use crate::compute::{self, Sparkline};use crate::market;use crate::models;use crate::render::render;use crate::summary::{market_verdict, vix_tone};use crate::AppState;pub fn router() -> Router<AppState> {
@@ -467,79 +468,6 @@ fn breadth(stocks: &[StockRow]) -> Breadth {    b}/// A VIX level read into one plain word. The bands suit the ^VIX cash gauge:/// sub-14 is a placid tape, the teens are normal, the low-20s start to show/// stress, and 28+ is outright fear.fn vix_tone(level: f64) -> &'static str {    match level {        v if v < 14.0 => "calm",        v if v < 20.0 => "steady",        v if v < 28.0 => "elevated",        _ => "stressed",    }}/// Blend the broad-market move, breadth, and the VIX read into the hero's/// two-line verdict. `broad_pct` is the lead index card's day change (the cash/// S&P during the regular session, its future outside it); `green_pct` the/// share of curated stocks green; `vix_level` / `vix_pct` the volatility gauge/// and its move. Returns `(lead, detail)`. A descriptive read of the tape, not/// a forecast — direction comes from the broad move, falling back to breadth/// when the index is flat; width and risk tone colour the wording.fn market_verdict(    broad_pct: Option<f64>,    green_pct: Option<u8>,    vix_level: Option<f64>,    vix_pct: Option<f64>,) -> (String, String) {    // Direction: the broad index move is the headline truth, so the verdict's    // direction tracks its sign — never the opposite of the "S&P +x%" figure    // shown beside it. A near-flat index (|move| < 0.05%) reads as mixed even if    // breadth skews, since that is genuinely a directionless tape. Breadth only    // sets direction when there is no index price at all (e.g. it never quoted).    // 1 up / -1 down / 0 flat.    let dir = match broad_pct {        Some(p) if p > 0.05 => 1,        Some(p) if p < -0.05 => -1,        Some(_) => 0,        None => match green_pct {            Some(g) if g >= 55 => 1,            Some(g) if g <= 45 => -1,            _ => 0,        },    };    // Breadth width: 2 broad / 1 split / 0 narrow.    let width = match green_pct {        Some(g) if g >= 60 => 2,        Some(g) if g <= 40 => 0,        _ => 1,    };    let vix_rising = vix_pct.is_some_and(|p| p > 4.0);    let vix_elevated = vix_level.is_some_and(|v| v >= 20.0);    let lead = match (dir, width) {        (1, 2) if !vix_elevated => "Risk-on, and broad.",        (1, 2) => "Higher across the board.",        (1, 0) => "Higher, but narrow.",        (1, _) => "Modestly higher.",        (-1, _) if vix_rising || vix_elevated => "Risk-off.",        (-1, 0) => "Broadly lower.",        (-1, _) => "Softer today.",        _ => "Quiet, mixed tape.",    };    let move_word = match dir {        1 => "higher",        -1 => "lower",        _ => "little changed",    };    let part_word = match width {        2 => "wide participation",        0 => "narrow participation",        _ => "mixed participation",    };    (lead.to_string(), format!("Markets {move_word} with {part_word}."))}/// Assemble the hero from the already-loaded index + commodity cards and the/// breadth read. The broad-market move is the lead index card (resolved to the/// cash S&P or its future by `dashboard_cards`); the VIX read is the lead
modified src/routes/stream.rs
@@ -99,6 +99,9 @@ async fn stream(                Ok(StreamEvent::Market { session }) => {                    yield Ok(sse_market(&session));                }                Ok(StreamEvent::Summary(s)) => {                    yield Ok(sse_summary(&s));                }                Ok(StreamEvent::Health) => {                    yield Ok(sse_health());                }
@@ -167,6 +170,14 @@ fn sse_market(session: &str) -> Event {        .data(format!("{{\"session\":\"{session}\"}}"))}/// The recomputed dashboard market summary (Phase 7). An open `/` page patches/// its hero verdict + headline figures + breadth from this payload.fn sse_summary(s: &crate::summary::MarketSummary) -> Event {    Event::default()        .event("summary")        .data(serde_json::to_string(s).unwrap_or_default())}/// A content-free nudge: an open `/health` page answers it by pulling a fresh/// snapshot from `/api/health`. See `routes::health`.fn sse_health() -> Event {
modified src/scheduler.rs
@@ -40,6 +40,7 @@ use crate::providers::{    PortfolioData, Quote, QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::summary;use crate::{seed, Config};/// How often the loop wakes to check whether a job is due. The jobs themselves
@@ -248,6 +249,12 @@ pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle                hub.publish(StreamEvent::Market {                    session: session.as_str().to_string(),                });                // The lead index swaps between the cash S&P and its future on a                // session change, so the verdict's basis moves — push a fresh                // summary so an open dashboard re-reads against the new lead.                hub.publish(StreamEvent::Summary(                    summary::market_summary(&pool, session).await,                ));                last_session = Some(session);            }
@@ -742,6 +749,19 @@ async fn run_intraday(        }        mark_ok(pool, "intraday", None).await?;    }    // If this sweep touched a symbol that moves the dashboard verdict (the broad    // index or ^VIX), recompute and push the market summary so an open dashboard    // re-reads its hero + figures live. The curated stocks behind breadth don't    // move intraday (they only price at the daily close), so a sweep that hit    // only a viewed stock page leaves the summary unchanged and we skip it.    if ok > 0 {        let pulse = summary::pulse_tickers(session);        if targets.iter().any(|t| pulse.contains(&t.as_str())) {            hub.publish(StreamEvent::Summary(summary::market_summary(pool, session).await));        }    }    notify_health(hub);    Ok(())}
@@ -860,6 +880,16 @@ async fn run_daily_close_if_due(            mark_ok(pool, "daily_close", None).await?;        }    }    // The close just re-priced the whole universe, so breadth and the verdict    // have moved — push a fresh summary for any dashboard still open after the    // bell. Use the live session so the lead index resolves the same way the    // page would (the close runs just after 16:00 ET, in the post session).    if ok > 0 {        let session = market::session_at(chrono::Utc::now());        hub.publish(StreamEvent::Summary(summary::market_summary(pool, session).await));    }    notify_health(hub);    Ok(())}
modified src/stream.rs
@@ -21,6 +21,8 @@ use std::sync::Mutex;use serde::Serialize;use tokio::sync::broadcast;use crate::summary::MarketSummary;/// Broadcast channel depth. One full universe sweep (~144 quote events) sits/// well under this; a subscriber that still lags is handled by skipping the/// gap (see `routes::stream`), never by stalling a publisher.
@@ -34,6 +36,11 @@ const CHANNEL_CAPACITY: usize = 1024;pub enum StreamEvent {    Quote(QuoteUpdate),    Market { session: String },    /// A recomputed dashboard market summary — the hero verdict, headline    /// figures, and breadth — pushed as index quotes tick intraday so the    /// home page's verdict and breadth stay live (Phase 7). Published by the    /// scheduler; an open `/` page patches the hero + breadth DOM in place.    Summary(MarketSummary),    /// A background-data state change — a job started or finished, a    /// `fetch_log` row landed. Carries no payload: it is a nudge telling an    /// open `/health` page to pull a fresh snapshot from `/api/health`.
added src/summary.rs
@@ -0,0 +1,264 @@//! Live market-summary snapshot — the dashboard hero verdict + market breadth,//! computed straight from stored prices.//!//! This is the one source of truth for the dashboard's plain-language read of//! the day. It is used two ways://!  - the home route renders it at page load (via the verdict vocabulary below,//!    [`market_verdict`] / [`vix_tone`]); and//!  - the scheduler recomputes it as index quotes tick intraday and pushes it//!    over the stream as a [`crate::stream::StreamEvent::Summary`], so the//!    verdict sentence, the headline figures, and the breadth counts stay live//!    instead of going stale beside the live-ticking index chips (Phase 7).//!//! For a Python reader: think of [`market_summary`] as a small read-model query//! — two cheap aggregate reads (breadth across the curated stocks, plus the//! lead index and ^VIX) folded into one serializable struct the browser patches//! into the page.use serde::Serialize;use sqlx::SqlitePool;use crate::compute;use crate::market::Session;/// The broad-market index the hero verdict reads: the cash S&P during the/// regular session, its E-mini future outside it (so the verdict stays live/// overnight, matching how `home::dashboard_cards` resolves the lead card).const BROAD_CASH: &str = "^SPX";const BROAD_FUTURE: &str = "ES=F";/// The volatility gauge folded into the risk tone. No tradable future on Yahoo,/// so it is always the cash level.const VIX: &str = "^VIX";/// The dashboard hero verdict + market breadth in one payload, shaped for the/// browser to patch in place. Mirrors the fields the home template renders at/// load so a pushed update and the server render agree.#[derive(Debug, Clone, Serialize, Default)]pub struct MarketSummary {    /// The punchy lead, e.g. "Higher, but narrow."    pub verdict: String,    /// The supporting clause, e.g. "Markets higher with narrow participation."    pub detail: String,    /// The broad-market day change (the lead index move); drives the "S&P +x%"    /// stat. `None` until that symbol prices.    pub broad_pct: Option<f64>,    /// The VIX read folded into one phrase, e.g. "calm at 13.2". `None` until    /// ^VIX prices.    pub vix_label: Option<String>,    /// Advancers vs decliners across the curated large-cap stocks.    pub breadth: BreadthCounts,}/// Market breadth across the curated large-cap stocks: how many are advancing/// vs declining today, the share green, and the proportion-bar segment widths./// Field names match the home template's `breadth.*` so the page render and a/// pushed update patch the same nodes.#[derive(Debug, Clone, Serialize, Default)]pub struct BreadthCounts {    pub advancers: usize,    pub decliners: usize,    pub unchanged: usize,    /// Stocks with a computable day change (advancers + decliners + unchanged).    pub total: usize,    /// Advancers as a percent of `total`, rounded; `None` when `total` is 0.    pub pct_green: Option<u8>,    /// Proportion-bar segment widths (percent of `total`): green, flat, red.    pub up_w: f64,    pub flat_w: f64,    pub down_w: f64,}/// The dashboard symbols whose live quotes move the hero verdict: the broad/// index (cash or its future, by session) and ^VIX. The scheduler uses this to/// decide whether an intraday sweep touched anything worth re-pushing a summary/// for (the curated stocks behind breadth only move at the daily close).pub fn pulse_tickers(session: Session) -> [&'static str; 2] {    let broad = if matches!(session, Session::Regular) {        BROAD_CASH    } else {        BROAD_FUTURE    };    [broad, VIX]}/// Build the full market summary from stored prices. Two cheap reads: a breadth/// scan over the curated stocks and a two-symbol pull for the lead index + VIX./// Both resolve the live last price, falling back to the latest stored daily/// close, exactly as the home route's card/breadth queries do — so a pushed/// update reconciles with what the page rendered.pub async fn market_summary(pool: &SqlitePool, session: Session) -> MarketSummary {    let breadth = breadth_snapshot(pool).await;    let [broad_ticker, _] = pulse_tickers(session);    let broad_pct = day_change_pct(pool, broad_ticker).await;    let vix_level = latest_price(pool, VIX).await;    let vix_pct = day_change_pct(pool, VIX).await;    let (verdict, detail) = market_verdict(broad_pct, breadth.pct_green, vix_level, vix_pct);    let vix_label = vix_level.map(|v| format!("{} at {:.1}", vix_tone(v), v));    MarketSummary {        verdict,        detail,        broad_pct,        vix_label,        breadth,    }}/// Market breadth across the curated large-cap stocks. One read of each stock's/// resolved last/prev price (live quote, else latest daily close), folded the/// same way [`crate::routes::home`]'s in-memory breadth does: a stock without a/// computable change (no price, or a non-positive prior close) is left out of/// every count so a missing quote never reads as "flat".async fn breadth_snapshot(pool: &SqlitePool) -> BreadthCounts {    let rows: Vec<(Option<f64>, Option<f64>)> = sqlx::query_as(        "SELECT \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(pool)    .await    .unwrap_or_default();    let mut b = BreadthCounts::default();    for (last, prev) in rows {        let (Some(last), Some(prev)) = (last, prev) else {            continue;        };        if prev <= 0.0 {            continue;        }        b.total += 1;        if last > prev {            b.advancers += 1;        } else if last < prev {            b.decliners += 1;        } else {            b.unchanged += 1;        }    }    if b.total > 0 {        let total = b.total as f64;        b.pct_green = Some((b.advancers as f64 / total * 100.0).round() as u8);        b.up_w = b.advancers as f64 / total * 100.0;        b.down_w = b.decliners as f64 / total * 100.0;        b.flat_w = (100.0 - b.up_w - b.down_w).max(0.0);    }    b}/// One symbol's resolved last price (live quote, else latest daily close).async fn latest_price(pool: &SqlitePool, ticker: &str) -> Option<f64> {    sqlx::query_scalar(        "SELECT COALESCE(s.last_price, \           (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)) \         FROM symbols s WHERE s.ticker = ?",    )    .bind(ticker)    .fetch_optional(pool)    .await    .ok()    .flatten()    .flatten()}/// One symbol's day change percent from its resolved last vs prior close.async fn day_change_pct(pool: &SqlitePool, ticker: &str) -> Option<f64> {    let row: Option<(Option<f64>, Option<f64>)> = sqlx::query_as(        "SELECT \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \         FROM symbols s WHERE s.ticker = ?",    )    .bind(ticker)    .fetch_optional(pool)    .await    .ok()    .flatten();    match row {        Some((Some(last), Some(prev))) => Some(compute::change(last, prev).pct),        _ => None,    }}/// A VIX level read into one plain word. The bands suit the ^VIX cash gauge:/// sub-14 is a placid tape, the teens are normal, the low-20s start to show/// stress, and 28+ is outright fear.pub fn vix_tone(level: f64) -> &'static str {    match level {        v if v < 14.0 => "calm",        v if v < 20.0 => "steady",        v if v < 28.0 => "elevated",        _ => "stressed",    }}/// Blend the broad-market move, breadth, and the VIX read into the hero's/// two-line verdict. `broad_pct` is the lead index card's day change (the cash/// S&P during the regular session, its future outside it); `green_pct` the/// share of curated stocks green; `vix_level` / `vix_pct` the volatility gauge/// and its move. Returns `(lead, detail)`. A descriptive read of the tape, not/// a forecast — direction comes from the broad move, falling back to breadth/// when the index is flat; width and risk tone colour the wording.pub fn market_verdict(    broad_pct: Option<f64>,    green_pct: Option<u8>,    vix_level: Option<f64>,    vix_pct: Option<f64>,) -> (String, String) {    // Direction: the broad index move is the headline truth, so the verdict's    // direction tracks its sign — never the opposite of the "S&P +x%" figure    // shown beside it. A near-flat index (|move| < 0.05%) reads as mixed even if    // breadth skews, since that is genuinely a directionless tape. Breadth only    // sets direction when there is no index price at all (e.g. it never quoted).    // 1 up / -1 down / 0 flat.    let dir = match broad_pct {        Some(p) if p > 0.05 => 1,        Some(p) if p < -0.05 => -1,        Some(_) => 0,        None => match green_pct {            Some(g) if g >= 55 => 1,            Some(g) if g <= 45 => -1,            _ => 0,        },    };    // Breadth width: 2 broad / 1 split / 0 narrow.    let width = match green_pct {        Some(g) if g >= 60 => 2,        Some(g) if g <= 40 => 0,        _ => 1,    };    let vix_rising = vix_pct.is_some_and(|p| p > 4.0);    let vix_elevated = vix_level.is_some_and(|v| v >= 20.0);    let lead = match (dir, width) {        (1, 2) if !vix_elevated => "Risk-on, and broad.",        (1, 2) => "Higher across the board.",        (1, 0) => "Higher, but narrow.",        (1, _) => "Modestly higher.",        (-1, _) if vix_rising || vix_elevated => "Risk-off.",        (-1, 0) => "Broadly lower.",        (-1, _) => "Softer today.",        _ => "Quiet, mixed tape.",    };    let move_word = match dir {        1 => "higher",        -1 => "lower",        _ => "little changed",    };    let part_word = match width {        2 => "wide participation",        0 => "narrow participation",        _ => "mixed participation",    };    (        lead.to_string(),        format!("Markets {move_word} with {part_word}."),    )}
modified templates/base.html
@@ -57,10 +57,55 @@    </a>  </nav>  {# Two-tier footer (Phase 7), modeled on the sibling apps' pattern (repos'     `footer__grid` with `// LABEL` mono headers) but in Paper Ledger tokens: a     multi-column upper block over a slim copyright bar. The data attribution     (was the old one-line footer) folds into the About column. #}  <footer class="footer">    <p>{{ site.title }} · Market data via Yahoo Finance · Fundamentals via SEC EDGAR · prices are delayed · not investment advice · <a href="/health">data health</a> · &copy; {{ now.year }} Isaac Bythewood</p>    <div class="wrap footer__grid">      <div class="footer__about">        <div class="footer__label">// {{ site.title }}</div>        <p>A self-hosted market watcher for stocks, ETFs, indexes, and          commodities: live charts, key stats, fundamentals, SEC filings, and          at-a-glance health reads. For watching the market only &mdash; no          portfolio, no accounts.</p>        <p class="footer__attrib">Market data via <span>Yahoo Finance</span> ·          fundamentals &amp; filings via <span>SEC EDGAR</span> · prices are          delayed · <strong>not investment advice</strong>.</p>      </div>      <div class="footer__col">        <div class="footer__label">// Pages</div>        <ul>          <li><a href="/">Markets</a></li>          <li><a href="/industries">Industries</a></li>          <li><a href="/search">Search</a></li>          <li><a href="/health">Data health</a></li>          <li><a href="https://github.com/overshard/finance" target="_blank" rel="noopener">Source</a></li>        </ul>      </div>      <div class="footer__col">        <div class="footer__label">// Elsewhere</div>        <ul>          <li><a href="https://isaacbythewood.com/" target="_blank" rel="noopener">Portfolio</a></li>          <li><a href="https://blog.bythewood.me/" target="_blank" rel="noopener">Blog</a></li>          <li><a href="https://analytics.bythewood.me/" target="_blank" rel="noopener">Analytics</a></li>          <li><a href="https://status.bythewood.me/" target="_blank" rel="noopener">Status</a></li>        </ul>      </div>    </div>  </footer>  <div class="footer-bar">    <div class="wrap footer-bar__row">      <small>&copy; {{ now.year }} Isaac Bythewood · Some rights reserved</small>      <a href="https://github.com/overshard/finance" target="_blank" rel="noopener" class="footer-bar__link" aria-label="Source on GitHub">        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">          <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>        </svg>      </a>    </div>  </div>  <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script>  {% block extra_js %}{% endblock %}</body>
modified templates/pages/health.html
@@ -9,6 +9,19 @@    <h1>Data health</h1>    <span class="health-asof" data-role="asof"></span>  </div>  {# Systems verdict (Phase 7): a one-line plain read distilling the whole page     — are the upstreams healthy, are the jobs on schedule, how fresh is the     last fetch. Filled + kept live by the page script from the same snapshot     that drives the detail below, so it never disagrees with it. #}  <div class="health-verdict" data-role="verdict" hidden>    <span class="health-verdict__dot"></span>    <div class="health-verdict__text">      <p class="health-verdict__head" data-role="verdict-head"></p>      <p class="health-verdict__detail" data-role="verdict-detail"></p>    </div>  </div>  <p class="health-lede">    Every background fetch this app makes, laid open: each upstream's request    guard and hourly budget, what each scheduler job is doing, and a live tail
modified templates/pages/home.html
@@ -24,16 +24,18 @@  {# Hero (Phase 5): a one-line plain read of the day — the broad move, breadth,     and the VIX risk tone blended into a verdict — over a compact index strip.     A page-load snapshot: the verdict is descriptive, not a forecast, so the     non-advice note rides with it. #}  <section class="hero">     Rendered at page load, then kept live (Phase 7): the scheduler pushes a     recomputed `summary` over the stream as the indexes tick, and home.js     patches the verdict, the figures, and the breadth band below in place. The     data-role hooks are those patch targets. #}  <section class="hero" data-role="hero">    <div class="hero__read">      <p class="hero__verdict">{{ hero.verdict }}</p>      <p class="hero__detail">{{ hero.detail }}</p>      <p class="hero__verdict" data-role="hero-verdict">{{ hero.verdict }}</p>      <p class="hero__detail" data-role="hero-detail">{{ hero.detail }}</p>      <p class="hero__stats">        {% if hero.broad_pct is not none %}<span class="hero__stat">S&amp;P <span class="num {{ 'is-up' if hero.broad_pct >= 0 else 'is-down' }}">{{ hero.broad_pct|pct }}</span></span>{% endif %}        {% if hero.green_pct is not none %}<span class="hero__stat"><span class="num">{{ hero.green_pct }}%</span> of the S&amp;P green</span>{% endif %}        {% if hero.vix_label %}<span class="hero__stat">VIX {{ hero.vix_label }}</span>{% endif %}        {% if hero.broad_pct is not none %}<span class="hero__stat">S&amp;P <span class="num {{ 'is-up' if hero.broad_pct >= 0 else 'is-down' }}" data-role="hero-broad">{{ hero.broad_pct|pct }}</span></span>{% endif %}        {% if hero.green_pct is not none %}<span class="hero__stat"><span class="num" data-role="hero-green">{{ hero.green_pct }}%</span> of the S&amp;P green</span>{% endif %}        {% if hero.vix_label %}<span class="hero__stat">VIX <span data-role="hero-vix">{{ hero.vix_label }}</span></span>{% endif %}      </p>      <p class="hero__note">A plain read of today&rsquo;s tape &mdash; not investment advice.</p>    </div>
@@ -64,18 +66,18 @@  <h2 class="section-title">Breadth{% if movers_asof %}<span class="section-title__asof">prices as of {{ movers_asof|asof }}</span>{% endif %}</h2>  <p class="section-note">How many of the curated large-cap stocks are advancing     versus declining today, and the share trading green.</p>  <div class="breadth">  <div class="breadth" data-role="breadth">    <div class="breadth__stats">      <span class="breadth__stat breadth__stat--up"><span class="breadth__num num">{{ breadth.advancers }}</span> advancing</span>      <span class="breadth__stat breadth__stat--down"><span class="breadth__num num">{{ breadth.decliners }}</span> declining</span>      {% if breadth.unchanged %}<span class="breadth__stat breadth__stat--flat"><span class="breadth__num num">{{ breadth.unchanged }}</span> flat</span>{% endif %}      {% if breadth.pct_green is not none %}<span class="breadth__pct num">{{ breadth.pct_green }}% green</span>{% endif %}      <span class="breadth__stat breadth__stat--up"><span class="breadth__num num" data-role="breadth-adv">{{ breadth.advancers }}</span> advancing</span>      <span class="breadth__stat breadth__stat--down"><span class="breadth__num num" data-role="breadth-dec">{{ breadth.decliners }}</span> declining</span>      {% if breadth.unchanged %}<span class="breadth__stat breadth__stat--flat"><span class="breadth__num num" data-role="breadth-flat">{{ breadth.unchanged }}</span> flat</span>{% endif %}      {% if breadth.pct_green is not none %}<span class="breadth__pct num" data-role="breadth-pct">{{ breadth.pct_green }}% green</span>{% endif %}    </div>    <div class="breadth__bar" role="img"    <div class="breadth__bar" role="img" data-role="breadth-bar"         aria-label="{{ breadth.advancers }} advancing, {{ breadth.decliners }} declining of {{ breadth.total }} stocks">      <span class="breadth__seg breadth__seg--up" style="width: {{ breadth.up_w }}%"></span>      <span class="breadth__seg breadth__seg--flat" style="width: {{ breadth.flat_w }}%"></span>      <span class="breadth__seg breadth__seg--down" style="width: {{ breadth.down_w }}%"></span>      <span class="breadth__seg breadth__seg--up" style="width: {{ breadth.up_w }}%" data-role="breadth-up"></span>      <span class="breadth__seg breadth__seg--flat" style="width: {{ breadth.flat_w }}%" data-role="breadth-flat-seg"></span>      <span class="breadth__seg breadth__seg--down" style="width: {{ breadth.down_w }}%" data-role="breadth-down"></span>    </div>  </div>  {% endif %}
@@ -201,3 +203,5 @@  {% endif %}</div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/home/index.js') }}"></script>{% endblock %}