read.markets/app/static/js/portfolio.js
Giorgio Gilestro 6377c929b8 portfolio-edit: form is edit-mode only; submit becomes a + glyph
Two related polishes:

- The add form was auto-shown by the empty-state path so brand-new
  users would see something to act on. That conflicts with the user's
  preference for "Edit always toggles the form, no other path." The
  empty state now shows guidance copy ("click edit to add one")
  instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
  + glyph (26×26). Matches the visual weight of the calendar ghost
  next to it but stays in the accent colour so it reads as primary.
  Adds a tiny active-state scale tick for tactile feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:22:13 +02:00

557 lines
21 KiB
JavaScript

/* 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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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
? '<div class="empty-notice">Your encrypted cloud backup expired. ' +
'Please re-upload your portfolio to refresh it.' +
'</div>'
: '';
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.add('pf-empty');
mount.innerHTML =
'<div class="empty" style="padding:16px;">' +
notice +
'No positions yet — click <strong>edit</strong> to add one, or ' +
'<a href="/settings#import">import a CSV from your broker →</a>' +
'</div>';
// The form is only ever visible in edit mode — never auto-shown by
// the empty state. Defensive: ensure it stays hidden here.
var form = document.getElementById('pf-add-form');
if (form) form.hidden = true;
}
// 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 =
'<div class="pf-restore" style="padding:16px;">' +
'<div class="result__head">▸ Restore from cloud</div>' +
'<div class="result__row" style="margin-bottom:12px;">' +
'A synced portfolio is available for this account (last synced ' +
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
'</div>' +
'<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' +
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
'<button type="submit" class="settings-btn">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' +
'</form>' +
'<div id="pf-restore-err" class="pf-warn" hidden style="margin-top:10px;"></div>' +
'</div>';
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) {
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.remove('pf-empty');
// The empty-state path forces the add form visible. When we move
// back to a populated view we re-hide it — unless edit mode is on,
// in which case the form stays visible for ongoing edits.
var form = document.getElementById('pf-add-form');
if (form && panel && !panel.classList.contains('pf-editing')) {
form.hidden = true;
}
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 '<span class="pf-pill">' + esc(c) + ' ' + share.toFixed(0) + '%</span>';
})
.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
? ' <span class="pf-ccy">' + esc(p._currency) + '</span>'
: '')
: '—';
const fxBadge = p._fx_missing
? ' <span class="pf-ccy" title="FX rate missing">' + esc(p._currency || '?') + '</span>'
: '';
return '<tr>' +
'<td class="label">' + esc(p.yahoo_ticker) + '</td>' +
'<td>' + esc(p.name || '') + '</td>' +
'<td class="num">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
'<td class="num neu">' + fmt(p.avg_cost) + '</td>' +
'<td class="num">' + lastDisplay + fxBadge + '</td>' +
'<td class="num ' + cls(p._ppl) + '">' + signed(p._ppl) + '</td>' +
'<td class="num ' + cls(p._ppl_pct) + '">' + pct(p._ppl_pct) + '</td>' +
'<td class="pf-row-del-cell">' +
'<button type="button" class="pf-row-del" data-idx="' + p._orig_idx + '" ' +
'title="Remove this position" aria-label="Remove">\xd7</button>' +
'</td>' +
'</tr>';
}).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
? '<div class="pf-warn">' + agg.missing_price +
' position(s) have no live price — universe may be catching up.</div>'
: '';
mount.innerHTML =
'<div class="pf-overall">' +
'<div class="pf-overall__head">' +
'<span class="pf-name has-tip" title="' + esc(nameTooltip) + '">' +
esc(pie.pie_name || 'Portfolio') +
'</span>' +
'<span class="pf-as-of" title="Prices fetched by the server at this time. Browser recomputes P/L on every refresh (~60s).">' +
'prices ' + esc(pricesAsOf) +
'</span>' +
'</div>' +
'<div class="pf-overall__grid">' +
'<div class="pf-stat"><div class="pf-stat-label">Total</div>' +
'<div class="pf-stat-value">' + fmt(agg.total_value) +
' <span class="pf-ccy">' + esc(pie.base_currency || '') + '</span></div></div>' +
'<div class="pf-stat"><div class="pf-stat-label">Invested</div>' +
'<div class="pf-stat-value">' + fmt(agg.total_invested) + '</div></div>' +
'<div class="pf-stat"><div class="pf-stat-label">Unrealised P/L</div>' +
'<div class="pf-stat-value ' + cls(agg.total_ppl) + '">' + signed(agg.total_ppl) +
(agg.total_ppl_pct != null
? ' <span class="pf-pct">(' + pct(agg.total_ppl_pct) + ')</span>'
: '') +
'</div></div>' +
'<div class="pf-stat"><div class="pf-stat-label">Positions</div>' +
'<div class="pf-stat-value">' + agg.n_positions + '</div></div>' +
'<div class="pf-stat" style="grid-column: span 2;">' +
'<div class="pf-stat-label">Currency mix</div>' +
'<div class="pf-pills">' + (ccyPills || '—') + '</div></div>' +
'</div>' +
'</div>' +
missingNote +
'<table class="dense">' +
'<thead><tr>' +
'<th>Ticker</th><th>Name</th>' +
'<th class="num">Qty</th><th class="num">Avg</th>' +
'<th class="num">Last</th><th class="num">P/L</th>' +
'<th class="num">%</th>' +
'<th></th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<div class="pf-actions">' +
'<button id="pf-analyze" type="button">Generate AI analysis</button>' +
'<button id="pf-forget" type="button" class="pf-secondary">Forget this pie</button>' +
'</div>' +
'<div id="pf-analysis" class="pf-analysis" hidden></div>';
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 =
'<details class="pf-analysis__details"' + openAttr + '>' +
'<summary class="pf-analysis__head">' +
'<span class="pf-analysis__head-left">' +
'AI analysis' +
'</span>' +
'<span class="pf-as-of">' +
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
' UTC</span>' +
'</summary>' +
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
'</details>';
}
async function runAnalysis(pie, enriched) {
const out = document.getElementById('pf-analysis');
const btn = document.getElementById('pf-analyze');
out.hidden = false;
out.innerHTML = '<div class="empty">generating…</div>';
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 = '<div class="pf-warn">' + esc(msg) + '</div>';
return;
}
// Persist before rendering so auto-refresh can re-hydrate.
saveAnalysis(data);
showAnalysis(data, { open: true });
} catch (e) {
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
} 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, i) => Object.assign(enrichPosition(p, base, fx), { _orig_idx: i }))
.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);
})();