repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

16.9 KB · 456 lines · JavaScript Raw History
  1// The dashboard's market overview + watchlist (redesigned to a sparkline grid).
  2//
  3// Goal: a glanceable, Yahoo-Finance-style read of "how are the markets doing"
  4// at one glance. Each instrument (the major indexes + gold, crude, bitcoin) and
  5// each watchlist symbol is a small card: its name, its live value, the day's %
  6// change, and a tiny non-interactive SVG sparkline of the day's path coloured
  7// green/red vs the previous close. No interactive charts, no session badges, no
  8// per-card chrome — the calm overview Isaac actually trusts. Everything comes
  9// from /api/dashboard, re-fetched ~every 20s and on tab focus. The same card
 10// shape is used for the fixed overview (built here) and the watchlist
 11// (server-rendered shells we draw the sparkline into).
 12
 13// Semantic day-direction inks (Paper Ledger up/down) + soft area fills. The
 14// sparkline is coloured by the day's direction vs the previous close, the
 15// Google-Finance / Yahoo read.
 16const UP = "#2f7d4f";
 17const DOWN = "#b23b32";
 18const UP_FILL = "rgba(47, 125, 79, 0.13)";
 19const DOWN_FILL = "rgba(178, 59, 50, 0.13)";
 20const REF = "rgba(33, 31, 26, 0.28)"; // dashed previous-close baseline
 21const DASH = "·";
 22
 23// Arrow + sign + colour together: a colourblind-safe change indicator (WCAG
 24// 1.4.1 wants a second channel beyond colour). "▲ +1.96%" reads in greyscale.
 25function fmtPctArrow(n) {
 26  if (n == null || Number.isNaN(n)) return DASH;
 27  const a = n > 0 ? "▲ " : n < 0 ? "▼ " : "";
 28  return a + fmtPct(n);
 29}
 30
 31const SESSION_LABELS = {
 32  regular: "Regular session",
 33  pre: "Pre-market",
 34  post: "After hours",
 35  closed: "Market closed",
 36};
 37
 38// ── formatters ─────────────────────────────────────────────────────────────
 39function fmtValue(n, unit) {
 40  if (n == null || Number.isNaN(n)) return DASH;
 41  const s = n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
 42  return unit === "$" ? "$" + s : s;
 43}
 44function fmtPct(n) {
 45  if (n == null || Number.isNaN(n)) return DASH;
 46  return (
 47    n.toLocaleString("en-US", {
 48      minimumFractionDigits: 2,
 49      maximumFractionDigits: 2,
 50      signDisplay: "exceptZero",
 51    }) + "%"
 52  );
 53}
 54function fmtCompact(n) {
 55  if (n == null || Number.isNaN(n)) return DASH;
 56  const abs = Math.abs(n);
 57  if (abs >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
 58  if (abs >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
 59  if (abs >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
 60  return String(n);
 61}
 62const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
 63
 64function fmtClock(ms) {
 65  if (!ms) return null;
 66  return new Date(ms)
 67    .toLocaleTimeString("en-US", { timeZone: "America/New_York", hour: "numeric", minute: "2-digit" })
 68    .replace(/\s/g, "")
 69    .toLowerCase();
 70}
 71function fmtAgo(ms) {
 72  if (!ms) return "";
 73  const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
 74  if (s < 5) return "just now";
 75  if (s < 60) return `${s}s ago`;
 76  if (s < 3600) return `${Math.round(s / 60)}m ago`;
 77  return `${Math.round(s / 3600)}h ago`;
 78}
 79
 80// Sector-tile background: a green/red wash whose strength scales with the move,
 81// clamped at ±3% (the de-facto heatmap scale Yahoo/Finviz use). Neutral when
 82// unknown.
 83function sectorColor(pct) {
 84  if (pct == null || Number.isNaN(pct)) return "var(--ink-wash, rgba(33, 31, 26, 0.05))";
 85  const t = Math.max(-1, Math.min(1, pct / 3));
 86  const a = (0.1 + 0.62 * Math.abs(t)).toFixed(3);
 87  return t >= 0 ? `rgba(47, 125, 79, ${a})` : `rgba(178, 59, 50, ${a})`;
 88}
 89
 90// ── sparkline ────────────────────────────────────────────────────────────────
 91// Build a small, non-interactive SVG sparkline of one instrument's day: the
 92// intraday line over the day's grid, coloured by direction vs the previous
 93// close (`base`), with a faint dashed baseline at that close and a soft area
 94// fill. preserveAspectRatio="none" stretches the fixed viewBox to the card; the
 95// line keeps a crisp 1.5px stroke via vector-effect. Returns "" when there are
 96// too few points to draw (the card then shows just its value + %).
 97function sparkSvg(s) {
 98  const pts = s.points || [];
 99  if (pts.length < 2) return "";
100  const W = 100;
101  const H = 34;
102  const PAD = 2;
103  // Value range, widened to include the baseline so the dashed line always sits
104  // inside the frame.
105  let lo = Infinity;
106  let hi = -Infinity;
107  for (const p of pts) {
108    if (p.v < lo) lo = p.v;
109    if (p.v > hi) hi = p.v;
110  }
111  if (s.base != null) {
112    lo = Math.min(lo, s.base);
113    hi = Math.max(hi, s.base);
114  }
115  const span = hi - lo || 1;
116  // x by the bar's position in the day window so a half-day plots from the left
117  // rather than stretching across the full width.
118  const dt = s.end_t > s.start_t ? s.end_t - s.start_t : 1;
119  const x = (t) => (PAD + ((t - s.start_t) / dt) * (W - 2 * PAD)).toFixed(2);
120  const y = (v) => (PAD + (1 - (v - lo) / span) * (H - 2 * PAD)).toFixed(2);
121  const line = pts.map((p, i) => `${i ? "L" : "M"}${x(p.t)} ${y(p.v)}`).join(" ");
122  const first = x(pts[0].t);
123  const last = x(pts[pts.length - 1].t);
124  const area = `${line} L${last} ${H - PAD} L${first} ${H - PAD} Z`;
125  const color = s.up ? UP : DOWN;
126  const fill = s.up ? UP_FILL : DOWN_FILL;
127  const baseY = s.base != null ? y(s.base) : null;
128  const baseline =
129    baseY != null
130      ? `<line x1="${PAD}" y1="${baseY}" x2="${W - PAD}" y2="${baseY}" stroke="${REF}" stroke-width="0.5" stroke-dasharray="2 2" vector-effect="non-scaling-stroke"/>`
131      : "";
132  return (
133    `<svg class="spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">` +
134    `<path class="spark__area" d="${area}" fill="${fill}"/>` +
135    baseline +
136    `<path class="spark__line" d="${line}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>` +
137    `</svg>`
138  );
139}
140
141function setText(role, text) {
142  const el = document.querySelector(`[data-role="${role}"]`);
143  if (el && text != null) el.textContent = text;
144}
145function setTone(role, tone, prefix) {
146  const el = document.querySelector(`[data-role="${role}"]`);
147  if (!el || !tone) return;
148  [...el.classList].forEach((c) => {
149    if (c.startsWith(prefix)) el.classList.remove(c);
150  });
151  el.classList.add(prefix + tone);
152}
153
154// ── session countdown ────────────────────────────────────────────────────────
155// "Market closes in 2h 14m" in the banner: the next boundary on the fixed ET
156// schedule (no holiday calendar, by design — mirrors market.rs).
157const WEEKDAYS = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
158const PRE_OPEN = 4 * 60;
159const REG_OPEN = 9 * 60 + 30;
160const REG_CLOSE = 16 * 60;
161const POST_CLOSE = 20 * 60;
162
163function etNowParts() {
164  const parts = new Intl.DateTimeFormat("en-US", {
165    timeZone: "America/New_York",
166    weekday: "short",
167    hour: "2-digit",
168    minute: "2-digit",
169    hour12: false,
170  }).formatToParts(new Date());
171  let wd = 0;
172  let h = 0;
173  let m = 0;
174  for (const p of parts) {
175    if (p.type === "weekday") wd = WEEKDAYS[p.value] ?? 0;
176    else if (p.type === "hour") h = parseInt(p.value, 10) % 24;
177    else if (p.type === "minute") m = parseInt(p.value, 10);
178  }
179  return { wd, minutes: h * 60 + m };
180}
181
182function fmtSpan(mins) {
183  const d = Math.floor(mins / 1440);
184  const h = Math.floor((mins % 1440) / 60);
185  const m = mins % 60;
186  if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
187  if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
188  return `${Math.max(1, m)}m`;
189}
190
191function nextSessionRead() {
192  const { wd, minutes } = etNowParts();
193  if (wd >= 1 && wd <= 5) {
194    const next = [
195      [PRE_OPEN, "Pre-market opens"],
196      [REG_OPEN, "Market opens"],
197      [REG_CLOSE, "Market closes"],
198      [POST_CLOSE, "After hours ends"],
199    ].find(([at]) => minutes < at);
200    if (next) return `${next[1]} in ${fmtSpan(next[0] - minutes)}`;
201  }
202  const days = wd === 5 ? 3 : wd === 6 ? 2 : 1;
203  const span = (days - 1) * 1440 + (1440 - minutes) + PRE_OPEN;
204  return `Pre-market opens in ${fmtSpan(span)}`;
205}
206
207// Last shown value per ticker, so a card flashes when its number actually moves.
208const lastShown = new Map();
209// The freshest reads quote time (epoch-ms), so the header "updated Ns ago" ticks.
210let readsAsofMs = null;
211
212const escapeHtml = (s) =>
213  String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
214
215// Update a card's value + % pill and its sparkline, flashing when the value moves.
216function paintCard(root, s) {
217  const prev = lastShown.get(s.ticker);
218
219  const v = root.querySelector(".ov-card__value");
220  if (v) v.textContent = fmtValue(s.last, s.unit);
221  const c = root.querySelector(".ov-card__chg");
222  if (c) {
223    c.textContent = fmtPctArrow(s.change_pct);
224    c.classList.remove("is-up", "is-down", "is-flat");
225    c.classList.add(s.change_pct == null ? "is-flat" : s.change_pct >= 0 ? "is-up" : "is-down");
226  }
227  const chart = root.querySelector(".ov-card__chart");
228  if (chart) chart.innerHTML = sparkSvg(s);
229
230  if (prev != null && s.last != null && prev !== s.last) {
231    root.classList.remove("ov-flash-up", "ov-flash-down");
232    void root.offsetWidth; // reflow so the animation re-triggers
233    root.classList.add(s.last >= prev ? "ov-flash-up" : "ov-flash-down");
234  }
235  if (s.last != null) lastShown.set(s.ticker, s.last);
236}
237
238export function initHero() {
239  const overviewGrid = document.querySelector('[data-role="overview-grid"]');
240  const overviewCards = new Map(); // ticker -> root
241
242  function makeOverviewCard(s) {
243    const root = document.createElement("div");
244    root.className = "ov-card";
245    root.dataset.ticker = s.ticker;
246    root.innerHTML =
247      `<div class="ov-card__name">${escapeHtml(s.name)}</div>` +
248      `<div class="ov-card__nums">` +
249      `<span class="ov-card__value num"></span>` +
250      `<span class="ov-card__chg num"></span>` +
251      `</div>` +
252      `<div class="ov-card__chart"></div>`;
253    overviewGrid.appendChild(root);
254    return root;
255  }
256
257  function drawOverview(list) {
258    const empty = document.querySelector('[data-role="hero-empty"]');
259    if (!overviewGrid) return;
260    if (!list || !list.length) {
261      if (empty) empty.hidden = false;
262      return;
263    }
264    if (empty) empty.hidden = true;
265    const seen = new Set();
266    for (const s of list) {
267      seen.add(s.ticker);
268      let root = overviewCards.get(s.ticker);
269      if (!root) {
270        root = makeOverviewCard(s);
271        overviewCards.set(s.ticker, root);
272      }
273      paintCard(root, s);
274    }
275    for (const [t, root] of overviewCards) {
276      if (!seen.has(t)) {
277        root.remove();
278        overviewCards.delete(t);
279      }
280    }
281  }
282
283  // Watchlist cards are server-rendered shells; draw the sparkline into each and
284  // refresh its value/%. A card with no series (no intraday bars) keeps its
285  // server-rendered figures and simply shows no line.
286  function drawWatchlist(list) {
287    const byTicker = new Map((list || []).map((s) => [s.ticker, s]));
288    document.querySelectorAll(".watch-grid .ov-card").forEach((root) => {
289      const s = byTicker.get(root.dataset.ticker);
290      if (s) paintCard(root, s);
291    });
292  }
293
294  // The sector heatmap: 11 tiles, each a link to the ETF, coloured by its move.
295  function drawSectors(list) {
296    const grid = document.querySelector('[data-role="sectors-grid"]');
297    if (!grid || !list) return;
298    grid.removeAttribute("aria-busy");
299    grid.innerHTML = list
300      .map((s) => {
301        const pct = s.change_pct;
302        const cls = pct == null ? "is-flat" : pct >= 0 ? "is-up" : "is-down";
303        return (
304          `<a class="sector-tile ${cls}" href="/s/${encodeURIComponent(s.ticker)}" ` +
305          `style="background:${sectorColor(pct)}" title="${escapeHtml(s.ticker)}">` +
306          `<span class="sector-tile__name">${escapeHtml(s.name)}</span>` +
307          `<span class="sector-tile__pct num">${fmtPct(pct)}</span>` +
308          `</a>`
309        );
310      })
311      .join("");
312  }
313
314  // ── market movers ──────────────────────────────────────────────────────────
315  function moverRow(m) {
316    const pct = m.change_pct;
317    const cls = pct == null ? "is-flat" : pct >= 0 ? "is-up" : "is-down";
318    return (
319      `<a class="mv-row" href="/s/${encodeURIComponent(m.symbol)}" title="${escapeHtml(m.name)}">` +
320      `<span class="mv-row__id">` +
321      `<span class="mv-row__sym">${escapeHtml(m.symbol)}</span>` +
322      `<span class="mv-row__name">${escapeHtml(m.name)}</span></span>` +
323      `<span class="mv-row__nums">` +
324      `<span class="mv-row__pct num ${cls}">${fmtPctArrow(pct)}</span>` +
325      `<span class="mv-row__sub num">${fmtValue(m.price, "$")}</span>` +
326      `</span></a>`
327    );
328  }
329
330  async function loadMovers() {
331    let data;
332    try {
333      const res = await fetch("/api/movers", { headers: { Accept: "application/json" } });
334      if (!res.ok) return;
335      data = await res.json();
336    } catch {
337      return;
338    }
339    const root = document.querySelector('[data-role="movers"]');
340    if (!root) return;
341    root.removeAttribute("aria-busy");
342    const fill = (key, rows) => {
343      const list = root.querySelector(`[data-mv="${key}"] .movers__list`);
344      if (list) {
345        list.innerHTML = (rows || []).map(moverRow).join("") || `<p class="movers__empty">${DASH}</p>`;
346      }
347    };
348    fill("gainers", data.gainers);
349    fill("losers", data.losers);
350    fill("actives", data.actives);
351    const asof = document.querySelector('[data-role="movers-asof"]');
352    if (asof) asof.textContent = data.asof ? "as of " + fmtClock(data.asof) : "";
353  }
354
355  function patchReads(r) {
356    if (!r) return;
357    setText("drawdown-pct", r.drawdown_pct != null ? fmtPct(r.drawdown_pct) : DASH);
358    setTone("drawdown-pct", r.drawdown_tone || "steady", "read__tone--");
359    if (r.drawdown_label) setText("drawdown-label", r.drawdown_label);
360    setText("credit-pct", r.credit_pct != null ? fmtPct(r.credit_pct) : DASH);
361    setTone("credit-pct", r.credit_tone || "steady", "read__tone--");
362    if (r.credit_label) setText("credit-label", r.credit_label);
363    setText("vix-level", r.vix_level != null ? r.vix_level.toFixed(2) : DASH);
364    if (r.vix_tone) {
365      setText("vix-tone", cap(r.vix_tone));
366      setTone("vix-tone", r.vix_tone, "read__tone--");
367    }
368    setText("volume", fmtCompact(r.volume));
369    setText("volume-label", r.volume_label ? r.volume_label + " vs avg" : DASH);
370    if (r.sma_read) {
371      setText("sma-read", r.sma_read);
372      setTone("sma-read", r.sma_tone, "read__tone--");
373    }
374    readsAsofMs = r.asof || null;
375    paintAsof();
376  }
377
378  // The header "Prices as of 3:42pm · updated 12s ago" caption, ticked in place.
379  function paintAsof() {
380    if (!readsAsofMs) return;
381    const clock = fmtClock(readsAsofMs);
382    if (clock) setText("reads-asof", `Prices as of ${clock} ${DASH} updated ${fmtAgo(readsAsofMs)}`);
383  }
384
385  function setRefreshing(on) {
386    const el = document.querySelector('[data-role="refresh-state"]');
387    if (el) el.hidden = !on;
388  }
389
390  function patchCountdown() {
391    setText("session-note", nextSessionRead() + " " + DASH + " all times ET");
392  }
393
394  function patchSession(session) {
395    const banner = document.querySelector('[data-role="session-banner"]');
396    if (banner && session) banner.dataset.session = session;
397    setText("session-label", SESSION_LABELS[session] || "Market closed");
398    patchCountdown();
399  }
400
401  async function refresh() {
402    let data;
403    try {
404      const res = await fetch("/api/dashboard", { headers: { Accept: "application/json" } });
405      if (!res.ok) return;
406      data = await res.json();
407    } catch {
408      return;
409    }
410    drawOverview(data.series);
411    drawSectors(data.sectors);
412    drawWatchlist(data.watchlist);
413    patchReads(data.reads);
414    patchSession(data.session);
415  }
416
417  // Land → show stored figures at once, then kick a guarded refresh with a
418  // visible "Refreshing…" state so the wait for fresh quotes is never a mystery.
419  patchCountdown();
420  refresh();
421  setRefreshing(true);
422  fetch("/api/dashboard/refresh")
423    .catch(() => {})
424    .finally(() => {
425      setRefreshing(false);
426      refresh();
427    });
428
429  loadMovers();
430
431  const timer = setInterval(refresh, 20000);
432  const clockTimer = setInterval(patchCountdown, 30000);
433  const asofTimer = setInterval(paintAsof, 5000);
434  const moversTimer = setInterval(loadMovers, 240000);
435  document.addEventListener("visibilitychange", () => {
436    if (!document.hidden) {
437      patchCountdown();
438      paintAsof();
439      loadMovers();
440      setRefreshing(true);
441      fetch("/api/dashboard/refresh")
442        .catch(() => {})
443        .finally(() => {
444          setRefreshing(false);
445          refresh();
446        });
447    }
448  });
449  window.addEventListener("pagehide", () => {
450    clearInterval(timer);
451    clearInterval(clockTimer);
452    clearInterval(asofTimer);
453    clearInterval(moversTimer);
454  });
455}