repos

Show data age across the app (Phase 22)

39a863e6 by Isaac Bythewood · 1 month ago

Show data age across the app (Phase 22)

A quiet data-freshness caption now rides on every data surface, not just
/health: each home section and the search results show 'prices as of ...',
the symbol header shows the live quote's age, and each symbol-page section
title shows its last SEC sync or close date.

- New minijinja filters: asof (epoch-ms -> absolute market-clock anchor)
  and shortdate (YYYY-MM-DD -> short date); ago is reused for sync ages.
- Home/search captions sit per section; the live Indexes/Commodities ones
  refresh via the stream client as quotes land, others are page-load values.
- Shared .section-title__asof style; .section-title wraps on narrow phones.
modified PLAN.md
@@ -32,17 +32,17 @@ and resume cleanly from this file alone, keeping token use low._Last updated: 2026-05-22_**Current phase: none in progress. Phase 23 + 24 (financials table — Q4column and readability) is complete, verified, and deployed.** Phases 0**Current phase: none in progress. Phase 22 (show data age everywhere) iscomplete and verified locally; not yet committed or deployed.** Phases 0through 12 (the MVP) plus Phase 14 (company leadership), Phase 18 (ETFprofiles), Phase 20 (strongest & weakest home panels), Phase 21 (home &search refinements) and Phase 23 + 24 are complete, verified, and **live inproduction at https://finance.bythewood.me**. Phase 23 + 24 were builttogether as one financials-table pass on 2026-05-22 (see the Done list andthe decisions log): a derived Q4 column in the quarterly table, per-cellperiod-over-period growth cues, and an em-dash missing-value glyph in placeof the middle dot. Remaining post-MVP work: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere).search refinements) and Phase 23 + 24 (financials table) are complete,verified, and **live in production at https://finance.bythewood.me**.Phase 22 adds a consistent, quiet data-age caption across the whole app —the home dashboard, search, and every symbol-page data section, not just`/health` — see the Done list and the decisions log; it ships on the next`git push server master`. Remaining post-MVP work: the loose-orderedPhase 13, 15, 16, 17, 19 backlog.**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +
@@ -699,16 +699,73 @@ schema, unused for now.    line). Desktop (1280px) and phone (390px) render with no overflow and    zero console errors; the wide quarterly table scrolls within its card.- **Phase 22 show data age everywhere.** Complete, verified locally; not yet  committed or deployed. A consistent, quiet data-freshness caption now rides  across the whole app — the home dashboard, search, and every symbol-page  data section — not only `/health`. Built per a small design pass (see the  decisions log): a section-level caption (one quiet line per section, never  per card), and mixed wording — a relative "N ago" for live quotes and SEC  syncs, an absolute clock time or date for daily and dated data.  - **Two new minijinja filters** (`templates.rs`): `asof` (epoch-ms -> an    absolute market-clock anchor — a time of day `2:14pm` when the moment is    today, else `May 21`, else `May 21, 2024`) and `shortdate` (a    `YYYY-MM-DD` string -> `May 21` / `May 21, 2024`). The existing `ago`    filter (relative `N ago`) is reused for sync times. `asof` formats in    `America/New_York`, matching `market.rs`.  - **Home** (`routes/home.rs`, `home.html`): each of the four sections    carries a caption on its `.section-title`. Indexes / Commodities show    "prices as of {clock}" off the freshest `last_quote_at` among the    section's symbols; Today's movers the same off the curated stocks;    Strongest & weakest "fundamentals synced {ago}" off the freshest    `fundamentals_synced_at`. `spark_cards_for` now returns a    `SparkSection { cards, asof }`; `load_stocks` carries each stock's    `last_quote_at` and `fundamentals_synced_at`.  - **Live sections stay honest.** The Indexes / Commodities cards stream    live, so their caption's time sits in a `data-field="spark-asof"` span    that `stream.js` refreshes to the current market clock whenever a    sparkline-card quote lands. The movers / strongest-weakest panels are    fixed page-load snapshots (per Phases 11 and 20), so their captions are    plain static text.  - **Search** (`routes/search.rs`, `search.html`): a "Prices as of {clock}"    line above the results grid, off the freshest quote among the matches.  - **Symbol page** (`symbol.html`; `symbols.rs` needed no change — the    `SymbolRow` already carries every sync timestamp). The header's quote    line gains the real quote age — "Live · quoted {ago}", live-refreshed to    "quoted just now" by the stream client on each quote; the daily-close    fallback reads "Last close · {shortdate}". Every data section title    carries a caption: Key stats "as of {close date}", Fundamentals /    Financials "synced from SEC {ago}" (`fundamentals_synced_at`), Leadership    (`leadership_synced_at`), Fund profile (`fund_synced_at`), Recent SEC    filings (`filings_synced_at`, falling back to `fund_synced_at` for an ETF,    whose filings ride along with the Phase 18 fund sweep), Top holdings    "holdings as of {N-PORT date}". Filing dates render through `shortdate`.  - **Style.** A shared `.section-title__asof` (base.scss): a quiet ink-faint    caption riding past the section's hairline rule (`order: 1`), the    eyebrow's uppercase voice dropped for a calmer annotation; `.section-title`    gained `flex-wrap: wrap` so a long caption drops to its own line on a    narrow phone rather than overflowing. A `.results-asof` line for search.  - Verified: cargo + bun build clean. `/` shows the four section captions —    Indexes / Commodities live-refreshing their clock as SSE quotes landed    (server-rendered "8:46am" became "3:47pm" once the intraday poll ran),    movers / standings static page-load snapshots. `/search` shows the    results caption. `/s/AAPL` header read "Live · quoted just now" and every    section title carried its sync / close caption; `/s/QQQ` (ETF) showed the    fund-profile, top-holdings (N-PORT date) and filings captions, the last    correctly sourced from `fund_synced_at`. Desktop (1280px) and phone    (360px) render with no horizontal overflow and zero console errors; a    too-long caption wraps cleanly to a second line at 360px.**Resuming, next action****Phase 23 + 24 (financials table — Q4 column and readability) is complete,verified and deployed** (2026-05-22). The MVP plus Phase 14, Phase 18,Phase 20, Phase 21 and Phase 23 + 24 are live athttps://finance.bythewood.me. No phase is in progress. Remaining post-MVPwork: the loose-ordered Phase 13, 15,16, 17, 19 backlog plus Phase 22 (show data age everywhere); the user pickswhich to take next. There is still no GitHub repo for finance:the user deferred that; if one is created later, add it as `origin` and the`overshard/finance` slug already in taproot's `projects.conf` lines up.**Phase 22 (show data age everywhere) is complete and verified locally**(2026-05-22) but **not yet committed or deployed** — it ships on the next`git push server master`. The MVP plus Phase 14, Phase 18, Phase 20,Phase 21 and Phase 23 + 24 are live at https://finance.bythewood.me;Phase 22 will join them on the next push. No phase is in progress.Remaining post-MVP work: the loose-ordered Phase 13, 15, 16, 17, 19backlog; the user picks which to take next. There is still no GitHub repofor finance: the user deferred that; if one is created later, add it as`origin` and the `overshard/finance` slug already in taproot's`projects.conf` lines up.Note: Phase 18 added the `quick-xml` crate (N-PORT XML streaming parser) andmigration `0005`. A fresh `make run` applies `0005`; the ETF fund profiles
@@ -1043,7 +1100,9 @@ depend on Phase 5 (live quotes) and Phase 7 (SEC data).  exactly one symbol, redirect straight to that symbol's page instead of  rendering a one-card result the user must then click.- [ ] **Phase 22: Show data age everywhere.** (Captured 2026-05-22 from a- [x] **Phase 22: Show data age everywhere.** Complete and verified locally  2026-05-22; not yet committed or deployed. See the Phase 22 Done entry in  Status and the decisions log. (Captured 2026-05-22 from a  vibe-coding side note.) The user considers data freshness critically  important and wants the age of displayed data surfaced consistently across  the whole app, the home page included — not only on `/health`. Today
@@ -1648,6 +1707,38 @@ finance/  would be worse than the original; the em dash is now the single app-wide  empty-value mark. Deployed to production on 2026-05-22 via  `git push server master`.- **2026-05-22 — Phase 22 picked, and a design pass settled it.** Asked which  loose-ordered backlog phase to take next, the user chose Phase 22 (show data  age everywhere). The plan had flagged it as needing a design pass on the  wording and where the freshness rides; a Q&A settled two points. (1) On the  list / grid surfaces (home, search) the freshness rides as **one quiet  caption per section**, not one per card — a timestamp on each of the 8 + 8  mover rows would clutter the Paper Ledger look; a per-section line stays  skimmable. (2) **Mixed wording** — a relative "N ago" for live quotes and  SEC syncs, an absolute clock time or date for daily and dated data — since a  relative age on a daily close ("18h ago") reads vaguely where an absolute  date ("May 21") is precise, while a relative age is exactly right for a  quote you are watching tick.- **2026-05-22 — Phase 22 show data age everywhere shipped (local).** A  consistent, quiet data-freshness caption now rides across the whole app  (home dashboard, search, every symbol-page data section), not only  `/health`. Two new minijinja filters carry it: `asof` (epoch-ms -> an  absolute market-clock anchor) and `shortdate` (a `YYYY-MM-DD` string -> a  short date); the existing `ago` filter is reused for sync ages. Design  calls made during the build: (1) the home Indexes / Commodities captions  are the **only** ones that live-update — those sparkline cards stream, so  their "as of" time sits in a `data-field` the stream client refreshes as  quotes land; the movers and strongest / weakest panels are fixed page-load  snapshots (per Phases 11 and 20), so their captions are static text, and  the symbol header's quote age is reset to "just now" on each live quote.  (2) An ETF's "Recent SEC filings" caption falls back from `filings_synced_at`  to `fund_synced_at`: a stock's filings sweep stamps the former, but an ETF's  filings are stored by the Phase 18 fund-profile sweep, which stamps the  latter. (3) The `asof` filter renders an absolute clock time (not a relative  age) for the section captions precisely so they never drift stale on screen  while live prices tick; only genuinely relative things (a quote age, a sync  age) use `ago`. No schema change, no new data source, no new network calls.  Not yet committed or deployed; ships on the next `git push server master`.---
@@ -1679,4 +1770,9 @@ finance/- A stock's Financials table (`/s/AAPL`, Quarterly) shows a derived Q4 column  and per-cell up/down growth cues; a figure the company did not report shows  an em dash (`—`), not a middle dot (Phase 23 + 24).- Every data surface carries a quiet freshness caption (Phase 22): each home  section and the search results show "prices as of ...", the symbol header  shows the live quote's age, and each symbol-page section title shows its  last SEC sync or close date. The home Indexes / Commodities captions  live-update as streamed quotes land; the others are page-load values.- No automated test suite or linter, matching the sibling projects.
modified frontend/static_src/base/scripts/stream.js
@@ -44,6 +44,20 @@ function fmtPct(n) {  );}// The current time as the server-side `asof` filter renders it — a market-// clock time of day like "3:42pm" (lowercase, no space). Used to refresh the// dashboard's live section freshness captions (Phase 22).function fmtClock(d) {  return d    .toLocaleTimeString("en-US", {      timeZone: "America/New_York",      hour: "numeric",      minute: "2-digit",    })    .replace(/\s/g, "")    .toLowerCase();}// 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");
@@ -96,6 +110,10 @@ function applyQuote(q) {  lastPrice.set(q.ticker, q.price);  const dir = prev === undefined ? 0 : Math.sign(q.price - prev);  // Whether this quote landed on a dashboard sparkline card — if so, the live  // section freshness caption is refreshed once the patches are applied.  let hitSparkCard = false;  document.querySelectorAll(`[data-ticker="${q.ticker}"]`).forEach((root) => {    const price = root.querySelector('[data-field="price"]');    if (price) price.textContent = fmtMoney(q.price);
@@ -113,10 +131,17 @@ function applyQuote(q) {      setMove(chg, q.change_pct);    }    // The header's freshness caption (Phase 22): a fresh quote just landed,    // so reset its age to "just now" rather than letting it drift stale while    // the price ticks. Mirrors the server-side `ago` filter's < 5s branch.    const quoted = root.querySelector('[data-field="quoted"]');    if (quoted) quoted.textContent = "quoted just now";    // Dashboard sparkline cards track the live quote: recolour and move the    // line tip. (The price/change nodes above are already patched.)    if (root.classList.contains("spark-card") && q.change_pct != null) {      paintSparkline(root, q);    if (root.classList.contains("spark-card")) {      hitSparkCard = true;      if (q.change_pct != null) paintSparkline(root, q);    }    const isCard =
@@ -128,6 +153,16 @@ function applyQuote(q) {      root.classList.add(dir > 0 ? "flash-up" : "flash-down");    }  });  // A dashboard sparkline card just took a live quote, so its section's  // freshness caption ("prices as of …") is now current (Phase 22). Both live  // sections poll together each cycle, so refreshing them as one is honest.  if (hitSparkCard) {    const now = fmtClock(new Date());    document      .querySelectorAll('[data-field="spark-asof"]')      .forEach((el) => (el.textContent = now));  }}// Status-pill appearance per market session. The dot color reuses the
modified frontend/static_src/base/styles/base.scss
@@ -472,6 +472,9 @@ main {  @include eyebrow;  display: flex;  align-items: center;  /* wrap is a safety net: if a freshness caption cannot fit beside the rule     on a narrow phone it drops to its own line rather than overflowing. */  flex-wrap: wrap;  gap: 12px;  margin: 30px 0 13px;}
@@ -483,6 +486,20 @@ main {  background: var(--rule);}/* A quiet data-freshness caption riding at the right end of a section title   (PLAN.md Phase 22). order:1 places it past the ::after hairline rule; the   eyebrow's uppercase voice is dropped for a calmer annotation. */.section-title__asof {  order: 1;  flex: none;  text-transform: none;  letter-spacing: normal;  font-size: 0.7rem;  font-weight: 400;  color: var(--ink-faint);  white-space: nowrap;}/* ---------- ticker grid + card ---------- */.ticker-grid {  display: grid;
modified frontend/static_src/search/styles/search.scss
@@ -7,6 +7,13 @@  font-size: 0.6rem;}/* ---------- data-freshness caption above the results grid (Phase 22) ---------- */.results-asof {  margin: 4px 0 12px;  font-size: 0.76rem;  color: var(--ink-faint);}/* ---------- the on-page search field ---------- */.searchbar {  display: flex;
modified src/routes/home.rs
@@ -15,7 +15,7 @@ use serde::Serialize;use crate::compute::{self, Sparkline};use crate::market;use crate::models::{self, SymbolCardRow};use crate::models;use crate::render::render;use crate::AppState;
@@ -73,6 +73,17 @@ struct SparkCard {    up: bool,}/// One sparkline section of the dashboard (Indexes, Commodities): its cards and/// the freshest quote behind them, so the section heading can carry a quiet/// "prices as of ..." freshness caption (PLAN.md Phase 22).#[derive(Serialize)]struct SparkSection {    cards: Vec<SparkCard>,    /// The most recent `last_quote_at` (epoch-ms) across the section's symbols;    /// `None` until at least one has ever been quoted.    asof: Option<i64>,}/// One row in a movers panel.#[derive(Serialize, Clone)]struct Mover {
@@ -116,6 +127,12 @@ struct StockRow {    prev: Option<f64>,    standing: Option<compute::Standing>,    ret_12m: Option<f64>,    /// When this stock was last quoted (epoch-ms); feeds the movers panel's    /// freshness caption (PLAN.md Phase 22).    last_quote_at: Option<i64>,    /// When this stock's SEC fundamentals last synced (epoch-ms); feeds the    /// strongest / weakest panels' freshness caption.    fundamentals_synced_at: Option<i64>,}async fn home(State(state): State<AppState>) -> Response {
@@ -136,22 +153,32 @@ async fn home(State(state): State<AppState>) -> Response {        .await        .unwrap_or(seeded);    let (index_cards, commodity_cards) = dashboard_cards(&state).await;    let (index_section, commodity_section) = dashboard_cards(&state).await;    // One scan of the curated stocks feeds both the movers and the strongest /    // weakest panels.    let stocks = load_stocks(&state).await;    let (gainers, losers) = movers(&stocks);    let (strongest, weakest) = strength_panels(&stocks);    // Section freshness (PLAN.md Phase 22): the movers panels date off the    // freshest stock quote, the strongest / weakest panels off the most recent    // SEC fundamentals sync — the data each panel actually leans on.    let movers_asof = stocks.iter().filter_map(|s| s.last_quote_at).max();    let standings_asof = stocks.iter().filter_map(|s| s.fundamentals_synced_at).max();    let extra = minijinja::context! {        title => "Markets",        empty => false,        index_cards => index_cards,        commodity_cards => commodity_cards,        index_cards => index_section.cards,        index_asof => index_section.asof,        commodity_cards => commodity_section.cards,        commodity_asof => commodity_section.asof,        gainers => gainers,        losers => losers,        movers_asof => movers_asof,        strongest => strongest,        weakest => weakest,        standings_asof => standings_asof,        total => total,    };    render(&state, "pages/home.html", "/", extra)
@@ -162,7 +189,7 @@ async fn home(State(state): State<AppState>) -> Response {/// Outside the regular cash session each index card resolves to its index/// future (see `INDEXES`): the future trades nearly around the clock, so the/// card stays live overnight instead of freezing on the 16:00 ET close.async fn dashboard_cards(state: &AppState) -> (Vec<SparkCard>, Vec<SparkCard>) {async fn dashboard_cards(state: &AppState) -> (SparkSection, SparkSection) {    let regular = matches!(        market::session_at(chrono::Utc::now()),        market::Session::Regular
@@ -181,37 +208,44 @@ async fn dashboard_cards(state: &AppState) -> (Vec<SparkCard>, Vec<SparkCard>) {    (indexes, commodities)}/// Build a sparkline card for each ticker in `tickers`, in that order: a/// current price, the day's change, and a sparkline of the latest session's/// bars. A ticker the universe does not hold is skipped.async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> {/// Build a sparkline section for `tickers`, in that order: a card per ticker/// (current price, the day's change, a sparkline of the latest session's bars)/// plus the freshest quote across them. A ticker the universe does not hold is/// skipped.async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> SparkSection {    if tickers.is_empty() {        return Vec::new();        return SparkSection {            cards: Vec::new(),            asof: None,        };    }    // One query for the price rows. The `IN` list is built from the hardcoded    // dashboard consts — never user input — so the placeholder count is fixed    // and safe.    // and safe. The trailing `last_quote_at` feeds the section's freshness.    type SparkRow = (String, String, String, Option<f64>, Option<f64>, Option<i64>);    let placeholders = vec!["?"; tickers.len()].join(",");    let sql = format!(        "SELECT s.ticker, s.name, s.kind, \           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)) \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \           s.last_quote_at \         FROM symbols s WHERE s.ticker IN ({placeholders})"    );    let mut q = sqlx::query_as::<_, SymbolCardRow>(&sql);    let mut q = sqlx::query_as::<_, SparkRow>(&sql);    for t in tickers {        q = q.bind(*t);    }    let rows: Vec<SymbolCardRow> = q.fetch_all(&state.pool).await.unwrap_or_default();    let mut by_ticker: HashMap<String, SymbolCardRow> =    let rows: Vec<SparkRow> = q.fetch_all(&state.pool).await.unwrap_or_default();    let asof = rows.iter().filter_map(|r| r.5).max();    let mut by_ticker: HashMap<String, SparkRow> =        rows.into_iter().map(|r| (r.0.clone(), r)).collect();    let mut cards = Vec::with_capacity(tickers.len());    for &t in tickers {        // Skip a dashboard symbol the universe somehow does not hold.        let Some((ticker, name, _kind, last, prev)) = by_ticker.remove(t) else {        let Some((ticker, name, _kind, last, prev, _quote_at)) = by_ticker.remove(t) else {            continue;        };
@@ -243,7 +277,7 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> {            up: change_pct.map_or(true, |p| p >= 0.0),        });    }    cards    SparkSection { cards, asof }}/// Every curated large-cap stock, each graded into a [`StockRow`].
@@ -256,14 +290,16 @@ async fn spark_cards_for(state: &AppState, tickers: &[&str]) -> Vec<SparkCard> {async fn load_stocks(state: &AppState) -> Vec<StockRow> {    // 1. Price per curated stock: the live last price, else the latest daily    //    close; plus the prior close it is changing against.    let price_rows: Vec<(String, String, Option<f64>, Option<f64>)> = sqlx::query_as(        "SELECT s.ticker, s.name, \    let price_rows: Vec<(String, String, Option<f64>, Option<f64>, Option<i64>, Option<i64>)> =        sqlx::query_as(            "SELECT s.ticker, s.name, \           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)) \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \           s.last_quote_at, s.fundamentals_synced_at \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )        )    .fetch_all(&state.pool)    .await    .unwrap_or_default();
@@ -313,7 +349,7 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> {    // standing and is left out of the strongest / weakest ranking.    price_rows        .into_iter()        .map(|(ticker, name, last, prev)| {        .map(|(ticker, name, last, prev, last_quote_at, fundamentals_synced_at)| {            let stock_closes = closes.get(&ticker).map(Vec::as_slice).unwrap_or(&[]);            let standing = facts.get(&ticker).and_then(|f| {                let inputs = models::latest_annual_inputs(f, last)?;
@@ -326,6 +362,8 @@ async fn load_stocks(state: &AppState) -> Vec<StockRow> {                prev,                standing,                ret_12m: compute::trailing_return(stock_closes),                last_quote_at,                fundamentals_synced_at,            }        })        .collect()
modified src/routes/search.rs
@@ -17,7 +17,7 @@ use axum::{use serde::Deserialize;use crate::compute;use crate::models::{self, to_card, Card, SymbolCardRow};use crate::models::{self, to_card, Card};use crate::render::render;use crate::routes::symbols::valid_ticker;use crate::AppState;
@@ -65,12 +65,14 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState    // make the ticker/name and kind filters no-ops when their input is empty.    // Ordering puts an exact ticker hit first, then ticker prefix matches,    // then indexes before futures before ETFs before stocks, then alphabetical.    let rows: Vec<SymbolCardRow> = sqlx::query_as(    type SearchRow = (String, String, String, Option<f64>, Option<f64>, Option<i64>);    let rows: Vec<SearchRow> = sqlx::query_as(        "SELECT s.ticker, s.name, s.kind, \           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)) \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)), \           s.last_quote_at \         FROM symbols s \         WHERE (? = '' OR s.ticker LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\') \           AND (? = '' OR s.kind = ?) \
@@ -90,7 +92,13 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState    .await    .unwrap_or_default();    let mut results: Vec<Card> = rows.into_iter().map(to_card).collect();    // The freshest quote across the matched symbols backs the page's "prices    // as of ..." caption (PLAN.md Phase 22).    let asof: Option<i64> = rows.iter().filter_map(|r| r.5).max();    let mut results: Vec<Card> = rows        .into_iter()        .map(|(t, n, k, last, prev, _)| to_card((t, n, k, last, prev)))        .collect();    // A search that pinpoints exactly one symbol jumps straight to its page,    // rather than rendering a single card the user must then click (Phase 21).
@@ -125,6 +133,7 @@ async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState        kind => kind,        results => results,        result_count => result_count,        asof => asof,        show_add => show_add,        add_ticker => query,    };
modified src/templates.rs
@@ -1,3 +1,4 @@use chrono::Datelike;use minijinja::value::Value;use minijinja::{path_loader, AutoEscape, Environment, Error, Output, State};use serde::Serialize;
@@ -116,6 +117,8 @@ pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'sta    env.add_filter("compact", compact_filter);    env.add_filter("intcomma", intcomma_filter);    env.add_filter("ago", ago_filter);    env.add_filter("asof", asof_filter);    env.add_filter("shortdate", shortdate_filter);    env.add_filter("urlencode", urlencode_filter);    env
@@ -242,6 +245,48 @@ fn ago_filter(value: Value) -> Result<String, Error> {    })}/// Epoch-ms -> an absolute "as of" anchor on the US market clock: a time of day/// when the moment falls on today's date (`2:14pm`), a month and day when it is/// earlier this year (`May 21`), else a full date (`May 21, 2024`). Unlike/// `ago` it never drifts on screen, so it suits a page-load section header.fn asof_filter(value: Value) -> Result<String, Error> {    let Some(ms) = value.as_i64() else {        return Ok(DASH.to_string());    };    let Some(instant) = chrono::DateTime::from_timestamp_millis(ms) else {        return Ok(DASH.to_string());    };    // Market data is wall-clock-anchored to the exchange (see market.rs), so a    // freshness time reads naturally in New York rather than UTC.    let et = instant.with_timezone(&chrono_tz::America::New_York);    let now = chrono::Utc::now().with_timezone(&chrono_tz::America::New_York);    Ok(if et.date_naive() == now.date_naive() {        // lowercase meridiem, no leading zero on the hour: "2:14pm".        et.format("%-I:%M%P").to_string()    } else if et.year() == now.year() {        et.format("%b %-d").to_string()    } else {        et.format("%b %-d, %Y").to_string()    })}/// A `YYYY-MM-DD` date string -> `May 21`, or `May 21, 2024` when the year is/// not the current one. For dated data: a last daily close, a holdings date, a/// filing date. A string that does not parse is passed through unchanged.fn shortdate_filter(value: Value) -> Result<String, Error> {    let Some(s) = value.as_str() else {        return Ok(DASH.to_string());    };    let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") else {        return Ok(s.to_string());    };    Ok(if date.year() == chrono::Utc::now().year() {        date.format("%b %-d").to_string()    } else {        date.format("%b %-d, %Y").to_string()    })}fn urlencode_filter(value: Value) -> Result<String, Error> {    let s = value        .as_str()
modified templates/pages/home.html
@@ -22,17 +22,21 @@    <a class="page-head__link" href="/search">Browse all {{ total }} symbols</a>  </div>  <h2 class="section-title">Indexes</h2>  {# The sparkline-card sections stream live, so their freshness time rides in     a data-field the stream client refreshes as quotes land (Phase 22). The     movers / standings panels below are fixed page-load snapshots, so their     captions are plain server-rendered text. #}  <h2 class="section-title">Indexes{% if index_asof %}<span class="section-title__asof">prices as of <span data-field="spark-asof">{{ index_asof|asof }}</span></span>{% endif %}</h2>  <div class="spark-grid">    {% for c in index_cards %}{{ spark_card(c) }}{% endfor %}  </div>  <h2 class="section-title">Commodities</h2>  <h2 class="section-title">Commodities{% if commodity_asof %}<span class="section-title__asof">prices as of <span data-field="spark-asof">{{ commodity_asof|asof }}</span></span>{% endif %}</h2>  <div class="spark-grid">    {% for c in commodity_cards %}{{ spark_card(c) }}{% endfor %}  </div>  <h2 class="section-title">Today&rsquo;s movers</h2>  <h2 class="section-title">Today&rsquo;s movers{% if movers_asof %}<span class="section-title__asof">prices as of {{ movers_asof|asof }}</span>{% endif %}</h2>  <div class="movers">    <section class="movers__panel">      <h3 class="movers__title">Top gainers</h3>
@@ -52,7 +56,7 @@    </section>  </div>  <h2 class="section-title">Strongest &amp; weakest</h2>  <h2 class="section-title">Strongest &amp; weakest{% if standings_asof %}<span class="section-title__asof">fundamentals synced {{ standings_asof|ago }}</span>{% endif %}</h2>  <p class="section-note">A broader read than the day&rsquo;s move: each curated     stock&rsquo;s fundamentals graded and rolled up, weighted with its price and     growth trajectory.</p>
modified templates/pages/search.html
@@ -49,6 +49,7 @@  {% if results %}  {% from "includes/macros.html" import ticker_card %}  {% if asof %}<p class="results-asof">Prices as of {{ asof|asof }}</p>{% endif %}  <div class="ticker-grid search-grid">    {% for c in results %}{{ ticker_card(c) }}{% endfor %}  </div>
modified templates/pages/symbol.html
@@ -53,7 +53,7 @@        {{ chg_abs|signed }} ({{ chg_pct|pct }})      </span>      {% endif %}      <span class="sym-head__asof">{{ quote.state_label if quote else 'last close · ' ~ stats.date }}</span>      <span class="sym-head__asof">{% if quote %}{{ quote.state_label }}{% if symbol.last_quote_at %} &middot; <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close &middot; {{ stats.date|shortdate }}{% endif %}</span>    </div>    {% endif %}  </header>
@@ -92,7 +92,7 @@    <div id="chart" data-ticker="{{ symbol.ticker }}"></div>  </section>  <h2 class="section-title">Key stats</h2>  <h2 class="section-title">Key stats<span class="section-title__asof">as of {{ stats.date|shortdate }}</span></h2>  <section class="keystats">    {# --- the trading day: open & close placed in the day's range --- #}
@@ -202,7 +202,7 @@  {# --- fundamentals + financials: stocks only --- #}  {% if symbol.kind == 'stock' %}  <h2 class="section-title">Fundamentals</h2>  <h2 class="section-title">Fundamentals{% if symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>  {% if fundamentals and fundamentals.ratios %}  {# The rolled-up standing badge sits above the per-ratio cards (Phase 20). #}  {% if standing %}
@@ -236,7 +236,7 @@  {% endif %}  {% if fundamentals and (fundamentals.has_annual or fundamentals.has_quarterly) %}  <h2 class="section-title">Financials</h2>  <h2 class="section-title">Financials{% if symbol.fundamentals_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fundamentals_synced_at|ago }}</span>{% endif %}</h2>  <section class="panel fin">    <div class="fin__toggle" role="tablist" aria-label="Reporting period">      <button type="button" class="fin__tab is-active" data-period="annual" aria-selected="true">Annual</button>
@@ -248,7 +248,7 @@  {% endif %}  {# --- leadership: officers, board & recent changes (Phase 14) --- #}  <h2 class="section-title">Leadership</h2>  <h2 class="section-title">Leadership{% if symbol.leadership_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.leadership_synced_at|ago }}</span>{% endif %}</h2>  {% if leadership and leadership.roster %}  <section class="panel leadership">    <ul class="roster">
@@ -269,7 +269,7 @@          <a class="lead-change__link" href="{{ c.url }}" target="_blank" rel="noopener noreferrer">            <span class="lead-change__body">              <span class="lead-change__desc">Officer or director change</span>              <span class="lead-change__meta">Reported in an 8-K filed {{ c.filed_at }}</span>              <span class="lead-change__meta">Reported in an 8-K filed {{ c.filed_at|shortdate }}</span>            </span>            <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">              <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>
@@ -294,7 +294,7 @@  {# --- fund profile: ETFs only (Phase 18) --- #}  {% if symbol.kind == 'etf' %}  <h2 class="section-title">Fund profile</h2>  <h2 class="section-title">Fund profile{% if symbol.fund_synced_at %}<span class="section-title__asof">synced from SEC {{ symbol.fund_synced_at|ago }}</span>{% endif %}</h2>  {% if fund %}  <section class="panel fund">    <dl class="fund__stats">
@@ -337,7 +337,7 @@  </section>  {% if fund.holdings %}  <h2 class="section-title">Top holdings</h2>  <h2 class="section-title">Top holdings{% if fund.report_date %}<span class="section-title__asof">holdings as of {{ fund.report_date|shortdate }}</span>{% endif %}</h2>  <section class="panel holdings">    <ol class="hold-list">      {% for h in fund.holdings %}
@@ -362,7 +362,10 @@  {# --- SEC filings: stocks and ETFs --- #}  {% if filings %}  <h2 class="section-title">Recent SEC filings</h2>  {# A stock's filings sync stamps `filings_synced_at`; an ETF's ride along     with the Phase 18 fund-profile sweep, which stamps `fund_synced_at`. #}  {% set filings_synced = symbol.filings_synced_at if symbol.filings_synced_at else symbol.fund_synced_at %}  <h2 class="section-title">Recent SEC filings{% if filings_synced %}<span class="section-title__asof">synced from SEC {{ filings_synced|ago }}</span>{% endif %}</h2>  <section class="panel filings">    <ul class="filing-list">      {% for f in filings %}
@@ -371,7 +374,7 @@          <span class="filing__form num">{{ f.form }}</span>          <span class="filing__body">            <span class="filing__desc">{{ f.title }}</span>            <span class="filing__meta">Filed {{ f.filed_at }}{% if f.period_of_report %} &middot; period {{ f.period_of_report }}{% endif %}</span>            <span class="filing__meta">Filed {{ f.filed_at|shortdate }}{% if f.period_of_report %} &middot; period {{ f.period_of_report|shortdate }}{% endif %}</span>          </span>          <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>