@@ -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’s movers</h2> <h2 class="section-title">Today’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 & weakest</h2> <h2 class="section-title">Strongest & 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’s move: each curated stock’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 %} · <span data-field="quoted">quoted {{ symbol.last_quote_at|ago }}</span>{% endif %}{% else %}Last close · {{ 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 %} · period {{ f.period_of_report }}{% endif %}</span> <span class="filing__meta">Filed {{ f.filed_at|shortdate }}{% if f.period_of_report %} · 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"/>