Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = "";

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
45 changes: 45 additions & 0 deletions test/dashboard-watchlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | null = null;

async function loadPage(where = "/watchlist") {
pageErrors = [];
watchlistRequests = [];
Expand All @@ -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),
Expand Down Expand Up @@ -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<void>((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<void>((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", () => {
Expand Down
Loading