Skip to content

Make the watchlist a place, and a dashboard - #22

Merged
ralyodio merged 1 commit into
mainfrom
feat/watchlist-dashboard
Aug 17, 2026
Merged

Make the watchlist a place, and a dashboard#22
ralyodio merged 1 commit into
mainfrom
feat/watchlist-dashboard

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

/#watchlist becomes /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 between
them. A fragment is invisible to the server: it cannot be linked to from an
email, crawled, or reloaded into. The older /#watchlist form is rewritten to
the path on arrival, so existing links still land. No new server route was
needed — an unknown path already serves the app shell.

The dashboard

Layer What it shows
Six tiles Count and how many are priced · last session's average with the up/down split · equal-weight change over the window vs SPY · best and worst mover · average score
One chart The watchlist, equal-weight and rebased to 100, against SPY on the same base. Hovering reports both lines at that session
A table Ticker, company, note, price, 1D/1W/1M/window, a sparkline, score, distance from the 52-week high, date saved — sortable on every column, filterable by text and risk class

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 you
left it.

GET /api/watchlist/overview?range=1M|3M|6M|1Y

Separate from /api/watchlist because it costs a market fetch and has to 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 a 200-ticker watchlist 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 — the digest gets that 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. 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.

Also

  • computePerformance and the sparkline series maths move to src/market/
    they were never crypto-specific. The crypto modules re-export them, so their
    callers and tests read exactly as before.
  • Chart colours were validated, not picked: both series pass 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 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 real index.html + app.js in a
    DOM: routing and the fragment upgrade, tiles, both chart series, sorting,
    filtering, the range control, and the state that survives a reload.
  • Full suite 541 pass / 0 fail, tsc --noEmit clean.
  • Rendered in Chromium at 1280 and 420 wide and looked at, which is how the
    crosshair drawing itself at the origin before any hover got caught.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

30 finding(s)

MEDIUM: 26 | LOW: 4

Severity Rule Location
MEDIUM js-unescaped-html-sink public/app.js:72
MEDIUM js-unescaped-html-sink public/app.js:128
MEDIUM js-unescaped-html-sink public/app.js:275
MEDIUM js-unescaped-html-sink public/app.js:390
MEDIUM js-unescaped-html-sink public/app.js:417
MEDIUM js-unescaped-html-sink public/app.js:833
MEDIUM js-unescaped-html-sink public/app.js:834
MEDIUM js-unescaped-html-sink public/app.js:881
MEDIUM js-unescaped-html-sink public/app.js:1390
MEDIUM js-unescaped-html-sink public/app.js:1575
MEDIUM js-unescaped-html-sink public/app.js:1587
MEDIUM js-unescaped-html-sink public/app.js:1629
MEDIUM js-unescaped-html-sink public/app.js:1689
MEDIUM js-unescaped-html-sink public/app.js:1825
MEDIUM js-unescaped-html-sink public/app.js:1841
MEDIUM js-unescaped-html-sink public/app.js:1992
MEDIUM js-unescaped-html-sink public/app.js:2009
MEDIUM js-unescaped-html-sink public/app.js:2214
MEDIUM js-unescaped-html-sink public/auth.js:41
MEDIUM js-unescaped-html-sink public/auth.js:59
MEDIUM js-unescaped-html-sink public/auth.js:106
MEDIUM js-unescaped-html-sink public/auth.js:258
MEDIUM sql-template-interpolation src/cli.ts:1033
MEDIUM sql-template-interpolation src/server.ts:452
MEDIUM redos-nested-quantifier src/signals/boilerplate.ts:44
MEDIUM sql-template-interpolation src/symbols/routes.ts:75
LOW secret-generic-api-key test/auth.test.ts:190
LOW js-dynamic-code-execution test/dashboard-crypto.test.ts:216
LOW js-dynamic-code-execution test/dashboard-watchlist.test.ts:230
LOW insecure-temp-file test/news.test.ts:420

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 07f85cb into main Aug 17, 2026
5 checks passed
@ralyodio
ralyodio deleted the feat/watchlist-dashboard branch August 17, 2026 11:39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant