';
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) {
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);
})();