The /api/analyze flow previously read principal.user.lang from the DB on every request and ignored anything the client might send. That races the language toggle's PATCH: a user can flip the toggle and click Generate/Regenerate before the PATCH /api/settings/language hits the DB, so the analysis is sent with the OLD persisted lang while the toggle visually reads as the new one. From the user's POV the analysis comes back in the wrong language. Frontend portfolio.js now reads the live #lang-toggle data-lang attribute (the same source the UI itself uses) and includes it in the /api/analyze body. The dataset attribute is updated optimistically by cassandraSetLang() before the PATCH fires, so it always reflects what the user is looking at. Backend universe.py prefers payload["lang"] when present and falls back to user.lang otherwise — older clients (scripts, direct curl) that don't send anything still get the DB-stored preference. The resolution path is logged so we can confirm in prod which lang actually drove a given request. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
610 lines
24 KiB
JavaScript
610 lines
24 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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[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 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 mobile-hide">' + fmt(p.qty, { maximumFractionDigits: 6 }) + '</td>' +
|
|
'<td class="num neu mobile-hide">' + 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 mobile-hide">Qty</th><th class="num mobile-hide">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>' +
|
|
// The "Generate" button only renders when there's no cached
|
|
// analysis yet. Once one exists, regeneration moves inside the
|
|
// collapsible analysis box (see showAnalysis below). The "Forget
|
|
// this pie" button is destructive enough that it lives in
|
|
// edit-mode only — CSS in portfolio.css hides it when the
|
|
// portfolio panel isn't carrying the .pf-editing class.
|
|
'<div class="pf-actions">' +
|
|
(pie.analysis && pie.analysis.content
|
|
? ''
|
|
: '<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>';
|
|
|
|
const analyzeBtn = document.getElementById('pf-analyze');
|
|
if (analyzeBtn) {
|
|
analyzeBtn.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. Rendered expanded so the user keeps seeing the body they
|
|
// just generated — collapsing it under their cursor every minute
|
|
// reads as "the analysis disappeared". They can still click the
|
|
// header to collapse manually within a single refresh window. The
|
|
// regenerate callback closes over the current pie/enriched so a
|
|
// click rebuilds the analysis with the same context that drove
|
|
// the initial render.
|
|
if (pie.analysis && pie.analysis.content) {
|
|
showAnalysis(pie.analysis, { open: true }, () => runAnalysis(pie, enriched));
|
|
}
|
|
}
|
|
|
|
function showAnalysis(analysis, opts, onRegenerate) {
|
|
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-analysis__head-right">' +
|
|
'<span class="pf-as-of">' +
|
|
esc((analysis.generated_at || '').slice(0, 19).replace('T', ' ')) +
|
|
' UTC</span>' +
|
|
(onRegenerate
|
|
? '<button id="pf-regen" type="button" class="pf-regen"' +
|
|
' title="Run the analysis again on the current portfolio">' +
|
|
'Regenerate</button>'
|
|
: '') +
|
|
'</span>' +
|
|
'</summary>' +
|
|
'<pre class="pf-analysis__body">' + esc(analysis.content) + '</pre>' +
|
|
'</details>';
|
|
if (onRegenerate) {
|
|
const regen = document.getElementById('pf-regen');
|
|
if (regen) {
|
|
regen.addEventListener('click', (e) => {
|
|
// The button lives inside <summary>; clicking it would
|
|
// normally toggle the <details> open/closed. Suppress the
|
|
// default toggle and the bubble so only our regen runs.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onRegenerate();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runAnalysis(pie, enriched) {
|
|
const out = document.getElementById('pf-analysis');
|
|
// First-run click is on pf-analyze; the regenerate path is pf-regen
|
|
// inside the details summary. Either may be the live trigger.
|
|
const btn = document.getElementById('pf-analyze') ||
|
|
document.getElementById('pf-regen');
|
|
out.hidden = false;
|
|
out.innerHTML = '<div class="empty">generating…</div>';
|
|
if (btn) 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;
|
|
}
|
|
}
|
|
|
|
// The language toggle's data-lang attribute is the user's LIVE
|
|
// pick — newer than user.lang in the DB if the user toggled and
|
|
// hit Generate/Regenerate before the toggle-PATCH committed.
|
|
// Backend prefers this value if provided (see universe.py).
|
|
const langPill = document.getElementById('lang-toggle');
|
|
const userLang = (langPill && langPill.dataset.lang) || 'en';
|
|
|
|
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',
|
|
lang: userLang,
|
|
}),
|
|
});
|
|
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);
|
|
// Pass the regenerate callback so the in-details "Regenerate"
|
|
// button shows up on the freshly-rendered analysis too.
|
|
showAnalysis(data, { open: true }, () => runAnalysis(pie, enriched));
|
|
} catch (e) {
|
|
out.innerHTML = '<div class="pf-warn">' + esc(e.message) + '</div>';
|
|
} finally {
|
|
// The original button may have been replaced by showAnalysis →
|
|
// re-fetch its handle (or null if neither id is on the page now).
|
|
const liveBtn = document.getElementById('pf-analyze') ||
|
|
document.getElementById('pf-regen');
|
|
if (liveBtn) liveBtn.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);
|
|
})();
|