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