Initial campaign daily report

- 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
This commit is contained in:
2026-09-10 16:39:50 +08:00
commit 12ca4a4af7
6 changed files with 30531 additions and 0 deletions
+641
View File
@@ -0,0 +1,641 @@
<!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>