Spaces:
Sleeping
Sleeping
| <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> · ` + | |
| `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> | |