'use strict';
const $ = (id) => document.getElementById(id);
const ROW_H = 34;
const state = {
data: null,
view: [],
sort: { key: 'sv', dir: -1 },
search: '',
diagnoses: new Set(),
pricedOnly: false,
staleOnly: false,
mode: 'campaign', // 'campaign' (one row per campaign) | 'day'
settings: { roas: '', haircut: 0.7, cap: 3, merge_gap: 5 },
files: [],
};
const nf = new Intl.NumberFormat('en-US');
const nf1 = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf2 = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const money2 = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2 });
const pct = (v) => (v * 100).toFixed(1) + '%';
const DX_CLASS = {
'Structurally underfunded': 'dx-under',
'Exhausts early': 'dx-early',
'Pacing thrash': 'dx-thrash',
'Evening cap': 'dx-evening',
'Intermittent': 'dx-inter',
'Healthy': 'dx-healthy',
'Mostly paused': 'dx-paused',
};
function stage(name) {
for (const s of ['upload', 'loading', 'error', 'dash']) $('stage-' + s).hidden = s !== name;
const showing = name === 'dash';
for (const b of ['btn-csv', 'btn-xlsx', 'btn-reset', 'btn-settings']) $(b).hidden = !showing;
}
function fail(message) {
$('error-text').textContent = message;
stage('error');
}
// ------------------------------------------------------------------ uploads
function renderFileList() {
const ul = $('filelist');
ul.innerHTML = '';
for (const f of state.files) {
const li = document.createElement('li');
li.innerHTML = `${f.kind === 'perf' ? 'performance' : 'history'}${escapeHtml(f.name)}ready`;
ul.appendChild(li);
}
$('btn-analyze').hidden = !state.files.some((f) => f.kind === 'history');
}
async function upload(file, kind) {
// FormData rather than a raw body: the filename travels in the part header,
// so no X-Filename escaping, and the server can stream it to disk instead of
// holding the whole thing in memory.
const form = new FormData();
form.append('file', file);
form.append('kind', kind);
const res = await A.api('/api/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Upload failed');
state.files.push({ name: file.name, kind });
renderFileList();
}
async function acceptFiles(list, kind) {
const files = [...list].filter((f) => /\.(xlsx|xlsm|csv|tsv)$/i.test(f.name) && !f.name.startsWith('~$'));
if (!files.length) {
fail('Those files are not Excel or CSV exports. Look for amazon-ads-history_*.xlsx.');
return;
}
try {
for (const f of files) await upload(f, kind);
} catch (err) {
fail(String(err.message || err));
}
}
// ----------------------------------------------------------------- analysis
async function analyze() {
stage('loading');
const steps = ['Reading the export…', 'Reconstructing budget timelines…',
'Measuring outages…', 'Pricing lost opportunity…'];
let i = 0;
const tick = setInterval(() => { $('loading-text').textContent = steps[++i % steps.length]; }, 900);
try {
const res = await A.api('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.settings),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Analysis failed');
state.data = json;
state.diagnoses.clear();
// Show the panel before rendering: the row virtualiser measures the table's
// height, and a hidden element measures zero.
stage('dash');
render();
} catch (err) {
fail(String(err.message || err));
} finally {
clearInterval(tick);
}
}
// ------------------------------------------------------------------- render
function render() {
const d = state.data;
const m = d.meta, t = d.totals;
const span = m.dates.length > 1 ? `${m.dates[0]} to ${m.dates.at(-1)}` : m.dates[0];
$('subtitle').textContent =
`${m.account} · ${m.marketplace} · ${span} · ${m.files.length} export(s)`;
// Fold the per-campaign action record onto every row so the existing sort
// and filter machinery treats it like any other column.
const acts = d.actions || {};
const blank = { sum: 'not observed', ds: null, unt: false, n: 0, label: '' };
for (const row of d.campaigns) Object.assign(row, { act: acts[row.c] || blank });
for (const row of d.recurring) Object.assign(row, { act: acts[row.c] || blank });
for (const row of [...d.campaigns, ...d.recurring]) {
row.ds = row.act.unt ? Infinity : row.act.ds; // untouched sorts to the top
row.unt = row.act.unt;
}
// A file that could not be read must never disappear quietly.
const skipped = d.skipped || [];
$('skipped').hidden = skipped.length === 0;
$('skipped-list').innerHTML = skipped.map((line) => {
const cut = line.indexOf(': ');
const name = cut > 0 ? line.slice(0, cut) : 'A file';
const why = cut > 0 ? line.slice(cut + 2) : line;
return `
${escapeHtml(name)}${escapeHtml(why)}
`;
}).join('');
$('grain').hidden = t.days < 2;
renderAnswer(t, m);
renderKpis(t, m);
renderCurve(d.curve, t);
renderReality(t, m);
renderChips();
renderQuality(d.quality, d.invariants);
applyFilters();
}
/** Durations read as "45min" / "2h 31min", never as decimal hours. */
function hrs(v) {
if (v == null) return '—';
const total = Math.round(v * 60);
const h = Math.floor(total / 60), m = total % 60;
if (h === 0) return `${m}min`;
return m ? `${h}h ${m}min` : `${h}h`;
}
function renderAnswer(t, m) {
const a = t.avg_day;
const day = t.days > 1 ? 'day' : `day (${m.dates[0]})`;
$('answer-lede').innerHTML =
`On an average ${day}, one of your campaigns spends ${hrs(a.running)} ` +
`able to run — and ${hrs(a.out)} shut off because it hit its daily budget.` +
(a.paused > 0.05 ? ` A further ${hrs(a.paused)} it was paused, which costs nothing.` : '');
// Same four values as TRACK_COLOR in ppcbudget/payload.py -- the day bar and
// the timeline strips have to agree.
const segs = [
['running', a.running, '#0f9a74', 'Running'],
['out', a.out, '#f2542d', 'Out of budget'],
['paused', a.paused, '#9aa6ad', 'Paused'],
['na', a.na, '#cbdbd4', 'Not yet created'],
].filter(([, v]) => v > 0.01);
// The two recessive states are pale enough that the default white label
// disappears on them, so those segments get dark text instead.
$('daybar').innerHTML = segs.map(([key, v, color, label]) =>
`${(v / 24) > 0.13 ? hrs(v) : ''}`).join('');
$('daykeys').innerHTML = segs.map(([, v, color, label]) =>
`
${label} ${hrs(v)}
`).join('');
const perDay = t.per_day;
const priced = t.priced < t.campaigns
? ` Priced across only the ${nf.format(t.priced)} campaigns whose budget appears in the export, that is ` +
`${money.format(perDay.lost_spend)} of spend you could not place per day` +
` — the true figure is higher, since ${nf.format(t.campaigns - t.priced)} campaigns have no budget to price against.`
: ` That works out at ${money.format(perDay.lost_spend)} of spend you could not place per day.`;
$('answer-account').innerHTML =
`Across all ${nf.format(t.distinct)} campaigns that is ${nf1.format(perDay.out_hours)} campaign-hours ` +
`of lost opportunity every single day.${priced}`;
}
function kpi(label, value, note, alarm) {
return `
${label}
${value}
${note}
`;
}
function renderKpis(t, m) {
const unit = t.days > 1 ? 'campaign-days' : 'campaigns';
$('kpis').innerHTML = [
kpi('Campaigns scored', nf.format(t.distinct),
t.days > 1 ? `${nf.format(t.campaigns)} campaign-days over ${t.days} days`
: 'had budget-state changes'),
kpi('Lost hours per day', nf1.format(t.per_day.out_hours),
'campaign-hours shut off, account-wide', true),
kpi('Average campaign runs', hrs(t.avg_day.running), `of 24 h — then it hits its budget`),
kpi('Lose over 12 h a day', nf.format(t.over_12h), `${unit} more than half the day dark`, true),
kpi('Ended the day out', nf.format(t.ended_oob),
t.campaigns ? `${Math.round(100 * t.ended_oob / t.campaigns)}% of ${unit}` : '', true),
kpi('Repeat outages', nf.format(t.flapping), `${unit} with 3 or more outages`),
kpi('Lost spend per day', money.format(t.per_day.lost_spend),
`only ${nf.format(t.priced)} of ${nf.format(t.campaigns)} ${unit} priced`),
kpi('Lost sales per day', money.format(t.per_day.lost_sales),
`ROAS ${m.roas.toFixed(2)} × ${Math.round(m.haircut * 100)}% haircut`),
kpi('No action taken', nf.format((state.data.action_summary || {}).untouched || 0),
`campaigns untouched across all ${t.days} day(s)`, true),
].join('');
}
function renderCurve(curve, t) {
const max = Math.max(...curve, 1);
const peak = curve.indexOf(Math.max(...curve.slice(1)));
$('curve-sub').textContent =
`Share of scored campaigns out of budget during each hour. Budgets reset at midnight, then ` +
`coverage decays as campaigns exhaust their cap — peaking at ${nf1.format(Math.max(...curve.slice(1)))}% around ${String(peak).padStart(2, '0')}:00.`;
$('curve').innerHTML = curve.map((v, h) => `
For every campaign the minutes in budget, out of budget, paused and
not-yet-created sum to exactly 1440, the episode durations sum to the out-of-budget total,
and the hourly buckets agree with both — so the chart and the table cannot tell
different stories.
`;
$('quality').innerHTML = rows.join('') + inv;
}
// -------------------------------------------------------------------- table
const BASE_COLUMNS = [
{ key: 'c', label: 'Campaign', cls: 'name' },
{ key: 'ib', label: 'Runs h/day', cls: 'num' },
{ key: 'ob', label: 'Lost h/day', cls: 'num' },
{ key: 'sh', label: '% day lost', cls: 'num' },
{ key: 'em', label: 'Outages', cls: 'num' },
{ key: 'f', label: '1st out', cls: 'num' },
{ key: null, label: 'Timeline 0→24h', cls: 'strip-h' },
{ key: 'ls', label: 'Lost spend', cls: 'num' },
{ key: 'sv', label: 'Severity', cls: 'num' },
{ key: 'dx', label: 'Diagnosis', cls: 'dx' },
{ key: 'ds', label: 'Last action', cls: 'act' },
];
// One row per campaign, averaged across the days loaded. This is the default
// once there is more than one day: eight rows of the same campaign is noise.
const GROUP_COLUMNS = [
{ key: 'c', label: 'Campaign', cls: 'name' },
{ key: 'out', label: 'Days out', cls: 'num' },
{ key: 'runs', label: 'Runs h/day', cls: 'num' },
{ key: 'mean', label: 'Lost h/day', cls: 'num' },
{ key: 'max', label: 'Worst day', cls: 'num' },
{ key: 'eps', label: 'Outages', cls: 'num' },
{ key: null, label: 'Lost h by day', cls: 'strip-h' },
{ key: 'slope', label: 'Trend', cls: 'trendcell' },
{ key: 'lostd', label: 'Lost $/day', cls: 'num' },
{ key: 'score', label: 'Chronic', cls: 'num' },
{ key: 'dx', label: 'Diagnosis', cls: 'dx' },
{ key: 'ds', label: 'Last action', cls: 'act' },
];
let COLUMNS = BASE_COLUMNS;
function setColumns(mode, multi) {
const wrap = document.querySelector('.tablewrap');
if (mode === 'campaign' && multi) {
COLUMNS = GROUP_COLUMNS;
} else {
COLUMNS = multi
? [BASE_COLUMNS[0], { key: 'd', label: 'Date', cls: 'date' }, ...BASE_COLUMNS.slice(1)]
: BASE_COLUMNS;
}
wrap.classList.toggle('grouped', mode === 'campaign' && multi);
wrap.classList.toggle('multi', mode === 'day' && multi);
}
/** Colour for a day: nothing lost is the in-budget green, and everything above
* that rides one warm ramp. A single hue getting steadily darker, rather than
* the yellow-orange-red rainbow it replaced -- with one hue, "worse" is
* readable from the depth of the colour alone. */
function heatColor(lostHours, eligibleHours) {
const f = eligibleHours > 0 ? Math.min(1, lostHours / eligibleHours) : 0;
if (f <= 0.005) return '#0f9a74';
const ramp = ['#fdece7', '#fbd7cd', '#f9bfae', '#f7a58c', '#f4886a',
'#f2542d', '#d8431f', '#b53617', '#8f2810'];
return ramp[Math.min(ramp.length - 1, Math.floor(f * ramp.length))];
}
function dayHeat(series, dates) {
return `
`;
}).join('');
}
function applyFilters() {
const q = state.search.toLowerCase();
const days = state.data.totals.days;
const grouped = state.mode === 'campaign' && days > 1;
const source = grouped ? state.data.recurring : state.data.campaigns;
const lostKey = grouped ? 'lostd' : 'ls';
state.view = source.filter((c) => {
if (q && !c.c.toLowerCase().includes(q)) return false;
if (state.diagnoses.size && !state.diagnoses.has(c.dx)) return false;
if (state.pricedOnly && c[lostKey] == null) return false;
if (state.staleOnly && !c.unt) return false;
return true;
});
// Sort key may not exist in the other grain; fall back to its default.
let { key, dir } = state.sort;
if (!COLUMNS.some((col) => col.key === key)) {
key = grouped ? 'score' : 'sv';
dir = -1;
state.sort = { key, dir };
}
state.view.sort((a, b) => {
const x = a[key], y = b[key];
if (x == null && y == null) return 0;
if (x == null) return 1; // unpriced/unknown always sink
if (y == null) return -1;
if (typeof x === 'string') return dir * x.localeCompare(y);
return dir * (x - y);
});
const total = source.length;
const hours = state.view.reduce((s, c) => s + (grouped ? c.tot : c.ob), 0);
const noun = grouped ? 'campaigns' : (days > 1 ? 'campaign-days' : 'campaigns');
$('table-title').textContent = state.view.length === total
? `All ${nf.format(total)} ${noun}` + (grouped ? ` across ${days} days` : '')
: `${nf.format(state.view.length)} of ${nf.format(total)} ${noun}`;
$('table-sub').innerHTML = grouped
? `One row per campaign, averaged over ${days} days. Runs h/day is how long it could ` +
`actually spend; Lost h/day is how long it sat shut off after hitting its budget. ` +
`The strip shows one cell per day, newest right. This selection loses ` +
`${nf1.format(hours)} campaign-hours in total. Click any row for the day-by-day breakdown.`
: `Runs h/day is how long the campaign could actually spend; Lost h/day is how long ` +
`it sat shut off after hitting its budget. Together with paused time they make up the 24-hour day. ` +
`This selection loses ${nf1.format(hours)} campaign-hours. Click any row for its timeline and outages.`;
setColumns(state.mode, days > 1);
renderHead();
$('spacer').style.height = (state.view.length * ROW_H) + 'px';
$('tbody').scrollTop = 0;
drawRows(true);
}
let lastWindow = '';
function drawRows(force) {
const body = $('tbody'), rows = $('rows');
if (!state.view.length) {
rows.innerHTML = '
No campaigns match those filters.
';
lastWindow = '';
return;
}
const multiDay = state.data.totals.days > 1;
const top = body.scrollTop;
// Fall back to a sensible window if the panel has not been laid out yet.
const viewportH = body.clientHeight || 620;
const first = Math.max(0, Math.floor(top / ROW_H) - 6);
const last = Math.min(state.view.length, Math.ceil((top + viewportH) / ROW_H) + 6);
// Scrolling within the already-rendered window needs no DOM work.
const key = first + ':' + last;
if (!force && key === lastWindow) return;
lastWindow = key;
const grouped = state.mode === 'campaign' && multiDay;
let html = '';
for (let i = first; i < last; i++) {
const c = state.view[i];
html += `
`;
}
// ------------------------------------------------------------------- drawer
const ACT_LABEL = {
budget: 'Budget', placement: 'Placement', strategy: 'Strategy', bid: 'Bid',
targeting: 'Targeting', structure: 'Structure', status: 'Status', portfolio: 'Portfolio',
};
/** "What has anyone actually done to this campaign?" -- shown in the drawer. */
function actionSection(name) {
const a = (state.data.actions || {})[name];
if (!a) return '';
const win = a.win || state.data.totals.days;
const plural = win === 1 ? 'day' : 'days';
if (a.unt) {
return `
Last action
No action taken in the entire ${win}-${plural} analysis period.
No budget, bid, placement, bidding-strategy, targeting or status change was
recorded for this campaign between ${state.data.meta.dates[0]} and
${state.data.meta.dates.at(-1)}. Amazon's own out-of-budget switching is not counted
as an action — that is the pacing engine, not a person.
${cell('Runs per day', `${hrs(r.runs)}`)}
${cell('Lost per day', `${hrs(r.mean)}`)}
${cell('Worst day', `${hrs(r.max)}
${r.wd}
`)}
${cell('Days it ran out', `${r.out} of ${r.obs}`)}
${cell('Longest run of bad days', `${r.smax}`)}
${cell('Total lost', hrs(r.tot))}
${cell('Outages', r.eps)}
${cell('Chronic score', nf1.format(r.score))}
${r.lostd == null
? 'No daily budget for this campaign appears in the export, so there is no honest way to price it. Add a performance report to fill this in.'
: `Priced from the budget observed in the export, at ROAS ${m.roas.toFixed(2)} with a ${Math.round(m.haircut * 100)}% haircut.`}
${worst ? `
Worst day in detail — ${r.wd}
#
Start
End
Duration
Billable
${worst.eps.map((e) => `
${e.i}
${e.s}
${e.e}
${hrs(e.m / 60)}
${e.a === e.m ? 'same' : hrs(e.a / 60)}
`).join('')}
Duration is wall-clock. Billable excludes any
minutes the campaign was paused during the outage — a paused campaign forgoes nothing
to its budget, so only billable minutes count as lost. "Same" means it was never paused.
Budget ${src}.${c.cap ? ` Lost spend hit the ${m.cap_multiple}× cap, so the true figure could be higher — or demand simply was not there.` : ''}
${c.ls == null && c.bs === 'unknown' ? ' Without an observed budget there is no honest way to price this campaign, so nothing is shown rather than a zero. Add a performance report to fill it in.' : ''}
Outages (${c.eps.length})
#
Start
End
Duration
Billable
${c.eps.map((e) => `
${e.i}
${e.s}
${e.e}
${hrs(e.m / 60)}
${e.a === e.m ? 'same' : hrs(e.a / 60)}
`).join('')}
Duration is wall-clock. Billable excludes any
minutes the campaign was paused during the outage — a paused campaign forgoes nothing to its
budget, so only billable minutes count as lost. "Same" means it was never paused.
${actionSection(c.c)}`;
$('drawer').hidden = false;
$('scrim').hidden = false;
}
function closeDrawer() { $('drawer').hidden = true; $('scrim').hidden = true; }
// -------------------------------------------------------------------- utils
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (ch) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
}
// -------------------------------------------------------------------- wiring
const dz = $('dropzone');
['dragenter', 'dragover'].forEach((e) =>
dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.add('over'); }));
['dragleave', 'drop'].forEach((e) =>
dz.addEventListener(e, (ev) => { ev.preventDefault(); dz.classList.remove('over'); }));
dz.addEventListener('drop', (ev) => acceptFiles(ev.dataTransfer.files, 'history'));
dz.addEventListener('click', () => $('file-input').click());
dz.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); $('file-input').click(); }
});
$('pick').addEventListener('click', (ev) => { ev.stopPropagation(); $('file-input').click(); });
$('file-input').addEventListener('change', (ev) => acceptFiles(ev.target.files, 'history'));
$('pick-perf').addEventListener('click', () => $('perf-input').click());
$('perf-input').addEventListener('change', (ev) => acceptFiles(ev.target.files, 'perf'));
window.addEventListener('dragover', (e) => e.preventDefault());
window.addEventListener('drop', (e) => e.preventDefault());
$('btn-analyze').addEventListener('click', analyze);
$('btn-error-back').addEventListener('click', () => stage(state.data ? 'dash' : 'upload'));
$('btn-signout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
location.replace('/login');
});
$('btn-reset').addEventListener('click', async () => {
await A.api('/api/clear', { method: 'POST' });
state.data = null; state.files = []; state.diagnoses.clear();
state.search = ''; $('search').value = ''; state.pricedOnly = false; $('only-priced').checked = false;
state.staleOnly = false; $('only-stale').checked = false;
renderFileList();
stage('upload');
});
// Fetched rather than navigated to, so a refusal renders in the UI instead of
// dumping JSON into a new tab. The session cookie rides along either way.
async function download(format) {
const res = await A.api('/api/export?format=' + format);
if (!res.ok) {
fail((await res.json().catch(() => ({}))).error || 'That export failed.');
return;
}
const name = /filename="([^"]+)"/.exec(res.headers.get('content-disposition') || '');
const url = URL.createObjectURL(await res.blob());
const a = document.createElement('a');
a.href = url;
a.download = name ? name[1] : 'ppc-budget-report.' + format;
document.body.appendChild(a);
a.click();
a.remove();
// Revoked on a later tick: Safari has not finished reading it synchronously.
setTimeout(() => URL.revokeObjectURL(url), 30000);
}
$('btn-csv').addEventListener('click', () => download('csv'));
$('btn-xlsx').addEventListener('click', () => download('xlsx'));
$('search').addEventListener('input', (ev) => { state.search = ev.target.value; applyFilters(); });
$('only-priced').addEventListener('change', (ev) => { state.pricedOnly = ev.target.checked; applyFilters(); });
$('only-stale').addEventListener('change', (ev) => { state.staleOnly = ev.target.checked; applyFilters(); });
$('chips').addEventListener('click', (ev) => {
const chip = ev.target.closest('.chip');
if (!chip) return;
const dx = chip.dataset.dx;
const on = !state.diagnoses.has(dx);
on ? state.diagnoses.add(dx) : state.diagnoses.delete(dx);
// Toggle in place rather than re-rendering: the counts never change, and
// replacing the node would detach the element mid-click.
chip.setAttribute('aria-pressed', String(on));
applyFilters();
});
$('thead').addEventListener('click', (ev) => {
const el = ev.target.closest('[data-col]');
if (!el) return;
const col = COLUMNS[+el.dataset.col];
if (!col.key) return;
state.sort = state.sort.key === col.key
? { key: col.key, dir: -state.sort.dir }
: { key: col.key, dir: col.key === 'c' || col.key === 'dx' || col.key === 'f' ? 1 : -1 };
applyFilters();
});
// Synchronous: rendering ~30 rows is sub-millisecond, and rAF does not fire in a
// backgrounded tab, which would leave the table frozen mid-scroll.
$('tbody').addEventListener('scroll', () => drawRows(), { passive: true });
window.addEventListener('resize', () => { if (state.data) drawRows(true); });
$('rows').addEventListener('click', (ev) => {
const row = ev.target.closest('.row');
if (!row) return;
const item = state.view[+row.dataset.i];
const grouped = state.mode === 'campaign' && state.data.totals.days > 1;
grouped ? openCampaignDrawer(item) : openDrawer(item);
});
$('grain').addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-mode]');
if (!btn || btn.dataset.mode === state.mode) return;
state.mode = btn.dataset.mode;
for (const b of $('grain').querySelectorAll('[data-mode]')) {
b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
}
applyFilters();
});
$('drawer-close').addEventListener('click', closeDrawer);
$('scrim').addEventListener('click', closeDrawer);
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeDrawer(); });
$('btn-settings').addEventListener('click', () => {
$('set-roas').value = state.settings.roas;
$('set-haircut').value = state.settings.haircut;
$('set-cap').value = state.settings.cap;
$('set-gap').value = state.settings.merge_gap;
$('settings').showModal();
});
$('settings').addEventListener('close', (ev) => {
if ($('settings').returnValue !== 'apply') return;
state.settings = {
roas: $('set-roas').value ? Number($('set-roas').value) : '',
haircut: Number($('set-haircut').value),
cap: Number($('set-cap').value),
merge_gap: Number($('set-gap').value),
};
analyze();
});
// Pick up whatever this account already has loaded. Routed through A.api so an
// expired session redirects to the sign-in page rather than silently rendering
// an empty dashboard.
A.api('/api/state').then((r) => r.json()).then((s) => {
if (s.user) {
$('whoami').textContent = s.user.name || s.user.email;
$('whoami').title = s.user.email;
$('link-admin').hidden = !s.user.is_admin;
}
state.files = s.history.map((n) => ({ name: n, kind: 'history' }));
if (s.perf) state.files.push({ name: s.perf, kind: 'perf' });
renderFileList();
if (state.files.some((f) => f.kind === 'history')) analyze();
}).catch(() => {});