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// On-demand data refresh for the symbol page (Phase B).
2//
3// The server fetches nothing on a timer any more: a symbol's data is pulled
4// here, when its page is open. On load this auto-runs a refresh (the live price
5// always; slow SEC / metadata only when their stored copy is stale); the
6// Refresh button re-pulls everything. Progress streams over SSE from
7// `/api/symbols/{ticker}/refresh` and drives the bar.
8//
9// When a "deep" (server-rendered) section was refreshed, the server's `done`
10// event asks us to reload so the new data and its "synced … ago" age render;
11// when only the live price moved, the stream client has already patched it in
12// place, so no reload is needed. A one-shot sessionStorage flag set just before
13// our own reload keeps that reload from re-triggering the auto-refresh.
14
15export function initRefresh() {
16 const root = document.querySelector("[data-refresh-root]");
17 if (!root) return;
18
19 const ticker = root.dataset.ticker;
20 const btn = root.querySelector("[data-refresh]");
21 const bar = root.querySelector("[data-refresh-bar]");
22 const fill = root.querySelector("[data-refresh-fill]");
23 const status = root.querySelector("[data-refresh-status]");
24 const skipKey = "fin:skipnext:" + ticker;
25
26 let running = false;
27
28 const setStatus = (t) => {
29 if (status) status.textContent = t;
30 };
31 const setFill = (pct) => {
32 if (fill) fill.style.width = Math.max(0, Math.min(100, pct)) + "%";
33 };
34
35 function run(force) {
36 if (running) return;
37 running = true;
38 root.classList.add("is-running");
39 if (bar) bar.hidden = false;
40 setFill(2);
41 if (btn) btn.disabled = true;
42 setStatus("Updating…");
43
44 const url = `/api/symbols/${encodeURIComponent(ticker)}/refresh?force=${force ? 1 : 0}`;
45 const es = new EventSource(url);
46 let done = false;
47
48 es.addEventListener("step", (e) => {
49 let d;
50 try {
51 d = JSON.parse(e.data);
52 } catch {
53 return;
54 }
55 // A step counts as half-done while running, full when it reports back, so
56 // the bar advances smoothly across the step list.
57 const progressed = d.i - (d.state === "running" ? 0.5 : 0);
58 setFill((progressed / Math.max(1, d.n)) * 100);
59 setStatus("Updating: " + d.label);
60 });
61
62 es.addEventListener("done", (e) => {
63 done = true;
64 es.close();
65 running = false;
66 setFill(100);
67 let reload = false;
68 try {
69 reload = !!JSON.parse(e.data).reload;
70 } catch {}
71 if (reload) {
72 // Show the new server-rendered data + ages. Mark this so the load it
73 // triggers does not auto-refresh again.
74 sessionStorage.setItem(skipKey, "1");
75 location.reload();
76 return;
77 }
78 root.classList.remove("is-running");
79 if (btn) btn.disabled = false;
80 setStatus("Up to date");
81 window.setTimeout(() => {
82 if (bar) bar.hidden = true;
83 }, 700);
84 });
85
86 es.onerror = () => {
87 if (done) return; // normal end-of-stream after `done`
88 es.close();
89 running = false;
90 root.classList.remove("is-running");
91 if (btn) btn.disabled = false;
92 if (bar) bar.hidden = true;
93 setStatus("Couldn’t refresh — showing stored data");
94 };
95 }
96
97 if (btn) btn.addEventListener("click", () => run(true));
98
99 // Auto-refresh on load, unless this very load was triggered by our own reload
100 // after a refresh (the one-shot skip flag).
101 if (sessionStorage.getItem(skipKey)) {
102 sessionStorage.removeItem(skipKey);
103 setStatus("Updated just now");
104 } else {
105 run(false);
106 }
107}