diff --git a/public/app.js b/public/app.js index bb1c2e9..87fbe63 100644 --- a/public/app.js +++ b/public/app.js @@ -701,7 +701,6 @@ async function boot() { // Restore the tab from the URL. `replace` rather than push, so arriving at // /#watchlist rewrites the address bar to /watchlist without leaving a // fragment entry behind for Back to land on. - restoreWatchlistPrefs(); showView(viewFromLocation() ?? "discover", { replace: true }); runWatchlist(); // Deep link from a digest email: /?ticker=NVDA opens that stock's detail. @@ -1438,6 +1437,8 @@ let wlView = { ...WL_DEFAULTS }; let wlItems = []; // saved rows: {ticker, note, createdAt} let wlOverview = null; // the priced payload, or null before it lands let wlLoadingOverview = false; +/** A load was asked for while one was in flight; honour it when that finishes. */ +let wlOverviewStale = false; /** A one-off message (an import result, an error) shown ahead of the prices. */ let wlNotice = ""; @@ -1459,6 +1460,13 @@ function restoreWatchlistPrefs() { if (wlView.dir !== "asc" && wlView.dir !== "desc") wlView.dir = WL_DEFAULTS.dir; } +// Restored here, at module scope, rather than inside boot(). boot() reaches it +// only after awaiting /health and /api/stats, and the session resolving fires +// `advis0r:auth-changed` -> openWatchlistTab() well before that — so a link +// carrying `?range=1Y` used to issue its first fetch on the default window. +// Nothing may read wlView before it reflects the URL. +restoreWatchlistPrefs(); + function persistWatchlistPrefs() { try { localStorage.setItem(WL_STORE_KEY, JSON.stringify(wlView)); } catch { /* ignore */ } // Only rewrite the URL while the watchlist is the visible tab, so switching @@ -1965,20 +1973,43 @@ async function loadMyWatchlist() { * market request, it can fail on its own, and losing it must never cost the * list of what is saved. */ +/** + * Fetch the priced overview for the window currently selected. + * + * Requests are **coalesced, not dropped**. The guard here used to return early + * while a request was in flight, which lost whichever range was chosen during + * that window: the table went on showing the previous window's numbers under + * the new window's label. Two ways in, both real — + * + * - opening a shared link like `/watchlist?range=1Y`, because the session + * resolving fires a default-range load before the URL has been read; and + * - clicking 1Y while the 3M request is still outstanding. + * + * So the loop re-runs whenever the selected range no longer matches the one + * just fetched, and the last write wins. + */ async function loadWatchlistOverview() { - if (wlLoadingOverview) return; + if (wlLoadingOverview) { wlOverviewStale = true; return; } wlLoadingOverview = true; try { - const res = await fetch(`/api/watchlist/overview?range=${encodeURIComponent(wlView.range)}`, { - credentials: "same-origin", - }); - if (!res.ok) throw new Error(String(res.status)); - const data = await res.json(); - if (!data || !Array.isArray(data.items)) return; - wlOverview = data; - renderWatchlist(); - } catch { - // Keep whatever is already on screen; the summary line says what is known. + let range; + do { + wlOverviewStale = false; + range = wlView.range; + try { + const res = await fetch(`/api/watchlist/overview?range=${encodeURIComponent(range)}`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json(); + if (data && Array.isArray(data.items)) { + wlOverview = data; + renderWatchlist(); + } + } catch { + // Keep whatever is already on screen; the summary line says what is known. + } + } while (wlOverviewStale || wlView.range !== range); } finally { wlLoadingOverview = false; } diff --git a/test/dashboard-watchlist.test.ts b/test/dashboard-watchlist.test.ts index 9cbbdd2..c174296 100644 --- a/test/dashboard-watchlist.test.ts +++ b/test/dashboard-watchlist.test.ts @@ -199,6 +199,9 @@ const rows = () => $$(".wl-table tbody tr").map((r: any) => r.dataset.ticker); const cell = (ticker: string, nth: number) => $$(`.wl-table tbody tr`).find((r: any) => r.dataset.ticker === ticker)?.children[nth]?.textContent?.trim(); +/** When set, overview responses block on this until the test resolves it. */ +let overviewGate: Promise | null = null; + async function loadPage(where = "/watchlist") { pageErrors = []; watchlistRequests = []; @@ -216,10 +219,13 @@ async function loadPage(where = "/watchlist") { win.LightweightCharts = null; win.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }; win.alert = () => {}; + overviewGate = null; win.fetch = async (input: any, init: any = {}) => { const u = String(input?.url ?? input); const method = String(init.method ?? "GET"); if (u.includes("/api/watchlist")) watchlistRequests.push({ method, url: u, body: init.body }); + // Lets a test hold one overview request open and act while it is in flight. + if (u.includes("/api/watchlist/overview") && overviewGate) await overviewGate; return { ok: true, status: 200, json: async () => respond(u), @@ -413,6 +419,45 @@ describe("the range control", () => { expect(cell("NVDA", 6)).toBe("+140.0%"); expect($$(".wl-sort").find((b: any) => b.dataset.sort === "range").textContent).toContain("1Y"); }); + + test("a range chosen while a request is in flight is honoured, not dropped", async () => { + // The in-flight guard used to `return` early, so this second choice was + // discarded and the table kept the old window's numbers under the new + // window's label. Same defect a shared `?range=` link hit on load. + let release = () => {}; + overviewGate = new Promise((r) => { release = r; }); + + click($$("#wl-ranges button").find((b: any) => b.dataset.range === "1Y")); + await sleep(20); // 1Y is now in flight and blocked on the gate + click($$("#wl-ranges button").find((b: any) => b.dataset.range === "6M")); + + overviewGate = null; + release(); + await sleep(120); + + expect(watchlistRequests.some((r) => r.url.includes("range=6M"))).toBe(true); + expect($$("#wl-ranges button").find((b: any) => b.classList.contains("on")).dataset.range).toBe("6M"); + }); + + test("a refresh asked for during a request is re-run, even at the same range", async () => { + // Not every refresh is a range change: adding a ticker reprices the table + // too. Dropping one of those leaves the new row permanently unpriced, so + // the in-flight request is repeated rather than the request discarded. + const overviews = () => watchlistRequests.filter((r) => r.url.includes("/overview")).length; + let release = () => {}; + overviewGate = new Promise((r) => { release = r; }); + + win.dispatchEvent(new win.Event("advis0r:auth-changed")); + await sleep(20); + const during = overviews(); + win.dispatchEvent(new win.Event("advis0r:auth-changed")); // arrives mid-flight + + overviewGate = null; + release(); + await sleep(120); + + expect(overviews()).toBeGreaterThan(during); + }); }); describe("state that survives a reload", () => {