Skip to main content

Watchlist

Tickers a member follows without owning them, shown in the dashboard Watchlist widget and readable by Silvia.

Data

public.watchlist_items (migration 20261002_01_watchlist_items.sql): one row per (clerk_id, symbol), plus a position for the member’s order.
  • symbol uses the same identity as manual_public_holdings.symbol and is an FK to the shared tracked_tickers_latest catalog. Anything a member can add as a manual holding (stocks, ETFs, mutual funds, bonds) they can watch. Crypto is a separate catalog (tracked_tokens) and is not supported yet.
  • Price, day change, name and logo are read from tracked_tickers_latest at request time. The watchlist stores no prices, so it refreshes on the same crons as holdings (stocks every 15 minutes in US market hours).
  • Only the stock quote feed writes a day change (change_1d, change_amount, prev_close). The mutual-fund and bond crons write the price alone, so fund and bond rows show a price with no day move.
  • A bond’s current_price is its quoted price as a percent of par, so the row shows “98.75% of par”, never a currency amount.
  • A watched ticker is not a holding. It never counts toward net worth, portfolio value or any snapshot.

Rollout

Gated by the PostHog flag dashboard-watchlist-v01 (lib/watchlist/flag.ts). Release conditions live in the PostHog dashboard: the Internal Users cohort first, then a percentage rollout, then GA. When the flag is off for a member:
  • the card is not rendered, even if their saved layout lists it, and it is not offered under Add Widget (useWatchlistEnabled);
  • every /api/watchlist route answers 404 (rejectIfWatchlistDisabled), so hiding the card is not the only gate;
  • no watchlist query runs, so Upcoming Earnings and Latest News behave exactly as before (holdings only, with the usual “add investments” overlay).
Non-production builds (staging, PR previews, local) are always on, so the team can QA without a cohort. Production fails closed: an unknown flag, a PostHog error or a 2s timeout all read as off. At GA, delete the flag and both gates in one PR.

Plan limits

WATCHLIST_LIMITS in lib/watchlist/limits.ts: Free 20, Pro 100, Max 250. Max is capped rather than unlimited so the widget, its price reads and Silvia’s queries stay bounded. The limit is enforced in the database, not just the UI. Clients have no INSERT policy. POST /api/watchlist resolves the tier with the strict getActiveSubscriptionOrError (a lookup failure returns 503 rather than capping a paying member at Free). It then pre-checks duplicates and the limit BEFORE ensureTickerTracked, because that call registers the symbol in the shared catalog the price crons refresh, and a refused request must not grow it. Finally it calls add_watchlist_item() with the service role. That function takes a per-member advisory lock, counts, and inserts, so concurrent adds cannot overshoot. Over the limit it raises watchlist_limit_reached, which the route returns as 403 { code: 'WATCHLIST_LIMIT_REACHED', limit, tier } and the widget turns into an upgrade prompt. Over the limit after a downgrade: locked, never deleted. A member who filled Pro and moved to Free keeps every row, but only the first limit in their saved order are active. loadWatchlistForPlan (lib/watchlist/plan.ts) applies applyPlanLimit on every read: rows past the limit come back with locked: true and no price, change, market cap or chart, earnings and news skip them, and the detail route answers 404 for them. The card shows them greyed under a notice (“N more saved tickers are locked”) with Upgrade and Choose which N, which opens the Edit window; dragging a row into the top N unlocks it (the card re-marks by position at once, and the refetch after the save brings its price). Upgrading unlocks everything with nothing lost. So the extra rows give a member nothing their plan does not include, and the per-ticker cost (earnings lookups, price history) follows the plan. Silvia’s SQL is not filtered (the plan is not in the database); a locked row only names a symbol, and its skills doc says so. Reorder (PUT /api/watchlist/order, via reorder_watchlist_items()) and delete (DELETE /api/watchlist/[id]) run with the member’s JWT under RLS. Clients may UPDATE only position and updated_at (column grant), so a row can be reordered but never repointed at another symbol or owner. During the demo tour GET /api/watchlist returns an empty Free watchlist: the demo seeds none, and the visitor’s own list must not show inside the demo.

UI

components/dashboard-cards/watchlist/. Registered as built-in card watchlist in hooks/use-dashboard-cards.ts. For members inside the rollout (see Rollout) it appears at the end of the content grid, including for members with a saved layout, and can be removed and restored like any other card. Outside the rollout it is neither rendered nor offered under Add Widget. The card is display-only and shares the Top & Bottom movers layout (grid, hairlines, type scale): symbol and logo, a 1M sparkline, last price and the day’s percent. Logos go through useTickerLogoMap, the same chain as every ticker list (registry logo, then the fund issuer’s badge for ETFs and funds). The header’s pencil opens the Edit watchlist modal, where rows are removed (the X on each row) and reordered (drag handle or keyboard, @dnd-kit); + opens the inline search, and so does the “Add a ticker” row under a short list (up to 5 tickers, while there is room under the plan and nothing is locked).

Card behaviour

  • Freshness cue. The header shows the US session and the newest price’s time in ET (“Market open · 2:44 PM ET”; the date is added when that price is not from today). The session comes from Finnhub’s market status, cached for a minute in Redis (lib/watchlist/market-status.ts), best-effort. The ticker count lives in the + button’s tooltip and the Edit modal.
  • Sort and price-column metric. “Symbol ▾” opens a sort menu (My order, Top gainers, Top losers, Name A–Z); the price header cycles the second line between Day %, Day $ and market cap (market_cap from tracked_tickers_latest). Both are view-only, sorted client-side (watchlist-view.ts; a null day change sinks to the end) and kept per user in localStorage (use-watchlist-view-prefs.ts), never in the database: the saved position order is still what the Edit modal shows and Silvia reads. The Add Widget preview always renders My order and Day %.
  • Empty state. The search is the card: “Start your watchlist”, the search field (not focused on load, so the page does not scroll to the card) and a Popular list of six (SPY, QQQ, VTI, AAPL, NVDA, MSFT) in the same row style as the results. The first pick keeps the search open with a Done, so a new member can pick several in a row; the same component instance carries on, so a typed query survives it.
  • Search. watchlist-add-search.tsx over /api/investments/search, shared with Add Investment. Before a query it shows the Popular list; results are grouped (Stocks, ETFs, Mutual funds, Bonds) with “N of limit” beside the first title. Every row (watchlist-pick-row.tsx) toggles: + adds, a filled check means watched, and hovering it turns the check into − to remove in place (not straight after an add under the pointer, which would read as an offer to undo it). ↑/↓ move a highlight, Enter toggles it, or the best match when nothing is highlighted; Escape clears the query, then closes.
  • Logos in search. The route attaches the registry logo, else the fund issuer’s badge (fundIssuerLogoPath), so an ETF such as VOO shows the Vanguard badge in the results, as it does on the card. Finnhub has no logo for ETFs and funds, so before this they showed a letter until added.
  • Earnings chip. A row whose company reports within 7 days shows a calendar chip (“3d”, “Tmrw”, “Today”), counted in market days.
  • Detail panel. Clicking a row opens watchlist-ticker-dialog.tsx: price and day move, a 1W–1Y chart of daily closes (only ranges the history covers), open / previous close / day’s range / 52-week range of closes / volume / market cap, the member’s own position across every account (“You own 12.5 shares across 2 accounts”, from public_holding_details, blurred in privacy mode), the next report, and three actions: Ask Silvia (sends a neutral “why is it moving” or “what’s new” question and opens chat; never buy/sell), I bought this (opens Add Investment with the security preset; hidden when already held), and Remove. GET /api/watchlist/ticker/[symbol] serves it, only for symbols on the caller’s watchlist.

Earnings and news

  • GET /api/watchlist/earnings asks Finnhub’s earnings calendar about each watched company (stocks only: funds, ETFs and bonds do not report), 90 days ahead, cached per symbol for 12 hours in Redis, skipped while the shared Finnhub budget is spent. The earnings table is filled by an external job for held tickers only, which is why watched tickers go to the provider.
  • useEarnings merges these into the Upcoming Earnings card; a company that is watched but not held carries a “Watching” tag. useNews adds watched symbols to the Latest News request. Both cards drop their “add investments” overlay for a member who has a watchlist.

Analytics (PostHog)

Every event is listed in components/dashboard-cards/watchlist/watchlist-analytics.ts and sent through captureWatchlistEvent, which adds feature: 'watchlist', tier, limit, item_count and locked_count to each one, so any event can be cut by plan or by how full the list is. Upgrade attribution. The Upgrade buttons fire upgrade_cta_clicked (source: dashboard_watchlist_limit / dashboard_watchlist_locked) and stamp the upgrade entry source watchlist_limit / watchlist_locked (lib/upgrade-attribution.ts). The confirm modal’s funnel events, the Stripe checkout and the subscription carry it, it classifies as the watchlist conversion bucket (proactive), and #silvia-revenue labels it “Watchlist · Full” / “Watchlist · Locked after downgrade”. Downstream pull. dashboard_news_article_clicked carries from_watchlist and related_watched (the watched tickers the article is about), and dashboard_earnings_view_all_clicked carries watching_count, so the watchlist’s effect on those cards is measurable.

Sparklines

GET /api/watchlist attaches each row’s last 30 days of daily closes from the shared tracked_tickers_historical table (read with the service role: it is market data with no client policy, and it is paged, since 250 tickers x ~22 days passes the 1,000-row cap). The EOD cron only writes the latest day, so a ticker first registered by an add has no history: POST backfills a year of candles after the response (after(), best-effort), in one request, which serves both the row chart and the detail panel’s 1Y range. Opening the panel on a ticker with a thin year triggers the same backfill. Funds and bonds have no candles and draw no chart. A failed history read returns the list without charts rather than failing. Prices show in the listing’s own currency (pence normalised to pounds), not converted to USD. Edits are blocked in demo mode. Storybook: Dashboard Widgets/Watchlist, backed by an in-memory fetch mock so the real hooks run.

Silvia

Silvia reads watchlist_items through the SQL tool (RLS-scoped SELECT). Her schema doc lives in the skills repo: silvia-database-skills/schema/watchlist_items.md, with the user-facing description in silvia-navigation-skills/SKILL.md. Update both when this feature changes (docs/SILVIA-SKILLS-SYNC.md).