'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]) => `
${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) => `
${v >= 10 ? Math.round(v) : ''}
${String(h).padStart(2, '0')}
`).join(''); } function renderReality(t, m) { const share = m.actual_spend ? t.lost_spend / m.actual_spend : null; $('reality').innerHTML = `
Modelled lost spend
${money2.format(t.lost_spend)}
Actual account spend
${money2.format(m.actual_spend)}
Lost as share of actual
${share === null ? '—' : pct(share)}
Guardrails
${t.capped} hit the ${m.cap_multiple}× budget cap, ${t.unreliable} unpriced for too little in-budget time
`; } function renderChips() { const counts = {}; const src = (state.mode === 'campaign' && state.data.totals.days > 1) ? state.data.recurring : state.data.campaigns; for (const c of src) counts[c.dx] = (counts[c.dx] || 0) + 1; const order = ['Structurally underfunded', 'Exhausts early', 'Pacing thrash', 'Evening cap', 'Intermittent', 'Healthy', 'Mostly paused']; const names = state.data.diagnoses.slice().sort((a, b) => order.indexOf(a) - order.indexOf(b)); $('chips').innerHTML = names.map((n) => ` `).join(''); } function renderQuality(checks, invariants) { const rows = checks.map((c) => `
${c.status === 'ok' ? 'OK' : c.status === 'review' ? 'Review' : 'Fail'}
${escapeHtml(c.name)}
${escapeHtml(c.value)}
${escapeHtml(c.note)}
`); const inv = invariants && invariants.failed.length ? `
Fail
Internal consistency
${invariants.failed.length} failed
${escapeHtml(invariants.failed.join(' | '))}
` : `
OK
Internal consistency
${nf.format(invariants ? invariants.checked : 0)} campaigns
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, on the same green-to-red scale as the hour heatmap. */ function heatColor(lostHours, eligibleHours) { const f = eligibleHours > 0 ? Math.min(1, lostHours / eligibleHours) : 0; if (f <= 0.005) return '#16a34a'; const ramp = ['#fff9c4', '#ffecb3', '#ffe0b2', '#ffccbc', '#ffab91', '#ff8a65', '#ef5350', '#dc2626', '#b71c1c']; return ramp[Math.min(ramp.length - 1, Math.floor(f * ramp.length))]; } function dayHeat(series, dates) { return `
${series.map((v, i) => ``).join('')}
`; } function renderHead() { $('thead').innerHTML = COLUMNS.map((c, i) => { const sorted = c.key && state.sort.key === c.key; const arrow = sorted ? (state.sort.dir === -1 ? ' ↓' : ' ↑') : ''; return `
${c.label}${arrow}
`; }).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 += `
` + (grouped ? groupCells(c) : dayCells(c, multiDay)) + '
'; } rows.innerHTML = html; } function dayCells(c, multiDay) { const lost = c.ls == null ? `no budget` : money.format(c.ls) + (c.cap ? ' *' : ''); return `
${escapeHtml(c.c)}
${multiDay ? `
${c.d}
` : ''}
${hrs(c.ib)}
${hrs(c.ob)}
${Math.round(c.sh * 100)}%
${c.em}${c.er !== c.em ? ` /${c.er}` : ''}
${c.f || '—'}
${lost}
${nf1.format(c.sv)}
${escapeHtml(c.dx)}
${c.unt ? `${escapeHtml(c.act.sum)}` : `${escapeHtml(c.act.sum)}`}
`; } function groupCells(r) { const lost = r.lostd == null ? `no budget` : money.format(r.lostd); const trendCls = r.trend === 'worsening' ? 'trend-worse' : r.trend === 'improving' ? 'trend-better' : 'muted'; return `
${escapeHtml(r.c)}
${r.out}/${r.obs}
${hrs(r.runs)}
${hrs(r.mean)}
${hrs(r.max)}
${r.eps}
${dayHeat(r.series, r.dates)}
${r.trend}
${lost}
${nf1.format(r.score)}
${escapeHtml(r.dx)}
${r.unt ? `${escapeHtml(r.act.sum)}` : `${escapeHtml(r.act.sum)}`}
`; } // ------------------------------------------------------------------- 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.

`; } const rows = (a.recent || []).map((r) => ` ${escapeHtml(r[0])} ${ACT_LABEL[r[1]] || r[1]} ${escapeHtml(r[2])} `).join(''); return `

Last action

${escapeHtml(a.sum)}

${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'}.

${rows ? `${rows}
WhenType What changed
${a.n > (a.recent || []).length ? `

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) => `
${k}
${v}
`; const worst = days.find((d) => d.d === r.wd); $('drawer-body').innerHTML = `
${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))}

Day by day

${days.map((d) => ` `).join('')}
DateRunsLost
billable
Paused% lost Outages1st outTimeline 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 || '—'}
0006121824

Money

${cell('Lost spend / day', r.lostd == null ? '—' : money2.format(r.lostd))} ${cell('Lost spend total', r.lost == null ? '—' : money2.format(r.lost))} ${cell('Lost sales total', r.lsa == null ? '—' : money2.format(r.lsa))}

${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}

${worst.eps.map((e) => ``).join('')}
#StartEndDurationBillable
${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) => `
${k}
${v}
`; const budget = c.bg == null ? '—' : money2.format(c.bg); const src = { daily_budget_event: 'from a budget change', budget_rule: 'from a budget rule', perf_report: 'from the performance report', unknown: 'not in the export' }[c.bs]; $('drawer-body').innerHTML = `
${cell('Runs per day', `${hrs(c.ib)}`)} ${cell('Lost per day', `${hrs(c.ob)}`)} ${cell('% of day lost', Math.round(c.sh * 100) + '%')} ${cell('Paused', hrs(c.pa))} ${cell('First ran out', c.f || '—')} ${cell('Recovered', c.l || (c.cl ? 'never' : '—'))} ${cell('Budget-cap hits', c.er)} ${cell('Distinct outages', c.em)}

Timeline

00:0006:0012:0018:0024:00

Money

${cell('Daily budget', budget)} ${cell('Spend rate', c.rt == null ? '—' : money2.format(c.rt) + '/h')} ${cell('Lost spend', c.ls == null ? '—' : money2.format(c.ls))} ${cell('Lost sales', c.lsa == null ? '—' : money2.format(c.lsa))}

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})

${c.eps.map((e) => ``).join('')}
#StartEndDurationBillable
${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(() => {});