Adds an 8-byte HKDF fingerprint of the current pepper to portfolio_sync rows. On fetch, a mismatch surfaces as 410 Gone (distinct from genuine GCM corruption → 500), and the UI silently cleans up the dead row and shows a soft "please re-import" notice instead of a confusing PIN re-prompt. Legacy rows (pepper_fp NULL) are probed optimistically and backfilled on success. Also fixes a latent bug in unwrap(): AESGCM.decrypt args were swapped (ct, nonce instead of nonce, ct), so restore-from-cloud always failed even when the pepper was correct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
537 lines
20 KiB
JavaScript
537 lines
20 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="settings-row__hint" style="margin-bottom:8px;">' +
|
|
'Your previous cloud backup couldn’t be restored on this server. ' +
|
|
'Please re-upload your portfolio to refresh it.' +
|
|
'</div>'
|
|
: '';
|
|
mount.innerHTML =
|
|
'<div class="empty" style="padding:16px;">' +
|
|
notice +
|
|
'No portfolio loaded in this browser. ' +
|
|
'<a href="/settings#import">Import a T212 CSV →</a>' +
|
|
'</div>';
|
|
}
|
|
|
|
// 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:8px; align-items:center;">' +
|
|
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
|
|
'autocomplete="off" placeholder="PIN" ' +
|
|
'style="flex:0 0 140px;">' +
|
|
'<button type="submit">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) {
|
|
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>' +
|
|
'</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>' +
|
|
'</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 => 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);
|
|
})();
|