From 5e2c191623a0b3a14bfefb5d884ab66b83de9e3d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 07:50:10 +0000 Subject: [PATCH] Fetch the watchlist window the URL actually asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared link like /watchlist?range=1Y lit up the 1Y button and then showed 3M numbers underneath it. The table was wrong and said nothing about being wrong, which is worse than failing. Two faults, one behind the other. The state was restored too late. `restoreWatchlistPrefs()` ran inside boot(), which reaches it only after awaiting /health and /api/stats — but the session resolving fires `advis0r:auth-changed` -> openWatchlistTab() well before that. So the first overview request went out on the default 3M window, before the URL had been read at all. It is restored at module scope now: nothing may read wlView before it reflects the URL. Then the correction was dropped. When boot() caught up and asked for 1Y, `loadWatchlistOverview` returned early because a request was already in flight, so the right window was never fetched and the 3M payload rendered under the 1Y label. Requests are coalesced now rather than discarded: the fetch loop re-runs while the selected range differs from the one just fetched, and the last write wins. The same guard swallowed a range clicked while a request was outstanding, and a reprice requested mid-flight by adding a ticker — which left the new row permanently unpriced. Both are covered by the loop and the pending flag respectively, and both now have a test that fails without the fix. This is why `test/dashboard-watchlist.test.ts` has been red since #22: the deep-link case was asserted and never passed. The suite is green at 590. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WQQYeLjnzqv5n39KLu3gdK --- public/app.js | 55 +++++++++++++++++++++++++------- test/dashboard-watchlist.test.ts | 45 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 12 deletions(-) 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", () => {