Gleb Tsyganov
add dashboard
cd8bc6c
Raw
History Blame Contribute Delete
12.2 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Email Campaign Stats</title>
<style>
:root {
--bg-0: #f1ede6;
--bg-1: #f8f5ef;
--ink: #1e1d1a;
--muted: #6f685f;
--accent: #cf5a2e;
--accent-2: #1b6f8a;
--card: #ffffff;
--line: #e7ddd2;
--ok: #1f7a4f;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background: radial-gradient(circle at 10% 8%, #fff8ef 0%, var(--bg-1) 42%, var(--bg-0) 100%);
font-family: "Avenir Next", "Segoe UI", Tahoma, sans-serif;
}
.page {
max-width: 1180px;
margin: 0 auto;
padding: 28px 20px 40px;
}
.hero {
border: 1px solid var(--line);
background: linear-gradient(145deg, #fff 0%, #fffaf5 100%);
border-radius: 20px;
padding: 22px;
box-shadow: 0 14px 35px rgba(36, 21, 9, 0.08);
}
.eyebrow {
font-size: 12px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 6px 0 8px;
font-size: clamp(30px, 5vw, 56px);
line-height: 1.05;
}
.subtitle {
margin: 0;
max-width: 900px;
color: var(--muted);
font-size: 15px;
}
.controls {
margin-top: 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 10px;
padding: 10px 14px;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: transform 120ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-secondary {
background: var(--accent-2);
color: #fff;
}
.meta {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin-top: 16px;
}
.kpi {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
}
.kpi .label {
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.kpi .value {
margin-top: 6px;
font-size: 24px;
line-height: 1.15;
font-weight: 800;
}
.kpi .sub {
margin-top: 5px;
font-size: 12px;
color: var(--muted);
}
.section {
margin-top: 18px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
}
.section-head {
padding: 12px 14px;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(180deg, #fff 0%, #faf6f0 100%);
border-bottom: 1px solid var(--line);
}
.section-head h2 {
margin: 0;
font-size: 16px;
}
.section-head span {
font-size: 12px;
color: var(--muted);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
th,
td {
padding: 9px 11px;
border-bottom: 1px solid #f1ebe4;
text-align: left;
font-size: 13px;
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: #fff;
z-index: 1;
color: #4c453e;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
tr:hover td {
background: #fffaf3;
}
.status-ok {
color: var(--ok);
font-weight: 700;
}
.error {
margin-top: 12px;
color: #a22a2a;
font-size: 13px;
font-weight: 600;
}
.foot {
margin-top: 14px;
font-size: 12px;
color: var(--muted);
}
@media (max-width: 720px) {
.page {
padding: 16px 12px 26px;
}
.hero {
padding: 16px;
}
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div class="eyebrow">Email Campaign Intelligence</div>
<h1>Campaign Stats Dashboard</h1>
<p class="subtitle">
Live view of simulation outputs from the 5-email optimizer and arm-level campaign experiments.
Data is read from the latest CSV files in <code>outputs/evals/</code>.
</p>
<div class="controls">
<button class="btn btn-primary" id="refreshBtn">Refresh Stats</button>
<a class="btn btn-secondary" href="/docs" style="text-decoration:none;">API Docs</a>
</div>
<div class="meta" id="metaText">Loading latest results...</div>
<div class="error" id="errorBox" hidden></div>
</section>
<section class="kpis">
<article class="kpi">
<div class="label">Best Schedule</div>
<div class="value" id="kpiSchedule">-</div>
<div class="sub">Winning 5-email send plan</div>
</article>
<article class="kpi">
<div class="label">Open Rate</div>
<div class="value" id="kpiOpen">-</div>
<div class="sub">Top schedule open rate</div>
</article>
<article class="kpi">
<div class="label">Click-Through Rate</div>
<div class="value" id="kpiCtr">-</div>
<div class="sub">Top schedule CTR</div>
</article>
<article class="kpi">
<div class="label">Purchase Rate</div>
<div class="value" id="kpiPurchase">-</div>
<div class="sub">Top schedule conversion</div>
</article>
<article class="kpi">
<div class="label">Top Arm</div>
<div class="value" id="kpiArm">-</div>
<div class="sub">Best variant/time from arm simulation</div>
</article>
</section>
<section class="section">
<div class="section-head">
<h2>Top Schedules</h2>
<span id="scheduleCount">0 rows</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Schedule</th>
<th>Open</th>
<th>CTR</th>
<th>Purchase</th>
<th>Composite</th>
<th>Opens</th>
<th>Clicks</th>
<th>Buys</th>
</tr>
</thead>
<tbody id="scheduleTable"></tbody>
</table>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Best Schedule Step Breakdown</h2>
<span id="stepCount">0 rows</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Step</th>
<th>Timing</th>
<th>Open</th>
<th>CTR</th>
<th>Purchase</th>
<th>Subject</th>
</tr>
</thead>
<tbody id="stepTable"></tbody>
</table>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>Top Campaign Arms</h2>
<span id="armCount">0 rows</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Arm</th>
<th>Voice</th>
<th>Open</th>
<th>CTR</th>
<th>Purchase</th>
<th>Marketer</th>
</tr>
</thead>
<tbody id="armTable"></tbody>
</table>
</div>
</section>
<p class="foot" id="fileMeta"></p>
</main>
<script>
const percent = (v) => `${(Number(v || 0) * 100).toFixed(1)}%`;
const fixed = (v) => Number(v || 0).toFixed(3);
const byId = (id) => document.getElementById(id);
function renderRows(targetId, rows, htmlFn) {
const target = byId(targetId);
target.innerHTML = rows.map(htmlFn).join("");
}
function maybe(text) {
if (text === null || text === undefined || text === "") return "-";
return String(text);
}
async function loadStats() {
byId("errorBox").hidden = true;
byId("metaText").textContent = "Loading latest results...";
try {
const resp = await fetch("/campaign/stats", { cache: "no-store" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const bestSchedule = (data.summary && data.summary.best_schedule) || {};
const bestArm = (data.summary && data.summary.best_arm) || {};
byId("kpiSchedule").textContent = maybe(bestSchedule.schedule);
byId("kpiOpen").textContent = percent(bestSchedule.open_rate);
byId("kpiCtr").textContent = percent(bestSchedule.ctr);
byId("kpiPurchase").textContent = percent(bestSchedule.purchase_rate);
byId("kpiArm").textContent = maybe(bestArm.arm_id || bestArm.variant_name);
const topSchedules = data.top_schedules || [];
byId("scheduleCount").textContent = `${topSchedules.length} rows`;
renderRows("scheduleTable", topSchedules, (row) => `
<tr>
<td>${row.rank}</td>
<td>${maybe(row.schedule)}</td>
<td>${percent(row.open_rate)}</td>
<td>${percent(row.ctr)}</td>
<td>${percent(row.purchase_rate)}</td>
<td>${fixed(row.composite_score)}</td>
<td>${row.opens}</td>
<td>${row.clicks}</td>
<td>${row.purchases}</td>
</tr>
`);
const steps = data.step_breakdown || [];
byId("stepCount").textContent = `${steps.length} rows`;
renderRows("stepTable", steps, (row) => `
<tr>
<td>${row.step_idx}. ${maybe(row.step_name)}</td>
<td>${maybe(row.send_day)} ${String(row.send_hour).padStart(2, "0")}:00</td>
<td>${percent(row.open_rate)}</td>
<td>${percent(row.ctr)}</td>
<td>${percent(row.purchase_rate)}</td>
<td>${maybe(row.subject_line)}</td>
</tr>
`);
const arms = data.top_arms || [];
byId("armCount").textContent = `${arms.length} rows`;
renderRows("armTable", arms, (row) => `
<tr>
<td>${row.rank}</td>
<td>${maybe(row.arm_id)}</td>
<td>${maybe(row.brand_voice)}</td>
<td>${percent(row.open_rate)}</td>
<td>${percent(row.ctr)}</td>
<td>${percent(row.purchase_rate)}</td>
<td>${Number(row.marketer_score || 0).toFixed(1)}</td>
</tr>
`);
const files = data.files || {};
const fileSummary = Object.values(files)
.map((f) => `${f.path}: ${f.exists ? "present" : "missing"}`)
.join(" | ");
byId("fileMeta").textContent = fileSummary;
byId("metaText").innerHTML =
`<span class="status-ok">Loaded</span> &middot; ` +
`generated at ${maybe(data.generated_at_utc)}`;
} catch (err) {
byId("metaText").textContent = "Failed to load campaign stats.";
const box = byId("errorBox");
box.hidden = false;
box.textContent = `Error: ${err.message}`;
}
}
byId("refreshBtn").addEventListener("click", loadStats);
loadStats();
</script>
</body>
</html>