Files
zhangyujia 12ca4a4af7 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
2026-09-10 16:39:50 +08:00

642 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>