';
}
// 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.' +
'
' +
'' +
'' +
'
';
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.
';
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);
})();