Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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}