// Phase 2E — Watchlist panel. // // First-class editable surface for config/watchlist.json + thresholded alerts. // AlphaSense-inspired: each watched ticker shows live price, day %, sparkline, // and any active alerts. Inline form to add a new ticker or set a price-cross // alert. Auto-refreshes every 30s while the panel is open. const { useState: wlUseState, useEffect: wlUseEffect, useCallback: wlUseCallback, useMemo: wlUseMemo, useRef: wlUseRef, } = React; // Mini sparkline — same compact format as the rest of the app's spark cards. function WlSpark({ data }) { if (!Array.isArray(data) || data.length < 2) return ; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const w = 60, h = 18; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / range) * h; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); const dir = data[data.length - 1] >= data[0] ? 'up' : 'down'; return ( ); } // Parse the thesis-driven watchlist (brain/4-Stocks/Watchlist/Watchlist.md) // into themed tables. The file is structured as `## Theme` sections, each // containing one or more markdown tables with columns starting with Ticker. // We don't try to be a full markdown parser — just enough to extract // {theme, rows} pairs for browse-by-theme rendering. function parseVaultWatchlist(md) { if (!md) return []; // Strip YAML frontmatter const fmMatch = md.match(/^---\s*\n[\s\S]*?\n---\s*\n/); const body = fmMatch ? md.slice(fmMatch[0].length) : md; // Split on `\n## ` — first chunk is the file intro, subsequent chunks // each start with the theme name on the first line. const parts = body.split(/\n## /); const themes = []; for (let i = 1; i < parts.length; i++) { const chunk = parts[i]; const newlineAt = chunk.indexOf('\n'); const themeName = (newlineAt >= 0 ? chunk.slice(0, newlineAt) : chunk).trim(); const themeBody = newlineAt >= 0 ? chunk.slice(newlineAt + 1) : ''; // Find every markdown table — they look like: // | A | B | // |---|---| // | a | b | // ... // Match the header row + separator + body rows until a blank line. const tables = []; const lines = themeBody.split('\n'); let cur = null; // {label, headers, rows} let pendingLabel = null; // last "**ETFs:**" type label seen before a table for (const line of lines) { const t = line.trim(); // Stop at the next theme separator (---) but only if not in a table if (t === '---' && !cur) continue; // Track inline section labels that precede sub-tables const labelMatch = t.match(/^\*\*(.+?):?\*\*$/); if (labelMatch && !cur) { pendingLabel = labelMatch[1].trim(); continue; } // Table line? if (t.startsWith('|')) { const cells = t.slice(1, t.endsWith('|') ? -1 : undefined) .split('|').map(c => c.trim()); // Header line — followed by separator. Detect by first row in fresh table. if (!cur) { cur = { label: pendingLabel, headers: cells, rows: [] }; pendingLabel = null; } else if (cells.every(c => /^-+$/.test(c.replace(/[: ]/g, '')))) { // separator — skip continue; } else { cur.rows.push(cells); } } else if (cur) { // Blank line or non-table — close the current table if (cur.headers.length && cur.rows.length) tables.push(cur); cur = null; } } if (cur && cur.headers.length && cur.rows.length) tables.push(cur); if (tables.length) themes.push({ name: themeName, tables }); } return themes; } // Strip Obsidian-style [[wikilinks]] for display — keep just the visible text. function stripWikilinks(s) { return String(s || '').replace(/\[\[(?:[^\]|]*\|)?([^\]]+)\]\]/g, '$1'); } function WatchlistPanel({ open, onClose }) { if (!open) return null; const [mode, setMode] = wlUseState('live'); // 'live' | 'theme' const [tickers, setTickers] = wlUseState([]); const [quotes, setQuotes] = wlUseState({}); const [alerts, setAlerts] = wlUseState([]); const [loading, setLoading] = wlUseState(false); const [err, setErr] = wlUseState(''); const [activeTicker, setActiveTicker] = wlUseState(null); const [newTicker, setNewTicker] = wlUseState(''); const [adding, setAdding] = wlUseState(false); const [alertForm, setAlertForm] = wlUseState({ condition: 'price_above', threshold: '', note: '' }); const [busyAlert, setBusyAlert] = wlUseState(false); const [themeData, setThemeData] = wlUseState(null); // {themes, mtime?} const [themeLoading, setThemeLoading] = wlUseState(false); const [themeFilter, setThemeFilter] = wlUseState(''); // free-text filter applied across all rows const [openThemes, setOpenThemes] = wlUseState({}); // themeName → bool (collapsed) const refreshTimerRef = wlUseRef(null); const refresh = wlUseCallback(async () => { setLoading(true); setErr(''); try { const [wlR, qR, alR] = await Promise.all([ fetch('/api/watchlist'), fetch('/api/watchlist/quotes'), fetch('/api/alerts?active_only=true'), ]); if (!wlR.ok) throw new Error(`watchlist HTTP ${wlR.status}`); const wl = await wlR.json(); setTickers(wl.tickers || []); if (qR.ok) { const q = await qR.json(); setQuotes(q.quotes || {}); } if (alR.ok) { const a = await alR.json(); setAlerts(a.alerts || []); } } catch (e) { setErr(e.message || String(e)); } finally { setLoading(false); } }, []); // Initial load + 30s polling refresh while open wlUseEffect(() => { if (!open) return; refresh(); refreshTimerRef.current = setInterval(refresh, 30000); return () => { if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); refreshTimerRef.current = null; }; }, [open, refresh]); // Lazy-load the by-theme markdown when the user first switches to that tab. // Cache on the panel; user can refresh by closing/reopening or hitting the // refresh button. The vault file is 50+KB so we only fetch on demand. const loadThemeData = wlUseCallback(async () => { setThemeLoading(true); setErr(''); try { const r = await fetch('/api/vault/note?path=' + encodeURIComponent('4-Stocks/Watchlist/Watchlist.md')); if (!r.ok) throw new Error(`vault note HTTP ${r.status}`); const j = await r.json(); const md = j.content || j.body || ''; const themes = parseVaultWatchlist(md); setThemeData({ themes, mtime: j.mtime || null, raw_chars: md.length }); // Default: all themes expanded const allOpen = {}; themes.forEach(t => { allOpen[t.name] = true; }); setOpenThemes(allOpen); } catch (e) { setErr(`Vault watchlist load: ${e.message || e}`); } finally { setThemeLoading(false); } }, []); wlUseEffect(() => { if (mode === 'theme' && !themeData && !themeLoading) { loadThemeData(); } }, [mode, themeData, themeLoading, loadThemeData]); const addTicker = async () => { const t = (newTicker || '').trim().toUpperCase(); if (!t || adding) return; setAdding(true); setErr(''); try { const r = await fetch('/api/watchlist/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticker: t }), }); if (!r.ok) { const tx = await r.text().catch(() => ''); throw new Error(`Add failed (${r.status}): ${tx.slice(0, 120)}`); } setNewTicker(''); refresh(); } catch (e) { setErr(e.message || String(e)); } finally { setAdding(false); } }; const removeTicker = async (t) => { if (!window.confirm(`Remove ${t} from your watchlist?`)) return; try { const r = await fetch(`/api/watchlist/${encodeURIComponent(t)}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`Remove failed (${r.status})`); if (activeTicker === t) setActiveTicker(null); refresh(); } catch (e) { setErr(e.message || String(e)); } }; const createAlert = async (ticker) => { if (busyAlert) return; const condition = alertForm.condition; let threshold = alertForm.threshold ? parseFloat(alertForm.threshold) : null; // price_above / price_below need a numeric threshold; news / earnings don't const needsThreshold = ['price_above', 'price_below'].includes(condition); if (needsThreshold && (threshold == null || isNaN(threshold))) { setErr('Set a numeric price threshold for price alerts.'); return; } setBusyAlert(true); setErr(''); try { const r = await fetch('/api/alerts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticker, condition, threshold: needsThreshold ? threshold : null, note: alertForm.note || `${condition} on ${ticker}`, }), }); if (!r.ok) throw new Error(`Create alert failed (${r.status})`); setAlertForm({ condition: 'price_above', threshold: '', note: '' }); refresh(); } catch (e) { setErr(e.message || String(e)); } finally { setBusyAlert(false); } }; const dismissAlert = async (alertId) => { try { const r = await fetch(`/api/alerts/${encodeURIComponent(alertId)}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`Dismiss failed (${r.status})`); refresh(); } catch (e) { setErr(e.message || String(e)); } }; // Group alerts by ticker for inline display under each row const alertsByTicker = wlUseMemo(() => { const m = {}; for (const a of alerts) { if (!m[a.ticker]) m[a.ticker] = []; m[a.ticker].push(a); } return m; }, [alerts]); const activeQuote = activeTicker ? quotes[activeTicker] : null; const activeAlertsList = activeTicker ? (alertsByTicker[activeTicker] || []) : []; return (
{ if (e.target.classList.contains('wl-overlay')) onClose(); }}>
Watchlist · alerts
Your watchlist
Live prices, day %, sparklines, threshold alerts. Auto-refreshes every 30s. Add tickers below; click a row to set a price-cross alert.
{err &&
⚠ {err}
} {/* Mode tabs — Live (default, existing functionality) | By theme (parses brain/4-Stocks/Watchlist/Watchlist.md). Different views of the same idea: live = which tickers am I tracking right now; theme = what's on the radar by sector/thesis axis. */}
{[ { id: 'live', label: 'Live · alerts' }, { id: 'theme', label: 'By theme' }, ].map(tab => ( ))}
{mode === 'theme' ? ( setOpenThemes(t => ({ ...t, [name]: !t[name] }))} onReload={loadThemeData} /> ) : ( <>
setNewTicker(e.target.value.toUpperCase())} onKeyDown={(e) => { if (e.key === 'Enter') addTicker(); }} placeholder="Add ticker (e.g. CRWV)" maxLength={6} autoComplete="off" />
{/* List on the left */}
{tickers.length === 0 && !loading && (
No tickers yet. Add one above to start tracking.
)} {tickers.map(t => { const q = quotes[t] || {}; const pct = q.changePct; const pctColor = pct == null ? 'var(--text-dim)' : (pct >= 0 ? 'var(--accent)' : 'var(--danger)'); const tickerAlerts = alertsByTicker[t] || []; return ( ); })}
{/* Detail / alert form on the right */}
{!activeTicker && (
Click a ticker to set an alert.
Threshold alerts (price_above / price_below) check on the next quote poll. The backend already polls; this UI just adds the threshold rules. News + earnings alerts hook into the existing alerts table — they fire on the next news / earnings event for that ticker.
)} {activeTicker && ( <>
${activeTicker}
{activeQuote?.price != null && Last${activeQuote.price.toFixed(2)}} {activeQuote?.changePct != null && ( Day = 0 ? 'var(--accent)' : 'var(--danger)' }}> {activeQuote.changePct >= 0 ? '+' : ''}{activeQuote.changePct.toFixed(2)}% )} {activeQuote?.volume != null && Vol{(activeQuote.volume / 1e6).toFixed(1)}M}
{activeAlertsList.length > 0 && (
Active alerts ({activeAlertsList.length})
{activeAlertsList.map(a => (
{a.condition} {a.threshold != null && @ ${a.threshold}} {a.note && {a.note}}
))}
)}
Set new alert
{['price_above', 'price_below'].includes(alertForm.condition) && ( setAlertForm(f => ({ ...f, threshold: e.target.value }))} placeholder={`Price (e.g. ${activeQuote?.price ? (activeQuote.price * 1.05).toFixed(2) : '210'})`} /> )}
setAlertForm(f => ({ ...f, note: e.target.value }))} placeholder="Note (optional) — why does this threshold matter?" maxLength={200} />
)}
)}
); } // ─────── By-theme view ─────── // Renders the parsed structure from brain/4-Stocks/Watchlist/Watchlist.md // as collapsible theme groups. Filter input narrows rows by free-text // match across ticker, company, and "why watching" cells. Click a ticker // to open its $TICKER.md deep dive in a new tab via /api/vault/note. function ThemeView({ data, loading, filter, onFilter, openThemes, onToggleTheme, onReload }) { const themes = data?.themes || []; const filterLower = (filter || '').trim().toLowerCase(); const matchesFilter = (row) => { if (!filterLower) return true; return row.some(cell => stripWikilinks(cell).toLowerCase().includes(filterLower)); }; // Total candidate count (post-filter) for header summary const totalRows = themes.reduce((sum, t) => { return sum + t.tables.reduce((ss, tbl) => ss + tbl.rows.filter(matchesFilter).length, 0); }, 0); if (loading) { return (
Parsing vault watchlist…
); } if (!data || themes.length === 0) { return (
No themes parsed yet.
); } const expandAll = () => { themes.forEach(t => { if (!openThemes[t.name]) onToggleTheme(t.name); }); }; const collapseAll = () => { themes.forEach(t => { if (openThemes[t.name]) onToggleTheme(t.name); }); }; return (
onFilter(e.target.value)} placeholder="Filter — ticker / sector / why watching…" style={{ flex: 1, padding: '8px 10px', fontSize: 12, background: 'var(--bg-2)', color: 'var(--text)', border: '1px solid var(--line-2)', borderRadius: 3, fontFamily: 'var(--font-body)', }} />
{themes.length} themes · {totalRows} candidate{totalRows === 1 ? '' : 's'} {filterLower && ` (matching "${filter}")`} {data.raw_chars ? ` · ${(data.raw_chars / 1024).toFixed(1)}KB source` : ''}
{themes.map(theme => { const isOpen = openThemes[theme.name]; const filteredTables = theme.tables.map(tbl => ({ ...tbl, rows: tbl.rows.filter(matchesFilter), })).filter(tbl => tbl.rows.length > 0); const themeRowCount = filteredTables.reduce((s, t) => s + t.rows.length, 0); // Skip rendering theme header if filter eliminates all its rows if (filterLower && themeRowCount === 0) return null; return (
{isOpen && filteredTables.map((tbl, i) => ( ))}
); })}
); } function ThemeTable({ table }) { return (
{table.label && (
{table.label}
)}
{table.headers.map((h, i) => ( ))} {table.rows.map((row, ri) => ( {row.map((cell, ci) => { const stripped = stripWikilinks(cell); // First column tends to be Ticker — make it tighter and accent-colored const isFirst = ci === 0; return ( ); })} ))}
{h}
= 3 ? 360 : 'none', }}> {stripped}
); } const themeBtnStyle = { padding: '6px 12px', fontSize: 10, fontFamily: "'JetBrains Mono', monospace", background: 'transparent', color: 'var(--text-2)', border: '1px solid var(--line)', borderRadius: 3, cursor: 'pointer', letterSpacing: '0.08em', textTransform: 'lowercase', }; window.WatchlistPanel = WatchlistPanel;