- index.html: table + trend chart, powered by Chart.js - update.sh: fetches today's tab from source Google Sheet (falls back up to 7 days) - schedule.conf: refresh every 24h - data.json / history.json: initial snapshot from sheet 9/9 so the page has data before the first scheduled run
642 lines
20 KiB
HTML
642 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Campaign Daily Report</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1420;
|
||
--panel: #171d2b;
|
||
--panel-2: #1e2537;
|
||
--border: #2a3145;
|
||
--text: #e6ebf5;
|
||
--muted: #8892a6;
|
||
--accent: #4c8bf5;
|
||
--good: #22c55e;
|
||
--bad: #ef4444;
|
||
--warn: #f59e0b;
|
||
--radius: 10px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
min-height: 100vh;
|
||
}
|
||
header {
|
||
padding: 24px 32px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-top: 10px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
.meta .chip {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
padding: 4px 12px;
|
||
}
|
||
.meta .chip b { color: var(--text); font-weight: 500; }
|
||
main { padding: 20px 32px 40px; }
|
||
.cards {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 14px 16px;
|
||
}
|
||
.card .label {
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
}
|
||
.card .value {
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
margin-top: 4px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.card .sub {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
}
|
||
.card.good .value { color: var(--good); }
|
||
.card.bad .value { color: var(--bad); }
|
||
.charts {
|
||
display: grid;
|
||
grid-template-columns: 2fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 24px;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.charts { grid-template-columns: 1fr; }
|
||
}
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 16px;
|
||
}
|
||
.panel h2 {
|
||
margin: 0 0 12px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.chart-wrap { position: relative; height: 300px; }
|
||
.chart-wrap.short { height: 240px; }
|
||
|
||
.table-panel { padding: 16px; }
|
||
.table-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
.table-header h2 { margin: 0; font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||
input[type="search"], select {
|
||
background: var(--panel-2);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 7px 10px;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
input[type="search"]:focus, select:focus { border-color: var(--accent); }
|
||
input[type="search"] { min-width: 220px; }
|
||
.table-scroll {
|
||
overflow: auto;
|
||
max-height: 640px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
}
|
||
thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--panel-2);
|
||
z-index: 2;
|
||
text-align: left;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
thead th:hover { color: var(--accent); }
|
||
thead th .sort-arrow { color: var(--muted); font-size: 10px; margin-left: 4px; }
|
||
thead th.sorted .sort-arrow { color: var(--accent); }
|
||
tbody td {
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
tbody tr:hover { background: rgba(76, 139, 245, 0.06); }
|
||
td.num { text-align: right; }
|
||
td.status-active { color: var(--good); }
|
||
td.status-paused, td.status-pause { color: var(--warn); }
|
||
td.status-stopped, td.status-closed { color: var(--bad); }
|
||
.pager {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 12px;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
.pager button {
|
||
background: var(--panel-2);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 6px 12px;
|
||
cursor: pointer;
|
||
margin: 0 3px;
|
||
}
|
||
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.pager button:not(:disabled):hover { border-color: var(--accent); color: var(--accent); }
|
||
.empty {
|
||
text-align: center;
|
||
color: var(--muted);
|
||
padding: 40px 20px;
|
||
}
|
||
footer {
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
padding: 20px;
|
||
}
|
||
a { color: var(--accent); text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Campaign Daily Report</h1>
|
||
<div class="meta" id="meta">
|
||
<span class="chip">Loading…</span>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="cards" id="cards"></section>
|
||
|
||
<section class="charts">
|
||
<div class="panel">
|
||
<h2>Daily Trend</h2>
|
||
<div class="chart-wrap"><canvas id="trendChart"></canvas></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Status Breakdown</h2>
|
||
<div class="chart-wrap short"><canvas id="statusChart"></canvas></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="charts">
|
||
<div class="panel">
|
||
<h2>Top 10 Campaigns by Revenue</h2>
|
||
<div class="chart-wrap"><canvas id="topCampaignsChart"></canvas></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>Top Geos by PB Installs</h2>
|
||
<div class="chart-wrap short"><canvas id="geoChart"></canvas></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel table-panel">
|
||
<div class="table-header">
|
||
<h2>All Campaigns</h2>
|
||
<div class="controls">
|
||
<input type="search" id="search" placeholder="Search campaigns, PKG, geo, AM…">
|
||
<select id="statusFilter"><option value="">All statuses</option></select>
|
||
<select id="pageSize">
|
||
<option value="25">25 / page</option>
|
||
<option value="50" selected>50 / page</option>
|
||
<option value="100">100 / page</option>
|
||
<option value="500">500 / page</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="table-scroll">
|
||
<table id="dataTable">
|
||
<thead><tr id="theadRow"></tr></thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pager">
|
||
<span id="pageInfo"></span>
|
||
<div>
|
||
<button id="prev">Prev</button>
|
||
<button id="next">Next</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<footer>
|
||
Source:
|
||
<a href="https://docs.google.com/spreadsheets/d/1t2Sz2W6845HbS1ZGJN1iuaj9cgjPFHt36U1fK938jT8/edit" target="_blank" rel="noopener">Google Sheet</a>
|
||
· auto-refreshed once per day by AgentStage
|
||
</footer>
|
||
|
||
<script>
|
||
const NUMERIC_COLS = new Set([
|
||
'Payin', 'Daily Cap', 'Channel Cap', 'Valid Click', 'PB Installs',
|
||
'Revenue', 'Cost', 'Gross Profit', 'RT Reject Installs',
|
||
'CVR', '%RT Reject Install', '%Gross Profit'
|
||
]);
|
||
const PERCENT_COLS = new Set(['CVR', '%RT Reject Install', '%Gross Profit']);
|
||
const MONEY_COLS = new Set(['Payin', 'Revenue', 'Cost', 'Gross Profit']);
|
||
|
||
const state = {
|
||
data: null,
|
||
history: null,
|
||
rows: [],
|
||
filtered: [],
|
||
sortKey: null,
|
||
sortDir: 1,
|
||
page: 0,
|
||
pageSize: 50,
|
||
charts: {},
|
||
};
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const el = (tag, attrs = {}, ...kids) => {
|
||
const n = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs)) {
|
||
if (k === 'class') n.className = v;
|
||
else if (k === 'html') n.innerHTML = v;
|
||
else n.setAttribute(k, v);
|
||
}
|
||
for (const kid of kids) n.append(kid instanceof Node ? kid : document.createTextNode(kid));
|
||
return n;
|
||
};
|
||
|
||
const fmt = {
|
||
int: (n) => (n == null || isNaN(n)) ? '—' : Math.round(n).toLocaleString('en-US'),
|
||
num: (n, d = 2) => (n == null || isNaN(n)) ? '—' : Number(n).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }),
|
||
money: (n) => (n == null || isNaN(n)) ? '—' : '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||
pct: (n) => (n == null || isNaN(n)) ? '—' : (Number(n) * 100).toFixed(2) + '%',
|
||
compact: (n) => {
|
||
if (n == null || isNaN(n)) return '—';
|
||
const abs = Math.abs(n);
|
||
if (abs >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||
if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||
return n.toFixed(2);
|
||
},
|
||
};
|
||
|
||
function toNum(x) {
|
||
if (x == null) return 0;
|
||
const s = String(x).replace(/[,$%]/g, '').trim();
|
||
if (!s) return 0;
|
||
const n = parseFloat(s);
|
||
return isNaN(n) ? 0 : n;
|
||
}
|
||
|
||
function formatCell(col, val) {
|
||
if (val === '' || val == null) return '—';
|
||
if (PERCENT_COLS.has(col)) return fmt.pct(toNum(val));
|
||
if (MONEY_COLS.has(col)) return fmt.money(toNum(val));
|
||
if (NUMERIC_COLS.has(col)) return fmt.int(toNum(val));
|
||
return val;
|
||
}
|
||
|
||
async function load() {
|
||
try {
|
||
const [dataResp, histResp] = await Promise.all([
|
||
fetch('data.json?_=' + Date.now()),
|
||
fetch('history.json?_=' + Date.now()).catch(() => null),
|
||
]);
|
||
if (!dataResp.ok) throw new Error('data.json ' + dataResp.status);
|
||
state.data = await dataResp.json();
|
||
state.history = (histResp && histResp.ok) ? await histResp.json() : [];
|
||
state.rows = state.data.rows || [];
|
||
state.filtered = state.rows.slice();
|
||
renderAll();
|
||
} catch (err) {
|
||
$('#meta').innerHTML = '<span class="chip" style="color:var(--bad)">Failed to load data: ' + err.message + '. update.sh may not have run yet.</span>';
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function renderAll() {
|
||
renderMeta();
|
||
renderCards();
|
||
renderTrendChart();
|
||
renderStatusChart();
|
||
renderTopCampaigns();
|
||
renderGeoChart();
|
||
renderStatusFilter();
|
||
renderHeader();
|
||
renderRows();
|
||
}
|
||
|
||
function renderMeta() {
|
||
const d = state.data;
|
||
const bits = [
|
||
`<span class="chip">Data as of <b>${d.sheet_name}</b></span>`,
|
||
`<span class="chip"><b>${fmt.int(d.row_count)}</b> campaigns</span>`,
|
||
`<span class="chip">Fetched <b>${d.fetched_at}</b></span>`,
|
||
`<span class="chip"><b>${state.history.length}</b> days tracked</span>`,
|
||
];
|
||
$('#meta').innerHTML = bits.join('');
|
||
}
|
||
|
||
function renderCards() {
|
||
const t = state.data.totals || {};
|
||
const cards = [
|
||
{ label: 'Revenue', value: fmt.money(t['Revenue']), klass: '' },
|
||
{ label: 'Cost', value: fmt.money(t['Cost']), klass: '' },
|
||
{ label: 'Gross Profit', value: fmt.money(t['Gross Profit']), klass: (t['Gross Profit'] || 0) >= 0 ? 'good' : 'bad' },
|
||
{ label: 'GP Rate', value: fmt.pct(t['%Gross Profit']), klass: (t['%Gross Profit'] || 0) >= 0 ? 'good' : 'bad' },
|
||
{ label: 'PB Installs', value: fmt.int(t['PB Installs']) },
|
||
{ label: 'Valid Clicks', value: fmt.int(t['Valid Click']) },
|
||
{ label: 'CVR', value: fmt.pct(t['CVR']) },
|
||
{ label: 'RT Reject Rate', value: fmt.pct(t['%RT Reject Install']) },
|
||
];
|
||
const wrap = $('#cards');
|
||
wrap.innerHTML = '';
|
||
for (const c of cards) {
|
||
const n = el('div', { class: 'card ' + (c.klass || '') });
|
||
n.append(el('div', { class: 'label' }, c.label));
|
||
n.append(el('div', { class: 'value' }, c.value));
|
||
wrap.append(n);
|
||
}
|
||
}
|
||
|
||
function renderTrendChart() {
|
||
const ctx = document.getElementById('trendChart').getContext('2d');
|
||
const labels = state.history.map(h => h.sheet_name);
|
||
const rev = state.history.map(h => (h.totals && h.totals['Revenue']) || 0);
|
||
const cost = state.history.map(h => (h.totals && h.totals['Cost']) || 0);
|
||
const gp = state.history.map(h => (h.totals && h.totals['Gross Profit']) || 0);
|
||
if (state.charts.trend) state.charts.trend.destroy();
|
||
|
||
if (labels.length === 0) {
|
||
const c = ctx.canvas;
|
||
ctx.clearRect(0, 0, c.width, c.height);
|
||
ctx.fillStyle = '#8892a6';
|
||
ctx.font = '13px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Trend appears after the second daily run.', c.width / 2, c.height / 2);
|
||
return;
|
||
}
|
||
|
||
state.charts.trend = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{ label: 'Revenue', data: rev, borderColor: '#4c8bf5', backgroundColor: 'rgba(76,139,245,0.15)', tension: 0.25, fill: true, borderWidth: 2 },
|
||
{ label: 'Cost', data: cost, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.10)', tension: 0.25, fill: false, borderWidth: 2 },
|
||
{ label: 'Gross Profit', data: gp, borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.10)', tension: 0.25, fill: false, borderWidth: 2 },
|
||
],
|
||
},
|
||
options: chartOpts({ money: true }),
|
||
});
|
||
}
|
||
|
||
function renderStatusChart() {
|
||
const ctx = document.getElementById('statusChart').getContext('2d');
|
||
const counts = state.data.status_counts || {};
|
||
const labels = Object.keys(counts);
|
||
const values = labels.map(k => counts[k]);
|
||
if (state.charts.status) state.charts.status.destroy();
|
||
if (!labels.length) return;
|
||
|
||
const palette = ['#22c55e', '#f59e0b', '#ef4444', '#4c8bf5', '#a78bfa', '#64748b'];
|
||
state.charts.status = new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels,
|
||
datasets: [{
|
||
data: values,
|
||
backgroundColor: labels.map((_, i) => palette[i % palette.length]),
|
||
borderColor: '#171d2b',
|
||
borderWidth: 2,
|
||
}],
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'right', labels: { color: '#e6ebf5', font: { size: 12 } } },
|
||
tooltip: { callbacks: { label: (c) => `${c.label}: ${c.parsed} (${(c.parsed * 100 / values.reduce((a,b)=>a+b,0)).toFixed(1)}%)` } },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderTopCampaigns() {
|
||
const ctx = document.getElementById('topCampaignsChart').getContext('2d');
|
||
const top = (state.data.top_by_revenue || []).slice(0, 10);
|
||
const labels = top.map(t => truncate(t.campaign, 40));
|
||
if (state.charts.top) state.charts.top.destroy();
|
||
if (!top.length) return;
|
||
|
||
state.charts.top = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{ label: 'Revenue', data: top.map(t => t.revenue), backgroundColor: '#4c8bf5' },
|
||
{ label: 'Cost', data: top.map(t => t.cost), backgroundColor: '#f59e0b' },
|
||
{ label: 'Gross Profit', data: top.map(t => t.gp), backgroundColor: '#22c55e' },
|
||
],
|
||
},
|
||
options: chartOpts({ money: true, horizontal: true }),
|
||
});
|
||
}
|
||
|
||
function renderGeoChart() {
|
||
const ctx = document.getElementById('geoChart').getContext('2d');
|
||
const rowsByGeo = {};
|
||
for (const r of state.rows) {
|
||
const g = (r['Geo'] || 'Unknown').trim() || 'Unknown';
|
||
rowsByGeo[g] = (rowsByGeo[g] || 0) + toNum(r['PB Installs']);
|
||
}
|
||
const entries = Object.entries(rowsByGeo).sort((a,b) => b[1] - a[1]).slice(0, 10);
|
||
if (state.charts.geo) state.charts.geo.destroy();
|
||
if (!entries.length) return;
|
||
|
||
state.charts.geo = new Chart(ctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: entries.map(e => e[0]),
|
||
datasets: [{ label: 'PB Installs', data: entries.map(e => e[1]), backgroundColor: '#a78bfa' }],
|
||
},
|
||
options: chartOpts({ money: false }),
|
||
});
|
||
}
|
||
|
||
function chartOpts({ money, horizontal } = {}) {
|
||
const fmtVal = money ? (v) => '$' + fmt.compact(v) : (v) => fmt.compact(v);
|
||
return {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
indexAxis: horizontal ? 'y' : 'x',
|
||
plugins: {
|
||
legend: { labels: { color: '#e6ebf5', font: { size: 12 } } },
|
||
tooltip: {
|
||
callbacks: {
|
||
label: (c) => {
|
||
const v = c.parsed[horizontal ? 'x' : 'y'];
|
||
return `${c.dataset.label}: ${money ? '$' + fmt.num(v) : fmt.num(v, 0)}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: { color: '#8892a6', callback: horizontal ? fmtVal : undefined },
|
||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||
},
|
||
y: {
|
||
ticks: { color: '#8892a6', callback: horizontal ? undefined : fmtVal },
|
||
grid: { color: 'rgba(255,255,255,0.05)' },
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function truncate(s, n) {
|
||
s = String(s || '');
|
||
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
||
}
|
||
|
||
function renderStatusFilter() {
|
||
const sel = $('#statusFilter');
|
||
const counts = state.data.status_counts || {};
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">All statuses</option>' +
|
||
Object.keys(counts).map(k => `<option value="${k}">${k} (${counts[k]})</option>`).join('');
|
||
sel.value = cur;
|
||
}
|
||
|
||
function renderHeader() {
|
||
const tr = $('#theadRow');
|
||
tr.innerHTML = '';
|
||
for (const col of state.data.columns) {
|
||
const th = el('th', {}, col);
|
||
const arrow = el('span', { class: 'sort-arrow' }, '↕');
|
||
th.append(arrow);
|
||
if (state.sortKey === col) {
|
||
th.classList.add('sorted');
|
||
arrow.textContent = state.sortDir > 0 ? '↑' : '↓';
|
||
}
|
||
th.addEventListener('click', () => {
|
||
if (state.sortKey === col) state.sortDir = -state.sortDir;
|
||
else { state.sortKey = col; state.sortDir = 1; }
|
||
applyFilterSort();
|
||
});
|
||
tr.append(th);
|
||
}
|
||
}
|
||
|
||
function applyFilterSort() {
|
||
const q = ($('#search').value || '').trim().toLowerCase();
|
||
const statusFilter = $('#statusFilter').value;
|
||
let rows = state.rows;
|
||
if (statusFilter) rows = rows.filter(r => (r['Status'] || '') === statusFilter);
|
||
if (q) rows = rows.filter(r => state.data.columns.some(c => String(r[c] || '').toLowerCase().includes(q)));
|
||
|
||
if (state.sortKey) {
|
||
const numeric = NUMERIC_COLS.has(state.sortKey);
|
||
rows = rows.slice().sort((a, b) => {
|
||
const va = a[state.sortKey] || '';
|
||
const vb = b[state.sortKey] || '';
|
||
const cmp = numeric ? (toNum(va) - toNum(vb)) : String(va).localeCompare(String(vb));
|
||
return cmp * state.sortDir;
|
||
});
|
||
}
|
||
state.filtered = rows;
|
||
state.page = 0;
|
||
renderHeader();
|
||
renderRows();
|
||
}
|
||
|
||
function renderRows() {
|
||
const tbody = $('#tbody');
|
||
const cols = state.data.columns;
|
||
const total = state.filtered.length;
|
||
const pageSize = state.pageSize;
|
||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||
if (state.page >= pages) state.page = pages - 1;
|
||
if (state.page < 0) state.page = 0;
|
||
const start = state.page * pageSize;
|
||
const slice = state.filtered.slice(start, start + pageSize);
|
||
|
||
tbody.innerHTML = '';
|
||
if (!slice.length) {
|
||
tbody.innerHTML = `<tr><td colspan="${cols.length}" class="empty">No campaigns match the current filter.</td></tr>`;
|
||
} else {
|
||
const frag = document.createDocumentFragment();
|
||
for (const r of slice) {
|
||
const tr = document.createElement('tr');
|
||
for (const c of cols) {
|
||
const td = document.createElement('td');
|
||
td.textContent = formatCell(c, r[c]);
|
||
if (NUMERIC_COLS.has(c)) td.classList.add('num');
|
||
if (c === 'Status') {
|
||
const s = String(r[c] || '').toLowerCase().replace(/\s+/g, '-');
|
||
if (s) td.classList.add('status-' + s);
|
||
}
|
||
tr.append(td);
|
||
}
|
||
frag.append(tr);
|
||
}
|
||
tbody.append(frag);
|
||
}
|
||
$('#pageInfo').textContent = `Showing ${total ? start + 1 : 0}–${Math.min(start + pageSize, total)} of ${fmt.int(total)}`;
|
||
$('#prev').disabled = state.page <= 0;
|
||
$('#next').disabled = state.page >= pages - 1;
|
||
}
|
||
|
||
$('#search').addEventListener('input', debounce(applyFilterSort, 200));
|
||
$('#statusFilter').addEventListener('change', applyFilterSort);
|
||
$('#pageSize').addEventListener('change', (e) => { state.pageSize = parseInt(e.target.value, 10); renderRows(); });
|
||
$('#prev').addEventListener('click', () => { state.page--; renderRows(); });
|
||
$('#next').addEventListener('click', () => { state.page++; renderRows(); });
|
||
|
||
function debounce(fn, ms) {
|
||
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||
}
|
||
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|