> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# WATCHLIST

# 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.

| Event | When | Extra properties |
| - | - | - |
| `dashboard_watchlist_viewed` | Card rendered with data, once per mount (not in the Add Widget preview) | `is_empty`, `is_full`, `sort`, `metric`; sets person props `watchlist_size`, `watchlist_locked_count` |
| `dashboard_watchlist_add_opened` | Search opened | `entry`: `plus` / `add_row` / `empty_state` (the empty card's embedded search first focused or used, once per mount) |
| `dashboard_watchlist_search_no_results` | A query settled with nothing to add (once per query) | `query`, `query_length` |
| `dashboard_watchlist_item_added` | Add saved | `symbol`, `asset_class`, `source`: `search` / `starter` (the Popular list), `item_count_after`, `is_first_item` |
| `dashboard_watchlist_item_add_failed` | Add failed (not the plan limit) | `symbol`, `source`, `error_message` |
| `dashboard_watchlist_limit_reached` | "Your watchlist is full" shown | `trigger`: `plus` / `pick` (a pick while pending adds already fill the list) / `server` |
| `dashboard_watchlist_limit_notice_dismissed` | "Not now" | |
| `dashboard_watchlist_locked_notice_viewed` | Locked rows shown after a downgrade, once per mount | |
| `dashboard_watchlist_edit_opened` | Edit window opened | `entry`: `pencil` / `locked_notice` |
| `dashboard_watchlist_edit_closed` | Edit window closed | `removed_count`, `reordered_count`, `unlocked_count` |
| `dashboard_watchlist_reordered` / `_reorder_failed` | A drag saved / failed | `symbol`, `from_position`, `to_position`, `unlocked`, `locked` |
| `dashboard_watchlist_item_removed` / `_item_remove_failed` | Remove saved / failed | `symbol`, `surface`: `edit_modal` / `detail_panel` / `search`, `was_locked`, `position` |
| `dashboard_watchlist_sorted` / `_metric_changed` | View controls | `sort` / `metric` |
| `dashboard_watchlist_detail_opened` / `_detail_closed` | Ticker panel | `symbol`, `position`, `change_percent`, `has_earnings_soon` / `dwell_ms` |
| `dashboard_watchlist_detail_range_changed` | 1W–1Y | `range` |
| `dashboard_watchlist_ask_silvia` | Ask Silvia from the panel | `prompt_kind`: `why_moved` / `latest` |
| `dashboard_watchlist_bought_clicked` / `_bought` | "I bought this" opened / holding saved (watch → own) | `symbol` |

**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`).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.