'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) { const body = await file.arrayBuffer(); const res = await fetch('/api/upload', { method: 'POST', headers: { 'X-Filename': encodeURIComponent(file.name).replace(/%20/g, ' '), 'X-Kind': kind }, body, }); 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 fetch('/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; } $('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.` : ''); const segs = [ ['running', a.running, '#16a34a', 'Running'], ['out', a.out, '#dc2626', 'Out of budget'], ['paused', a.paused, '#9ca3af', 'Paused'], ['na', a.na, '#e5e7eb', 'Not yet created'], ].filter(([, v]) => v > 0.01); $('daybar').innerHTML = segs.map(([, v, color, label]) => `${(v / 24) > 0.13 ? hrs(v) : ''}`).join(''); $('daykeys').innerHTML = segs.map(([, v, color, label]) => `
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.
${escapeHtml(a.label || '')} — ${escapeHtml(a.at || '')}. ${a.n} change${a.n === 1 ? '' : 's'} in the ${win}-${plural} window across ${(a.cats || []).map((c) => ACT_LABEL[c] || c).join(', ') || 'no categories'}.
| When | Type | What changed |
|---|
Showing the ${a.recent.length} most recent of ${a.n} changes.
` : ''}` : ''}`; } /** Day-by-day view of one campaign: the answer to "what happened each day?" */ function openCampaignDrawer(r) { const m = state.data.meta; const days = state.data.campaigns .filter((c) => c.c === r.c) .sort((a, b) => a.d.localeCompare(b.d)); $('drawer-title').textContent = r.c; const ract = (state.data.actions || {})[r.c]; $('drawer-sub').innerHTML = `${r.obs} days · ${escapeHtml(r.dx)} · ran out on ${r.out} of ${r.obs} days · trend ${r.trend}` + (ract ? ` · ${escapeHtml(ract.sum)}` : ''); const cell = (k, v) => `| Date | Runs | Lost billable | Paused | % lost | Outages | 1st out | Timeline 0→24h |
|---|---|---|---|---|---|---|---|
| ${d.d} | ${hrs(d.ib)} | ${hrs(d.ob)} | ${d.pa > 0.005 ? hrs(d.pa) : '—'} | ${Math.round(d.sh * 100)}% | ${d.em} | ${d.f || '—'} |
${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 ? `| # | Start | End | Duration | Billable |
|---|---|---|---|---|
| ${e.i} | ${e.s} | ${e.e} | ${hrs(e.m / 60)} | ${e.a === e.m ? 'same' : hrs(e.a / 60)} |
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(r.c)}`; $('drawer').hidden = false; $('scrim').hidden = false; } function openDrawer(c) { const m = state.data.meta; $('drawer-title').textContent = c.c; const cact = (state.data.actions || {})[c.c]; $('drawer-sub').innerHTML = `${c.d} · ${escapeHtml(c.dx)} · confidence: ${c.cf.replace('_', ' ')}` + (c.un ? ` (±${nf2.format(c.un)} h from a repaired gap)` : '') + (cact ? ` · ${escapeHtml(cact.sum)}` : ''); const cell = (k, v) => `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.' : ''}
| # | Start | End | Duration | Billable |
|---|---|---|---|---|
| ${e.i} | ${e.s} | ${e.e} | ${hrs(e.m / 60)} | ${e.a === e.m ? 'same' : hrs(e.a / 60)} |
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-reset').addEventListener('click', async () => { await fetch('/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'); }); $('btn-csv').addEventListener('click', () => { location.href = '/api/export?format=csv'; }); $('btn-xlsx').addEventListener('click', () => { location.href = '/api/export?format=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 anything preloaded from data/ on startup. fetch('/api/state').then((r) => r.json()).then((s) => { 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(() => {});