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:
@@ -0,0 +1,2 @@
|
|||||||
|
# Transient CSV that update.sh downloads then deletes; ignore in case it stays around.
|
||||||
|
.sheet.csv
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"sheet_name": "9/9",
|
||||||
|
"fetched_at": "2026-09-10 08:19:23 UTC",
|
||||||
|
"row_count": 1130,
|
||||||
|
"totals": {
|
||||||
|
"Valid Click": 354404.0,
|
||||||
|
"PB Installs": 1597.0,
|
||||||
|
"Revenue": 554.84,
|
||||||
|
"Cost": 303.16,
|
||||||
|
"Gross Profit": 251.68,
|
||||||
|
"RT Reject Installs": 0.0,
|
||||||
|
"CVR": 0.004506,
|
||||||
|
"%Gross Profit": 0.453608,
|
||||||
|
"%RT Reject Install": 0.0
|
||||||
|
},
|
||||||
|
"status_counts": {
|
||||||
|
"Active": 738,
|
||||||
|
"Pause": 392
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+641
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pulls today's campaign snapshot from the source Google Sheet and writes
|
||||||
|
# data.json + history.json for the report page.
|
||||||
|
#
|
||||||
|
# The source spreadsheet creates a fresh tab per day named "M/D" (e.g. 9/9).
|
||||||
|
# We fetch via the gviz CSV endpoint with URL-encoded sheet name and walk
|
||||||
|
# back up to 7 days if today's tab hasn't been created yet.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SHEET_ID="1t2Sz2W6845HbS1ZGJN1iuaj9cgjPFHt36U1fK938jT8"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CSV_FILE="$SCRIPT_DIR/.sheet.csv"
|
||||||
|
|
||||||
|
log() { echo "[update.sh $(date -u +%FT%TZ)] $*" >&2; }
|
||||||
|
|
||||||
|
# Compute M/D for a day offset (portable via python3).
|
||||||
|
mmdd_of() {
|
||||||
|
python3 -c "import datetime,sys;d=datetime.date.today()+datetime.timedelta(days=int(sys.argv[1]));print(f'{d.month}/{d.day}')" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fetch a candidate tab into CSV_FILE. Success = first header column is "Order ID"
|
||||||
|
# (gviz silently returns the first tab when the requested name doesn't exist).
|
||||||
|
fetch_sheet() {
|
||||||
|
local name="$1"
|
||||||
|
local encoded="${name//\//%2F}"
|
||||||
|
local url="https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encoded}"
|
||||||
|
if ! curl --max-time 60 -sSL -f "$url" -o "$CSV_FILE"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local first_col
|
||||||
|
first_col=$(head -1 "$CSV_FILE" | tr -d '\r' | cut -d',' -f1 | tr -d '"')
|
||||||
|
[ "$first_col" = "Order ID" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
SHEET_NAME=""
|
||||||
|
for offset in 0 -1 -2 -3 -4 -5 -6 -7; do
|
||||||
|
candidate=$(mmdd_of "$offset")
|
||||||
|
log "trying sheet '$candidate'"
|
||||||
|
if fetch_sheet "$candidate"; then
|
||||||
|
SHEET_NAME="$candidate"
|
||||||
|
log "matched sheet '$candidate'"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$SHEET_NAME" ]; then
|
||||||
|
log "no matching sheet found in the last 8 days"
|
||||||
|
rm -f "$CSV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHEET_NAME="$SHEET_NAME" SCRIPT_DIR="$SCRIPT_DIR" CSV_FILE="$CSV_FILE" python3 <<'PYEOF'
|
||||||
|
import csv, json, os, sys, datetime
|
||||||
|
|
||||||
|
script_dir = os.environ['SCRIPT_DIR']
|
||||||
|
csv_file = os.environ['CSV_FILE']
|
||||||
|
sheet_name = os.environ['SHEET_NAME']
|
||||||
|
data_path = os.path.join(script_dir, 'data.json')
|
||||||
|
hist_path = os.path.join(script_dir, 'history.json')
|
||||||
|
|
||||||
|
with open(csv_file, newline='', encoding='utf-8') as f:
|
||||||
|
raw = list(csv.reader(f))
|
||||||
|
if not raw:
|
||||||
|
print('empty CSV', file=sys.stderr); sys.exit(1)
|
||||||
|
|
||||||
|
headers = [h.strip() for h in raw[0]]
|
||||||
|
records = []
|
||||||
|
for r in raw[1:]:
|
||||||
|
if not any((c or '').strip() for c in r):
|
||||||
|
continue
|
||||||
|
row = {}
|
||||||
|
for i, h in enumerate(headers):
|
||||||
|
row[h] = (r[i].strip() if i < len(r) else '')
|
||||||
|
records.append(row)
|
||||||
|
|
||||||
|
def num(x):
|
||||||
|
if x is None: return 0.0
|
||||||
|
s = str(x).replace(',', '').replace('$', '').replace('%', '').strip()
|
||||||
|
if not s: return 0.0
|
||||||
|
try: return float(s)
|
||||||
|
except ValueError: return 0.0
|
||||||
|
|
||||||
|
# Aggregate totals for the summary strip.
|
||||||
|
sum_keys = ['Valid Click', 'PB Installs', 'Revenue', 'Cost', 'Gross Profit', 'RT Reject Installs']
|
||||||
|
totals = {k: round(sum(num(r.get(k, '')) for r in records), 4) for k in sum_keys if k in headers}
|
||||||
|
|
||||||
|
# Derived rates (recomputed from totals, not averaged across rows).
|
||||||
|
if totals.get('Valid Click', 0) > 0 and 'PB Installs' in totals:
|
||||||
|
totals['CVR'] = round(totals['PB Installs'] / totals['Valid Click'], 6)
|
||||||
|
if totals.get('Revenue', 0) > 0 and 'Gross Profit' in totals:
|
||||||
|
totals['%Gross Profit'] = round(totals['Gross Profit'] / totals['Revenue'], 6)
|
||||||
|
if 'PB Installs' in totals and 'RT Reject Installs' in totals:
|
||||||
|
denom = totals['PB Installs'] + totals['RT Reject Installs']
|
||||||
|
if denom > 0:
|
||||||
|
totals['%RT Reject Install'] = round(totals['RT Reject Installs'] / denom, 6)
|
||||||
|
|
||||||
|
# Distributions for the mini-charts.
|
||||||
|
def bucket(field):
|
||||||
|
out = {}
|
||||||
|
for r in records:
|
||||||
|
key = (r.get(field) or '').strip() or 'Unknown'
|
||||||
|
out[key] = out.get(key, 0) + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
status_counts = bucket('Status') if 'Status' in headers else {}
|
||||||
|
geo_counts = bucket('Geo') if 'Geo' in headers else {}
|
||||||
|
|
||||||
|
# Top-10 by revenue for the campaign bar chart.
|
||||||
|
def top_by(field, n=10):
|
||||||
|
scored = [(num(r.get(field, '')), r) for r in records]
|
||||||
|
scored.sort(key=lambda t: t[0], reverse=True)
|
||||||
|
out = []
|
||||||
|
for val, r in scored[:n]:
|
||||||
|
if val <= 0: break
|
||||||
|
out.append({
|
||||||
|
'campaign': r.get('Campaign Name', '') or r.get('Campaign ID', ''),
|
||||||
|
'pkg': r.get('PKG Name', ''),
|
||||||
|
'geo': r.get('Geo', ''),
|
||||||
|
'value': val,
|
||||||
|
'revenue': num(r.get('Revenue', '')),
|
||||||
|
'cost': num(r.get('Cost', '')),
|
||||||
|
'gp': num(r.get('Gross Profit', '')),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
fetched_at = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'sheet_name': sheet_name,
|
||||||
|
'fetched_at': fetched_at,
|
||||||
|
'columns': headers,
|
||||||
|
'row_count': len(records),
|
||||||
|
'totals': totals,
|
||||||
|
'status_counts': status_counts,
|
||||||
|
'geo_counts': geo_counts,
|
||||||
|
'top_by_revenue': top_by('Revenue'),
|
||||||
|
'top_by_gp': top_by('Gross Profit'),
|
||||||
|
'rows': records,
|
||||||
|
}
|
||||||
|
with open(data_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Keep a rolling history of daily totals so the trend chart has time series.
|
||||||
|
try:
|
||||||
|
with open(hist_path, 'r', encoding='utf-8') as f:
|
||||||
|
history = json.load(f)
|
||||||
|
if not isinstance(history, list):
|
||||||
|
history = []
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
history = []
|
||||||
|
|
||||||
|
history = [h for h in history if h.get('sheet_name') != sheet_name]
|
||||||
|
history.append({
|
||||||
|
'sheet_name': sheet_name,
|
||||||
|
'fetched_at': fetched_at,
|
||||||
|
'row_count': len(records),
|
||||||
|
'totals': totals,
|
||||||
|
'status_counts': status_counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
def sort_key(h):
|
||||||
|
try:
|
||||||
|
m, d = h['sheet_name'].split('/')
|
||||||
|
return (int(m), int(d))
|
||||||
|
except Exception:
|
||||||
|
return (0, 0)
|
||||||
|
history.sort(key=sort_key)
|
||||||
|
history = history[-120:] # keep ~4 months
|
||||||
|
|
||||||
|
with open(hist_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(history, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f'wrote {len(records)} rows for sheet {sheet_name}; history now {len(history)} days', file=sys.stderr)
|
||||||
|
PYEOF
|
||||||
|
|
||||||
|
rm -f "$CSV_FILE"
|
||||||
|
log "done"
|
||||||
Reference in New Issue
Block a user