// 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 (
| {h} | ))}
|---|
| = 3 ? 360 : 'none', }}> {stripped} | ); })}