/* Cassandra — browser-side portfolio (Phase G). * * The server never persists holdings. The pie lives in this browser's * localStorage under `cassandra.pie`; the server only knows what * tickers the universe contains (anonymously). This module: * * 1. On dashboard load, hydrates the portfolio panel from localStorage. * 2. Fetches /api/universe (gzipped, same for every user) and computes * P/L locally. * 3. Refreshes prices every 60s. * 4. On /upload submission, POSTs to /api/portfolio/parse, stashes the * returned pie in localStorage, redirects to the dashboard. * 5. The "analyze" button POSTs the pie + prices to /api/analyze and * renders the returned commentary inline. */ (function () { 'use strict'; const STORAGE_KEY = 'cassandra.pie'; const UNIVERSE_REFRESH_MS = 60_000; // --- localStorage ------------------------------------------------------ function loadPie() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { console.warn('cassandra.pie: corrupt localStorage, clearing', e); localStorage.removeItem(STORAGE_KEY); return null; } } function savePie(pie) { pie.imported_at = new Date().toISOString(); localStorage.setItem(STORAGE_KEY, JSON.stringify(pie)); } function clearPie() { localStorage.removeItem(STORAGE_KEY); } // Cache the AI analysis inside the pie so it survives auto-refreshes // and full page reloads. Cleared when the pie itself is forgotten or // replaced. We re-fetch on demand by clicking "Regenerate". function saveAnalysis(analysis) { const pie = loadPie(); if (!pie) return; pie.analysis = analysis; localStorage.setItem(STORAGE_KEY, JSON.stringify(pie)); } // --- Universe ---------------------------------------------------------- let universeCache = null; let universeFetchedAt = 0; async function fetchUniverse() { const r = await fetch('/api/universe', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', }); if (!r.ok) throw new Error('universe: HTTP ' + r.status); universeCache = await r.json(); universeFetchedAt = Date.now(); return universeCache; } function priceFor(ticker) { if (!universeCache || !universeCache.tickers) return null; return universeCache.tickers[ticker] || null; } // --- FX conversion ----------------------------------------------------- // fx[CCY] = units of CCY per 1 USD (USD itself is 1.0). // To convert X-currency value to Y-currency: value_y = value_x * (fx[Y] / fx[X]). function convertCurrency(value, fromCcy, toCcy, fx) { if (value == null || !isFinite(value)) return null; if (!fromCcy || !toCcy || fromCcy === toCcy) return value; if (!fx || !fx[fromCcy] || !fx[toCcy]) return null; // missing rate return value * (fx[toCcy] / fx[fromCcy]); } // --- P/L computation --------------------------------------------------- function enrichPosition(p, base, fx) { // T212 reports `invested value` (and therefore avg_cost) in the pie's // base currency — so the avg row is ALREADY in base. The current // price from Yahoo is in the ticker's local currency and must be // converted before subtracting. const q = priceFor(p.yahoo_ticker); const priceLocal = q ? q.p : null; const priceCcy = (q && q.c) || p.currency || null; const priceBase = convertCurrency(priceLocal, priceCcy, base, fx); const value = (priceBase != null && p.qty != null) ? priceBase * p.qty : null; const invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null; const ppl = (value != null && invested != null) ? value - invested : null; const ppl_pct = (value != null && invested) ? (value / invested - 1) * 100 : null; const d1 = q && q.d ? q.d['1d'] : null; return Object.assign({}, p, { _current_price_local: priceLocal, _current_price_base: priceBase, _currency: priceCcy, // for the currency-mix pills _base_currency: base, _value: value, _invested: invested, _ppl: ppl, _ppl_pct: ppl_pct, _change_1d: d1, _fx_missing: priceLocal != null && priceBase == null, }); } function aggregate(positions) { let totalValue = 0, totalInvested = 0, missingPrice = 0; const byCcy = {}; for (const r of positions) { if (r._value != null) { totalValue += r._value; if (r._currency) byCcy[r._currency] = (byCcy[r._currency] || 0) + r._value; } else { missingPrice++; } if (r._invested != null) totalInvested += r._invested; } return { n_positions: positions.length, missing_price: missingPrice, total_value: totalValue || null, total_invested: totalInvested || null, total_ppl: (totalValue && totalInvested) ? totalValue - totalInvested : null, total_ppl_pct: (totalValue && totalInvested) ? (totalValue / totalInvested - 1) * 100 : null, by_currency: byCcy, }; } // --- Rendering --------------------------------------------------------- function fmt(n, opts) { if (n == null || !isFinite(n)) return '—'; const o = Object.assign({ minimumFractionDigits: 2, maximumFractionDigits: 2 }, opts || {}); return Number(n).toLocaleString(undefined, o); } function signed(n) { if (n == null || !isFinite(n)) return '—'; return (n >= 0 ? '+' : '') + fmt(n); } function pct(n) { if (n == null || !isFinite(n)) return '—'; return (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; } function cls(n) { if (n == null) return 'neu'; return n >= 0 ? 'pos' : 'neg'; } function esc(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } // Tiny one-shot flag the orphan-cleanup path sets so renderEmpty can // surface a plain-English "your previous backup needs to be re-uploaded" // line. Read-once; cleared as soon as it's shown. function consumeBackupExpiredNotice() { try { if (sessionStorage.getItem('cassandra.sync.backupExpired') === '1') { sessionStorage.removeItem('cassandra.sync.backupExpired'); return true; } } catch (e) { /* ignore */ } return false; } function renderEmpty(mount) { const expired = consumeBackupExpiredNotice(); const notice = expired ? '
' + 'Your previous cloud backup couldn’t be restored on this server. ' + 'Please re-upload your portfolio to refresh it.' + '
' : ''; mount.innerHTML = '
' + notice + 'No portfolio loaded in this browser. ' + 'Import a portfolio CSV →' + '
'; } // Silently remove an unrecoverable cloud blob and re-render. The user // sees the standard empty state with a soft one-liner — no jargon, no // extra buttons. The decision to remove is safe: the blob is already // permanently undecryptable, so we're cleaning up dead state, not // discarding user data. async function autoCleanStaleBlob(mount) { try { await window.CassandraSync.disableSync(); } catch (e) { console.warn('cassandra.sync: auto-clean of stale blob failed', e); } try { sessionStorage.setItem('cassandra.sync.backupExpired', '1'); } catch (e) { /* ignore */ } renderEmpty(mount); } function renderRestoreFromCloud(mount, status) { const lastSynced = status.updated_at ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '—'; mount.innerHTML = '
' + '
▸ Restore from cloud
' + '
' + 'A synced portfolio is available for this account (last synced ' + esc(lastSynced) + '). Enter your PIN to load it on this browser.' + '
' + '
' + '' + '' + '' + 'or import a new CSV →' + '
' + '' + '
'; const form = document.getElementById('pf-restore-form'); const pin = document.getElementById('pf-restore-pin'); const err = document.getElementById('pf-restore-err'); form.addEventListener('submit', async (e) => { e.preventDefault(); err.hidden = true; const value = (pin.value || '').trim(); if (!value) return; try { const pie = await window.CassandraSync.pullSync(value); if (!pie) { err.textContent = 'No synced portfolio found.'; err.hidden = false; return; } savePie(pie); mountAndRender(); } catch (e2) { if (e2 && e2.name === 'StaleBlobError') { // Pepper rotated since the blob was written — silently clean // up and fall through to the empty state with a soft notice. autoCleanStaleBlob(mount); return; } err.textContent = (e2 && e2.name === 'BadPinError') ? 'Incorrect PIN.' : (e2.message || 'Could not restore.'); err.hidden = false; } }); } function renderPanel(mount, pie, enriched, agg) { const ccyPills = Object.keys(agg.by_currency) .sort((a, b) => agg.by_currency[b] - agg.by_currency[a]) .map(c => { const share = agg.total_value ? (agg.by_currency[c] / agg.total_value * 100) : 0; return '' + esc(c) + ' ' + share.toFixed(0) + '%'; }) .join(' '); const rows = enriched.map(p => { // 'Last' shows the local-currency price (matches Yahoo + T212 display). // P/L column is in the pie's base currency after FX conversion. const lastDisplay = p._current_price_local != null ? fmt(p._current_price_local) + (p._currency && p._currency !== pie.base_currency ? ' ' + esc(p._currency) + '' : '') : '—'; const fxBadge = p._fx_missing ? ' ' + esc(p._currency || '?') + '' : ''; return '' + '' + esc(p.yahoo_ticker) + '' + '' + esc(p.name || '') + '' + '' + fmt(p.qty, { maximumFractionDigits: 6 }) + '' + '' + fmt(p.avg_cost) + '' + '' + lastDisplay + fxBadge + '' + '' + signed(p._ppl) + '' + '' + pct(p._ppl_pct) + '' + ''; }).join(''); const importedAt = pie.imported_at ? new Date(pie.imported_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' : '—'; // Prices' as-of timestamp comes from the universe response; that's // the moment the server fetched the quotes this panel just used. // Falls back to "now" if universeCache hasn't populated yet. const pricesAsOf = (universeCache && universeCache.as_of) ? new Date(universeCache.as_of).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; const nameTooltip = 'Imported ' + importedAt + ' — kept in this browser only'; const missingNote = agg.missing_price > 0 ? '
' + agg.missing_price + ' position(s) have no live price — universe may be catching up.
' : ''; mount.innerHTML = '
' + '
' + '' + esc(pie.pie_name || 'Portfolio') + '' + '' + 'prices ' + esc(pricesAsOf) + '' + '
' + '
' + '
Total
' + '
' + fmt(agg.total_value) + ' ' + esc(pie.base_currency || '') + '
' + '
Invested
' + '
' + fmt(agg.total_invested) + '
' + '
Unrealised P/L
' + '
' + signed(agg.total_ppl) + (agg.total_ppl_pct != null ? ' (' + pct(agg.total_ppl_pct) + ')' : '') + '
' + '
Positions
' + '
' + agg.n_positions + '
' + '
' + '
Currency mix
' + '
' + (ccyPills || '—') + '
' + '
' + '
' + missingNote + '' + '' + '' + '' + '' + '' + '' + '' + rows + '' + '
TickerNameQtyAvgLastP/L%
' + '
' + '' + '' + '
' + ''; document.getElementById('pf-analyze').addEventListener('click', () => runAnalysis(pie, enriched)); document.getElementById('pf-forget').addEventListener('click', () => { if (confirm('Remove the saved pie from this browser? The server holds nothing — this is local.')) { clearPie(); mountAndRender(); } }); // Re-hydrate any cached AI analysis so the 60s auto-refresh doesn't // wipe it. Collapsed by default on hydration so the panel stays // compact — click the header to expand. if (pie.analysis && pie.analysis.content) { showAnalysis(pie.analysis, { open: false }); } } function showAnalysis(analysis, opts) { const out = document.getElementById('pf-analysis'); if (!out) return; const openAttr = (opts && opts.open) ? ' open' : ''; out.hidden = false; out.innerHTML = '
' + '' + '' + 'AI analysis' + '' + '' + esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) + ' UTC' + '' + '
' + esc(analysis.content) + '
' + '
'; } async function runAnalysis(pie, enriched) { const out = document.getElementById('pf-analysis'); const btn = document.getElementById('pf-analyze'); out.hidden = false; out.innerHTML = '
generating…
'; btn.disabled = true; // Build the prices payload from the universe cache so the server // doesn't have to re-fetch. const prices = {}; if (universeCache && universeCache.tickers) { for (const p of pie.positions) { const q = universeCache.tickers[p.yahoo_ticker]; if (q) prices[p.yahoo_ticker] = q; } } try { const r = await fetch('/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ positions: pie.positions, prices: prices, base_currency: pie.base_currency || 'GBP', }), }); const data = await r.json(); if (!r.ok) { // FastAPI `detail` is usually a string, but some endpoints send // an object — e.g. the 402 paid-gate returns {code, message}. // Render the human-readable text either way; never the object // (which stringifies to the useless "[object Object]"). const d = data && data.detail; const msg = (d && typeof d === 'object') ? (d.message || JSON.stringify(d)) : (d || ('HTTP ' + r.status)); out.innerHTML = '
' + esc(msg) + '
'; return; } // Persist before rendering so auto-refresh can re-hydrate. saveAnalysis(data); showAnalysis(data, { open: true }); } catch (e) { out.innerHTML = '
' + esc(e.message) + '
'; } finally { btn.disabled = false; } } // --- Mount / refresh --------------------------------------------------- async function mountAndRender() { const mount = document.getElementById('pf-mount'); if (!mount) return; const pie = loadPie(); if (!pie || !pie.positions || !pie.positions.length) { // Before falling back to "no portfolio", check whether the account // has a synced blob this device could restore from. Status is // 402 for free-tier users — getStatus() returns paid:false there // and we fall through to the standard empty state. let status = null; if (window.CassandraSync) { try { status = await window.CassandraSync.getStatus(); } catch (e) { console.warn('sync status check failed', e); } } if (status && status.paid && status.exists) { if (status.orphaned) { // Pepper rotated since the blob was written — clean up // silently and show the standard empty state with a soft // "please re-upload" notice. autoCleanStaleBlob(mount); } else { renderRestoreFromCloud(mount, status); } } else { renderEmpty(mount); } return; } try { if (!universeCache || Date.now() - universeFetchedAt > UNIVERSE_REFRESH_MS) { await fetchUniverse(); } } catch (e) { console.warn('universe fetch failed', e); } const base = pie.base_currency || 'GBP'; const fx = (universeCache && universeCache.fx) || null; const enriched = pie.positions.map(p => enrichPosition(p, base, fx)) .sort((a, b) => (b._value || 0) - (a._value || 0)); const agg = aggregate(enriched); renderPanel(mount, pie, enriched, agg); } // --- Parse primitive --------------------------------------------------- // // Hits /api/portfolio/parse and returns the parsed pie. The caller // decides whether to savePie() and whether to push to cloud sync — keeps // the post-parse decision in the inline UI script instead of buried in // this module. async function parseCsv(file) { const fd = new FormData(); fd.append('file', file); const r = await fetch('/api/portfolio/parse', { method: 'POST', body: fd, credentials: 'same-origin', }); const data = await r.json().catch(() => ({})); if (!r.ok) { const err = new Error(data.detail || ('HTTP ' + r.status)); err.status = r.status; throw err; } return data; } // Formatting helpers exposed so inline UI scripts (like the import // preview in settings.html) don't have to re-implement them. window.CassandraPortfolio = { mountAndRender, parseCsv, loadPie, savePie, clearPie, fmt, signed, pct, cls, esc, }; // Auto-mount on dashboard load and refresh every minute. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountAndRender); } else { mountAndRender(); } setInterval(mountAndRender, UNIVERSE_REFRESH_MS); })();