Make the watchlist a place, and a dashboard - #22
Merged
Conversation
The saved watchlist was a fragment (/#watchlist) holding a list of links. A fragment cannot be linked to from an email, crawled, or reloaded into, and a list of tickers answers "what did I save" but not "what has any of it done" — you had to open every row to find out. Two changes. **/watchlist is a path.** Every tab is one now (/discover, /watchlist, /search, /signals, /about), pushed through the History API, with Back and Forward moving between them. The older /#watchlist form is rewritten to the path on arrival, so existing links still land. The server already answers an unknown path with the app shell, so this needed no new route. **The tab is a dashboard.** Six summary tiles, one line chart of the watchlist equal-weighted and rebased to 100 against SPY on the same base, and a sortable, filterable table with a sparkline per row. Sort, filter, risk class and window live in the URL as well as in localStorage: the address bar makes a configured table shareable, storage makes it the way you left it. Backed by GET /api/watchlist/overview, which is separate from /api/watchlist because it costs a market fetch and must be allowed to fail on its own — losing prices never costs the list of what is saved. Three rules shape it: - one upstream fetch for the whole list, cached ten minutes and shared across viewers, so 200 tickers is not 200 round trips per load; adding a ticker fetches that ticker. AlpacaClient.getBars now uses the multi-symbol endpoint when asked for more than one, which the digest gets for free; - nothing is invented: a ticker with no bars stays on the list, is reported unpriced and named in stats.missing rather than borrowing its stored report price, and a period longer than the available history is null; - the freshness is part of the answer: the payload carries the date of the last bar it used, and the equal-weight line names both the tickers it covers and the ones left out for want of history over the window. computePerformance and the sparkline series maths move to src/market/, since they were never crypto-specific; the crypto modules re-export them so their callers and tests read as they did. Chart colours were validated rather than picked: both series pass the lightness, chroma, contrast and colour-vision-separation checks against the chart surface, and are differentiated by dash pattern and direct end labels as well as hue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThreatCrush Security Scan30 finding(s) MEDIUM: 26 | LOW: 4
Snippets are redacted; ThreatCrush never prints matched credential material. |
ralyodio
added a commit
that referenced
this pull request
Aug 29, 2026
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. Claude-Session: https://claude.ai/code/session_01WQQYeLjnzqv5n39KLu3gdK Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
/#watchlistbecomes/watchlist, and the tab stops being a list of links.The path
Every tab is a path now —
/discover,/watchlist,/search,/signals,/about— pushed through the History API, with Back and Forward moving betweenthem. A fragment is invisible to the server: it cannot be linked to from an
email, crawled, or reloaded into. The older
/#watchlistform is rewritten tothe path on arrival, so existing links still land. No new server route was
needed — an unknown path already serves the app shell.
The dashboard
Sort, filter, risk class and window live in the URL and in
localStorage:the address bar makes a configured table shareable
(
/watchlist?sort=range&dir=desc&q=ai&range=1Y), storage makes it the way youleft it.
GET /api/watchlist/overview?range=1M|3M|6M|1YSeparate from
/api/watchlistbecause it costs a market fetch and has to beallowed to fail on its own — losing prices never costs the list of what is
saved. Three rules shape it:
across viewers, so a 200-ticker watchlist is not 200 round trips per load;
adding a ticker fetches that ticker.
AlpacaClient.getBarsnow uses themulti-symbol endpoint when asked for more than one — the digest gets that for
free.
unpriced and named in
stats.missingrather than borrowing its stored reportprice. A period longer than the available history is
null.last bar it used, and the equal-weight line names both the tickers it covers
and the ones left out for want of history over the window.
Also
computePerformanceand the sparkline series maths move tosrc/market/—they were never crypto-specific. The crypto modules re-export them, so their
callers and tests read exactly as before.
contrast and colour-vision-separation checks against the chart surface, and
are differentiated by dash pattern and direct end labels as well as by hue.
Testing
test/watchlist-overview.test.ts(23) — pricing, null periods, unpriced rows,the equal-weight index and what it excludes, and the upstream call count.
test/dashboard-watchlist.test.ts(28) — the realindex.html+app.jsin aDOM: routing and the fragment upgrade, tiles, both chart series, sorting,
filtering, the range control, and the state that survives a reload.
tsc --noEmitclean.crosshair drawing itself at the origin before any hover got caught.
🤖 Generated with Claude Code