genesisai-forecasting / dabur_demand_platform.html
aashish-bindal's picture
Genesis AI deploy
bb004f6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Dabur Β· Demand Intelligence Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<!-- Pyodide removed: pipeline now runs via local Flask server on port 5050 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#F4F3EF;
--white:#FFFFFF;
--surface:#FAFAF8;
--border:#E2E0D8;
--border2:#C8C5BA;
--text:#1A1916;
--text2:#6B6860;
--text3:#A39E92;
--accent:#C84B31;
--accent-soft:#F5E6E2;
--green:#2D6A4F;
--green-soft:#E8F4EE;
--blue:#1D4E89;
--blue-soft:#E5EDF7;
--amber:#C77A06;
--amber-soft:#FEF3D8;
--teal:#1A7A6E;
--teal-soft:#E3F4F1;
--font:'DM Sans',sans-serif;
--mono:'DM Mono',monospace;
--r:8px;
--r2:12px;
--shadow:0 1px 3px rgba(0,0,0,0.08),0 1px 2px rgba(0,0,0,0.04);
--shadow2:0 4px 16px rgba(0,0,0,0.1);
}
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;font-size:14px;line-height:1.5}
/* ── TOPBAR ── */
.topbar{height:56px;background:var(--white);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 24px;position:sticky;top:0;z-index:200;box-shadow:var(--shadow)}
.topbar-brand{display:flex;align-items:center;gap:10px}
.brand-logo{width:32px;height:32px;background:var(--accent);border-radius:6px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-size:13px;letter-spacing:0.02em}
.brand-name{font-size:15px;font-weight:600;letter-spacing:-0.01em}
.brand-sub{font-size:11px;color:var(--text3);font-family:var(--mono)}
.topbar-nav{display:flex;gap:2px}
.nav-item{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;background:none;color:var(--text2);transition:all 0.15s;display:flex;align-items:center;gap:6px}
.nav-item:hover{background:var(--bg);color:var(--text)}
.nav-item.active{background:var(--accent-soft);color:var(--accent)}
.nav-badge{background:var(--accent);color:#fff;border-radius:99px;padding:1px 6px;font-size:10px;font-family:var(--mono)}
.topbar-right{display:flex;align-items:center;gap:8px}
.status-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:99px;border:1px solid var(--border);font-size:12px;font-family:var(--mono);color:var(--text3)}
.status-dot{width:6px;height:6px;border-radius:50%}
.sd-loading{background:var(--amber);animation:blink 1s infinite}
.sd-ready{background:var(--green)}
.sd-error{background:var(--accent)}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
/* ── PAGES ── */
.page{display:none;min-height:calc(100vh - 56px);padding:24px}
.page.active{display:block}
/* ── SECTION HEADER ── */
.section-header{margin-bottom:20px}
.section-header h1{font-size:20px;font-weight:600;letter-spacing:-0.02em;margin-bottom:4px}
.section-header p{font-size:13px;color:var(--text2)}
/* ── CARDS ── */
.card{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);box-shadow:var(--shadow)}
.card-header{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.card-title{font-size:13px;font-weight:600;color:var(--text)}
.card-body{padding:20px}
/* ── GRID ── */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
/* ── METRIC CARDS ── */
.metric{background:var(--white);border:1px solid var(--border);border-radius:var(--r2);padding:16px 20px}
.metric-label{font-size:11px;color:var(--text3);font-weight:500;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px}
.metric-value{font-size:26px;font-weight:600;font-family:var(--mono);letter-spacing:-0.02em;line-height:1}
.metric-sub{font-size:11px;color:var(--text3);margin-top:4px;font-family:var(--mono)}
.metric.accent .metric-value{color:var(--accent)}
.metric.green .metric-value{color:var(--green)}
.metric.blue .metric-value{color:var(--blue)}
.metric.amber .metric-value{color:var(--amber)}
/* ── FILE UPLOAD ZONE ── */
.upload-zone{border:1.5px dashed var(--border2);border-radius:var(--r2);padding:32px;text-align:center;cursor:pointer;transition:all 0.2s;position:relative;background:var(--surface)}
.upload-zone:hover,.upload-zone.drag{border-color:var(--accent);background:var(--accent-soft)}
.upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
.upload-icon{font-size:28px;margin-bottom:10px;display:block}
.upload-title{font-size:14px;font-weight:500;margin-bottom:4px}
.upload-hint{font-size:12px;color:var(--text3);font-family:var(--mono)}
.file-loaded{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--green-soft);border:1px solid #C3E6D4;border-radius:var(--r);margin-top:10px;font-size:12px;font-family:var(--mono);color:var(--green)}
.file-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
/* ── BUTTONS ── */
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:var(--r);font-size:13px;font-weight:500;cursor:pointer;border:none;font-family:var(--font);transition:all 0.15s}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:#b04028}
.btn-primary:active{transform:scale(0.98)}
.btn-primary:disabled{background:var(--border2);color:var(--text3);cursor:not-allowed;transform:none}
.btn-outline{background:#fff;color:var(--text);border:1px solid var(--border2)}
.btn-outline:hover{background:var(--bg);border-color:var(--text3)}
.btn-sm{padding:5px 12px;font-size:12px}
.btn-run{background:var(--green);color:#fff;font-weight:600;padding:10px 24px}
.btn-run:hover{background:#22573e}
.btn-run:disabled{background:var(--border2);color:var(--text3);cursor:not-allowed}
/* ── FILTERS ── */
.filter-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.filter-label{font-size:12px;font-weight:500;color:var(--text3)}
select{border:1px solid var(--border);border-radius:var(--r);padding:6px 10px;font-size:12px;font-family:var(--font);color:var(--text);background:var(--white);cursor:pointer;outline:none}
select:focus{border-color:var(--accent)}
/* ── CHART CANVAS ── */
.chart-wrap{position:relative;width:100%}
.chart-wrap canvas{width:100%!important}
/* ── STACKED BAR DECOMP ── */
.decomp-legend{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text2)}
.legend-dot{width:10px;height:10px;border-radius:2px;flex-shrink:0}
/* ── CODE EDITOR ── */
.editor-wrap{background:#1E1F23;border-radius:var(--r2);overflow:hidden}
.editor-bar{background:#2A2B30;padding:8px 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #363840}
.editor-filename{font-size:12px;font-family:var(--mono);color:#9DA3B0}
.editor-actions{display:flex;gap:6px}
.editor-btn{padding:4px 12px;border-radius:5px;font-size:11px;font-family:var(--font);font-weight:600;cursor:pointer;border:none;transition:all 0.15s}
.btn-code-run{background:#2D6A4F;color:#fff}
.btn-code-run:hover{background:#3a8562}
.btn-code-run:disabled{background:#363840;color:#555;cursor:not-allowed}
.btn-code-ghost{background:#363840;color:#9DA3B0}
.btn-code-ghost:hover{background:#404248;color:#E0E4EE}
textarea.editor{background:#1E1F23;color:#E0E4EE;font-family:var(--mono);font-size:12.5px;line-height:1.7;border:none;outline:none;resize:none;width:100%;padding:16px;tab-size:4;min-height:360px}
textarea.editor::selection{background:rgba(200,75,49,0.25)}
/* ── CONSOLE ── */
.console{background:#0F1012;border-radius:0 0 var(--r2) var(--r2);max-height:180px;overflow-y:auto;padding:12px 16px;font-family:var(--mono);font-size:11.5px;line-height:1.8}
.c-dim{color:#4A4F5C}
.c-info{color:#4FA3C8}
.c-ok{color:#7AC59A}
.c-warn{color:#D4A853}
.c-err{color:#E06060}
/* ── TABLES ── */
.data-table{width:100%;border-collapse:collapse;font-size:12px}
.data-table th{font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.06em;padding:8px 12px;border-bottom:1px solid var(--border);text-align:left;background:var(--surface)}
.data-table td{padding:9px 12px;border-bottom:1px solid var(--border);color:var(--text);font-family:var(--mono);font-size:12px}
.data-table tr:last-child td{border-bottom:none}
.data-table tr:hover td{background:var(--bg)}
/* ── PILLS / BADGES ── */
.pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:99px;font-size:10px;font-weight:600;font-family:var(--mono)}
.pill-green{background:var(--green-soft);color:var(--green)}
.pill-blue{background:var(--blue-soft);color:var(--blue)}
.pill-amber{background:var(--amber-soft);color:var(--amber)}
.pill-red{background:var(--accent-soft);color:var(--accent)}
.pill-teal{background:var(--teal-soft);color:var(--teal)}
/* ── PROGRESS ── */
.progress-bar{height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.progress-fill{height:100%;border-radius:3px;transition:width 0.5s ease}
/* ── SERIES TABS ── */
.series-tabs{display:flex;gap:4px;margin-bottom:16px}
.stab{padding:6px 14px;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--white);color:var(--text2);transition:all 0.15s}
.stab:hover{border-color:var(--text3)}
.stab.active{background:var(--accent);color:#fff;border-color:var(--accent)}
/* ── EMPTY STATE ── */
.empty{text-align:center;padding:48px 24px;color:var(--text3)}
.empty-icon{font-size:36px;margin-bottom:12px;display:block;opacity:0.5}
.empty h3{font-size:15px;font-weight:600;color:var(--text2);margin-bottom:6px}
.empty p{font-size:12px;line-height:1.6}
/* ── SECTION DIVIDER ── */
.divider{height:1px;background:var(--border);margin:20px 0}
/* ── FORECAST OUTPUT ── */
.forecast-summary{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:20px}
.model-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;font-size:11px;font-family:var(--mono);background:var(--blue-soft);color:var(--blue);margin-right:4px}
/* ── PAGE 4 PLACEHOLDER ── */
.coming-soon{min-height:60vh;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;text-align:center}
.coming-soon .big-icon{font-size:60px;opacity:0.15}
.coming-soon h2{font-size:22px;font-weight:600;color:var(--text2)}
.coming-soon p{font-size:14px;color:var(--text3);max-width:400px;line-height:1.7}
.coming-soon .placeholder-boxes{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:100%;max-width:700px;margin-top:20px}
.ph-box{background:var(--white);border:1.5px dashed var(--border2);border-radius:var(--r2);padding:24px;text-align:center}
.ph-box .ph-icon{font-size:24px;margin-bottom:8px;opacity:0.3}
.ph-box p{font-size:12px;color:var(--text3)}
/* ── SCROLLBARS ── */
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
/* ── RESPONSIVE ── */
@media(max-width:900px){
.grid-4{grid-template-columns:repeat(2,1fr)}
.forecast-summary{grid-template-columns:repeat(3,1fr)}
}
/* ── LOGIN SCREEN ── */
#login-screen{position:fixed;inset:0;z-index:1000;background:var(--bg);display:flex;align-items:center;justify-content:center}
#login-screen.hidden{display:none}
.login-box{background:var(--white);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow2);padding:40px;width:380px}
.login-logo{width:48px;height:48px;background:var(--accent);border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:18px;margin:0 auto 16px}
.login-title{text-align:center;font-size:20px;font-weight:600;letter-spacing:-0.02em;margin-bottom:4px}
.login-sub{text-align:center;font-size:12px;color:var(--text3);font-family:var(--mono);margin-bottom:28px}
.login-field{margin-bottom:14px}
.login-label{font-size:11px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:5px;display:block}
.login-input{width:100%;border:1px solid var(--border);border-radius:var(--r);padding:9px 12px;font-size:13px;font-family:var(--font);color:var(--text);background:var(--white);outline:none;transition:border 0.15s}
.login-input:focus{border-color:var(--accent)}
.login-btn{width:100%;padding:10px;border-radius:var(--r);background:var(--accent);color:#fff;font-size:14px;font-weight:600;border:none;cursor:pointer;font-family:var(--font);transition:background 0.15s;margin-top:4px}
.login-btn:hover{background:#b04028}
.login-btn:disabled{background:var(--border2);cursor:not-allowed}
.login-error{color:var(--accent);font-size:12px;text-align:center;margin-top:10px;min-height:18px;font-family:var(--mono)}
/* ── USER CHIP in topbar ── */
.user-chip{display:flex;align-items:center;gap:8px;padding:4px 12px 4px 8px;border-radius:99px;border:1px solid var(--border);background:var(--white);font-size:12px;cursor:pointer;transition:background 0.15s}
.user-chip:hover{background:var(--bg)}
.user-avatar{width:22px;height:22px;border-radius:50%;background:var(--accent);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center}
.user-name{font-weight:500;color:var(--text)}
.user-role{font-family:var(--mono);color:var(--text3);font-size:10px}
</style>
</head>
<body>
<!-- ══════════════════════════════════════════════════════════════════
LOGIN SCREEN
══════════════════════════════════════════════════════════════════ -->
<div id="login-screen">
<div class="login-box">
<div class="login-logo">DB</div>
<div class="login-title">Demand Intelligence Platform</div>
<div class="login-sub">Dabur Β· MMM Decomp & Forecasting</div>
<div class="login-field">
<label class="login-label">Username</label>
<input class="login-input" id="login-user" type="text" placeholder="analyst or viewer" autocomplete="username"/>
</div>
<div class="login-field">
<label class="login-label">Password</label>
<input class="login-input" id="login-pass" type="password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’" autocomplete="current-password"/>
</div>
<button class="login-btn" id="login-btn" onclick="doLogin()">Sign In</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════
TOP BAR
══════════════════════════════════════════════════════════════════ -->
<div class="topbar">
<div class="topbar-brand">
<div class="brand-logo">DB</div>
<div>
<div class="brand-name">Demand Intelligence Platform</div>
<div class="brand-sub">Dabur Β· MMM Decomp & Forecasting</div>
</div>
</div>
<div class="topbar-nav" id="topbar-nav">
<button class="nav-item active" id="nav-p1" onclick="goPage('p1',this)">πŸ“Š Decomposition</button>
<button class="nav-item" id="nav-p2" onclick="goPage('p2',this)">βš™οΈ Forecast Engine</button>
<button class="nav-item" id="nav-p3" onclick="goPage('p3',this)">πŸ“ˆ Forecast Output</button>
<button class="nav-item" id="nav-p4" onclick="goPage('p4',this)">πŸ—ΊοΈ Full Demand <span class="nav-badge">Soon</span></button>
</div>
<div class="topbar-right" style="gap:10px">
<div class="status-chip" id="status-chip" style="display:none">
<div class="status-dot sd-loading" id="sdot"></div>
<span id="stext">loading…</span>
</div>
<div class="user-chip" id="user-chip" style="display:none" onclick="doLogout()">
<div class="user-avatar" id="user-avatar">?</div>
<div>
<div class="user-name" id="user-name-label">β€”</div>
<div class="user-role" id="user-role-label">β€”</div>
</div>
<span style="font-size:10px;color:var(--text3);margin-left:2px">⏏</span>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════
PAGE 1 – DECOMPOSED VOLUME VISUALISATION
══════════════════════════════════════════════════════════════════ -->
<div class="page active" id="p1">
<div class="section-header">
<h1>Volume Decomposition by Drivers</h1>
<p>Historical baseline and driver contribution breakdown from the input decomp file</p>
</div>
<!-- File upload -->
<div class="card" style="margin-bottom:16px">
<div class="card-header"><span class="card-title">Upload Decomposition File</span><span class="pill pill-amber">Required to start</span></div>
<div class="card-body">
<div class="grid-2" style="gap:20px;align-items:start">
<div>
<div class="upload-zone" id="decomp-upload-zone">
<input type="file" id="decomp-file-input" accept=".xlsx,.csv"/>
<span class="upload-icon">πŸ“‚</span>
<div class="upload-title">Drop your decomposition file here</div>
<div class="upload-hint">.xlsx (Base Sheet) Β· .csv supported</div>
</div>
<div id="decomp-file-loaded" style="display:none" class="file-loaded">
<div class="file-dot"></div><span id="decomp-file-name">β€”</span>
</div>
</div>
<div>
<p style="font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:12px">
Expected sheet: <span style="font-family:var(--mono);color:var(--text)">Base Sheet</span><br>
Key columns: <span style="font-family:var(--mono);color:var(--text)">Channel, Design Brand, Account, DATE, Volume, Driver Level 3</span><br>
Drivers read: <span style="font-family:var(--mono);color:var(--text)">Baseline, Actual Volume, and all incremental drivers</span>
</p>
<button class="btn btn-primary" id="load-decomp-btn" onclick="loadDecompFile()" disabled>Load &amp; Visualise</button>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="filter-bar" id="decomp-filters" style="display:none">
<span class="filter-label">Filter:</span>
<select id="f-channel" onchange="renderDecomp()"><option value="ALL">All Channels</option></select>
<select id="f-brand" onchange="renderDecomp()"><option value="ALL">All Brands</option></select>
<select id="f-account" onchange="renderDecomp()"><option value="ALL">All Accounts</option></select>
<select id="f-fy" onchange="renderDecomp()"><option value="ALL">All FY</option></select>
</div>
<!-- KPI row -->
<div class="grid-4" id="decomp-kpis" style="display:none;margin-bottom:16px">
<div class="metric accent"><div class="metric-label">Total Actual Volume</div><div class="metric-value" id="kpi-actual">β€”</div><div class="metric-sub">all periods</div></div>
<div class="metric green"><div class="metric-label">Total Baseline Vol</div><div class="metric-value" id="kpi-baseline">β€”</div><div class="metric-sub">structural demand</div></div>
<div class="metric blue"><div class="metric-label">Incremental Vol</div><div class="metric-value" id="kpi-incr">β€”</div><div class="metric-sub">promo + visibility + price</div></div>
<div class="metric amber"><div class="metric-label">Baseline Share</div><div class="metric-value" id="kpi-bshare">β€”</div><div class="metric-sub">of actual volume</div></div>
</div>
<!-- Charts -->
<div id="decomp-charts" style="display:none">
<div class="grid-2" style="margin-bottom:16px">
<div class="card">
<div class="card-header"><span class="card-title">Stacked Driver Decomposition Β· Monthly</span></div>
<div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="stackedChart"></canvas></div></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Actual vs Baseline Volume</span></div>
<div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="actualBaseChart"></canvas></div></div>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">Driver Contribution Share</span>
<div id="decomp-legend" class="decomp-legend" style="margin-bottom:0"></div>
</div>
<div class="card-body"><div class="chart-wrap" style="height:220px"><canvas id="driverShareChart"></canvas></div></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Driver Contribution Summary</span></div>
<div class="card-body" style="padding:0 0 8px">
<table class="data-table" id="driver-table"><thead><tr><th>Driver</th><th>Level 1</th><th>Level 2</th><th>Total Volume</th><th>Avg Monthly</th><th>Share</th></tr></thead><tbody id="driver-tbody"></tbody></table>
</div>
</div>
</div>
<div id="decomp-empty" class="empty">
<span class="empty-icon">πŸ“Š</span>
<h3>No data loaded</h3>
<p>Upload a decomposition Excel file above to see the driver visualisation</p>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════
PAGE 2 – FORECAST ENGINE
══════════════════════════════════════════════════════════════════ -->
<div class="page" id="p2">
<div class="section-header">
<h1>Baseline Forecast Engine</h1>
<p>Upload the forecasting script and data file Β· run via in-browser Python interpreter (Pyodide)</p>
</div>
<div class="grid-2" style="gap:16px;margin-bottom:16px;align-items:start">
<!-- Upload panel -->
<div>
<div class="card" style="margin-bottom:12px">
<div class="card-header"><span class="card-title">Forecast Script</span><span class="pill pill-blue">.py</span></div>
<div class="card-body">
<div class="upload-zone" id="script-upload-zone">
<input type="file" id="script-file-input" accept=".py,.ipynb"/>
<span class="upload-icon">πŸ“œ</span>
<div class="upload-title">Upload forecast script</div>
<div class="upload-hint">dabur_baseline_forecast_v2.py</div>
</div>
<div id="script-file-loaded" style="display:none" class="file-loaded">
<div class="file-dot"></div><span id="script-file-name">β€”</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Decomp Input Data</span><span class="pill pill-amber">.xlsx</span></div>
<div class="card-body">
<div class="upload-zone" id="data-upload-zone">
<input type="file" id="data-file-input" accept=".xlsx,.csv"/>
<span class="upload-icon">πŸ“‘</span>
<div class="upload-title">Upload decomp Excel file</div>
<div class="upload-hint">Base Sheet Β· same file as Page 1</div>
</div>
<div id="data-file-loaded" style="display:none" class="file-loaded">
<div class="file-dot"></div><span id="data-file-name">β€”</span>
</div>
</div>
</div>
</div>
<!-- Config panel -->
<div class="card" style="height:100%">
<div class="card-header"><span class="card-title">Run Configuration</span></div>
<div class="card-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px">
<div>
<div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Forecast horizon (months)</div>
<input type="number" id="cfg-horizon" value="12" min="1" max="36" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
</div>
<div>
<div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Season length</div>
<input type="number" id="cfg-season" value="12" min="1" max="52" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
</div>
<div>
<div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">CV horizon</div>
<input type="number" id="cfg-cvh" value="6" min="1" max="12" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
</div>
<div>
<div style="font-size:11px;font-weight:600;color:var(--text3);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.06em">Top N ensemble</div>
<input type="number" id="cfg-topn" value="3" min="1" max="7" style="width:100%;border:1px solid var(--border);border-radius:var(--r);padding:7px 10px;font-size:13px;font-family:var(--mono)"/>
</div>
</div>
<div style="padding:10px 14px;background:var(--bg);border-radius:var(--r);font-size:12px;color:var(--text2);margin-bottom:16px;line-height:1.7">
Pipeline: AutoETS Β· AutoARIMA Β· AutoTheta Β· HoltWinters (Additive + Multiplicative) Β· MSTL+Theta Β· SeasonalNaive<br>
Ensemble: top-N models by cross-validation MAE Β· fallback: WindowAverage(6)
</div>
<button class="btn btn-run" id="run-pipeline-btn" onclick="runForecastPipeline()" disabled style="width:100%">β–Ά Run Baseline Forecast</button>
</div>
</div>
</div>
<!-- Editor -->
<div style="margin-bottom:16px">
<div class="editor-wrap">
<div class="editor-bar">
<span class="editor-filename" id="p2-editor-filename">no script loaded</span>
<div class="editor-actions">
<button class="editor-btn btn-code-ghost" onclick="loadDemoScript()">Load demo script</button>
<button class="editor-btn btn-code-ghost" onclick="document.getElementById('editor-code').value=''">Clear</button>
<button class="editor-btn btn-code-run" id="run-editor-btn" onclick="runForecastPipeline()" disabled>β–Ά Run</button>
</div>
</div>
<textarea class="editor" id="editor-code" spellcheck="false" placeholder="Upload a script or click 'Load demo script'…"></textarea>
<div class="console" id="p2-console"><div class="c-dim">Β» Pyodide interpreter initialising…</div></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════
PAGE 3 – FORECAST OUTPUT
══════════════════════════════════════════════════════════════════ -->
<div class="page" id="p3">
<div class="section-header">
<h1>Baseline Forecast Output</h1>
<p>Results from the last pipeline run Β· 12-month baseline projection per series</p>
</div>
<div id="p3-empty" class="empty">
<span class="empty-icon">πŸ“ˆ</span>
<h3>No forecast run yet</h3>
<p>Go to the Forecast Engine tab, upload your files and run the pipeline. Results will appear here automatically.</p>
</div>
<div id="p3-content" style="display:none">
<!-- Top KPIs -->
<div class="forecast-summary" id="p3-kpis" style="margin-bottom:16px"></div>
<!-- Series selector -->
<div class="series-tabs" id="p3-series-tabs"></div>
<!-- Per-series view -->
<div id="p3-series-view">
<div class="grid-2" style="margin-bottom:16px">
<div class="card">
<div class="card-header">
<span class="card-title" id="p3-chart-title">Baseline Forecast Β· 12-Month Horizon</span>
<div id="p3-top-models" style="display:flex;gap:4px;flex-wrap:wrap"></div>
</div>
<div class="card-body"><div class="chart-wrap" style="height:280px"><canvas id="forecastChart"></canvas></div></div>
</div>
<div class="card">
<div class="card-header"><span class="card-title">Model Accuracy (CV MAE)</span></div>
<div class="card-body" style="padding:0">
<table class="data-table" id="mae-table">
<thead><tr><th>Model</th><th>MAE</th><th>Rank</th></tr></thead>
<tbody id="mae-tbody"></tbody>
</table>
</div>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">
<span class="card-title">12-Month Forecast Table</span>
<button class="btn btn-outline btn-sm" onclick="downloadForecast()">⬇ Download CSV</button>
</div>
<div class="card-body" style="padding:0">
<table class="data-table" id="forecast-table">
<thead><tr><th>Date</th><th>FY</th><th>Forecast Volume</th><th>Price</th><th>Forecast Value</th><th>MoM Ξ”%</th></tr></thead>
<tbody id="forecast-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════════
PAGE 4 – FULL DEMAND FORECAST (PLACEHOLDER)
══════════════════════════════════════════════════════════════════ -->
<div class="page" id="p4">
<div class="coming-soon">
<div class="big-icon">πŸ—ΊοΈ</div>
<h2>Full Demand Forecast</h2>
<p>This module will combine baseline forecast with plan data (CP spend, visibility, price) to produce total demand forecast across all channels and accounts.</p>
<div class="placeholder-boxes">
<div class="ph-box"><div class="ph-icon">πŸ“‹</div><p>Plan Data Upload<br><span style="color:var(--text3);font-size:10px">CP Β· Visibility Β· Price Plans</span></p></div>
<div class="ph-box"><div class="ph-icon">⚑</div><p>Incremental Modelling<br><span style="color:var(--text3);font-size:10px">Promo uplift · ROI attribution</span></p></div>
<div class="ph-box"><div class="ph-icon">πŸ“Š</div><p>Full Demand Output<br><span style="color:var(--text3);font-size:10px">Total Volume Β· Value Β· Share</span></p></div>
</div>
<p style="margin-top:24px;font-size:12px;background:var(--white);padding:10px 20px;border-radius:var(--r2);border:1px solid var(--border);color:var(--text3);font-family:var(--mono)">Status: Pending plan data integration Β· ETA: Next sprint</p>
</div>
</div>
<!-- ═══════════════════════════ JAVASCRIPT ═══════════════════════════ -->
<script>
// ─── GLOBAL STATE ─────────────────────────────────────────────────────
const SERVER_URL = "";
let decompData = null, actualData = null;
let forecastResult = null;
let activeSeriesIdx = 0;
let dataFile = null;
let chartInstances = {};
let currentUser = null; // { username, role }
const DRIVER_COLORS = {
"Baseline" : "#2D6A4F",
"Consumer Promotions" : "#C84B31",
"Visibility" : "#1D4E89",
"Rainfall" : "#4A90D9",
"Own Price" : "#C77A06",
"Baseline Forecast" : "#7B4EA0",
};
const DEFAULT_COLORS = ["#E76F51","#264653","#2A9D8F","#E9C46A","#457B9D","#A8DADC","#F4A261"];
// ─── AUTH ─────────────────────────────────────────────────────────────
async function doLogin(){
const btn = document.getElementById("login-btn");
const err = document.getElementById("login-error");
const user = document.getElementById("login-user").value.trim();
const pass = document.getElementById("login-pass").value;
if(!user||!pass){ err.textContent="Enter username and password."; return; }
btn.disabled=true; btn.textContent="Signing in…"; err.textContent="";
try {
const r = await fetch("/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:user,password:pass})});
const j = await r.json();
if(j.status!=="ok") throw new Error(j.message||"Login failed");
onLoginSuccess(j);
} catch(e){
err.textContent = e.message;
btn.disabled=false; btn.textContent="Sign In";
}
}
function onLoginSuccess(user){
currentUser = user;
document.getElementById("login-screen").classList.add("hidden");
document.getElementById("user-chip").style.display="flex";
document.getElementById("status-chip").style.display="flex";
document.getElementById("user-avatar").textContent = user.username[0].toUpperCase();
document.getElementById("user-name-label").textContent = user.username;
document.getElementById("user-role-label").textContent = user.role;
applyRoleUI(user.role);
// ─── BOOT: restore session or show login ─────────────────────────────
(async function boot(){
// Enter key on password field triggers login
document.getElementById("login-pass").addEventListener("keydown", e=>{
if(e.key==="Enter") doLogin();
});
try {
const r = await fetch("/me");
const j = await r.json();
if(j.status==="ok"){
onLoginSuccess(j); // session still valid – skip login screen
}
// else: stay on login screen
} catch(e){ /* stay on login screen */ }
})();
// If viewer, immediately try to load the latest forecast
if(user.role==="viewer") loadLatestForecast();
}
async function doLogout(){
if(!confirm("Sign out?")) return;
await fetch("/logout",{method:"POST"});
currentUser=null; forecastResult=null;
document.getElementById("login-screen").classList.remove("hidden");
document.getElementById("user-chip").style.display="none";
document.getElementById("status-chip").style.display="none";
document.getElementById("login-pass").value="";
document.getElementById("login-error").textContent="";
}
function applyRoleUI(role){
// analyst sees all 4 tabs; viewer sees only Forecast Output
const analystOnly = ["nav-p1","nav-p2","nav-p4"];
analystOnly.forEach(id=>{
const el=document.getElementById(id);
if(el) el.style.display = role==="analyst" ? "" : "none";
});
if(role==="viewer"){
// force viewer onto p3
document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
document.getElementById("p3").classList.add("active");
document.querySelectorAll(".nav-item").forEach(n=>n.classList.remove("active"));
document.getElementById("nav-p3").classList.add("active");
}
}
async function loadLatestForecast(){
// Viewer polls the server for the latest forecast result
try {
const r = await fetch("/forecast-result");
const j = await r.json();
if(j.status==="success"){
forecastResult=j;
renderPage3();
setStatus("ready","forecast loaded βœ“");
} else if(j.status==="empty"){
setStatus("ready","awaiting forecast");
}
} catch(e){ /* silent */ }
}
// ─── SERVER HEALTH CHECK (runs after login) ────────────────────────────
async function initPyodide(){
if(currentUser?.role==="viewer"){
setStatus("ready","viewer mode");
return;
}
clog("Β» Checking server…","dim");
try {
const r=await fetch("/health");
const j=await r.json();
if(j.status==="ok"){
setStatus("ready","server ready βœ“");
clog("Β» Server ready βœ“ Upload your data file and run.","ok");
document.getElementById("run-pipeline-btn").disabled=false;
document.getElementById("run-editor-btn").disabled=false;
} else throw new Error(j.message);
} catch(e){
setStatus("error","server offline");
clog("βœ— "+e.message,"err");
}
}
function setStatus(state,text){
const d=document.getElementById("sdot"),s=document.getElementById("stext");
d.className="status-dot sd-"+state; s.textContent=text;
}
function clog(msg,cls="ok"){
const el=document.getElementById("p2-console");
if(!el) return;
const div=document.createElement("div");
div.className="c-"+cls; div.textContent=msg;
el.appendChild(div); el.scrollTop=el.scrollHeight;
}
// ─── NAVIGATION ───────────────────────────────────────────────────────
function goPage(id,el){
// viewers cannot navigate away from p3
if(currentUser?.role==="viewer" && id!=="p3") return;
document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
document.querySelectorAll(".nav-item").forEach(n=>n.classList.remove("active"));
document.getElementById(id).classList.add("active");
el.classList.add("active");
}
// ─── FILE UPLOAD HELPERS ──────────────────────────────────────────────
function setupUpload(zoneId,inputId,onLoaded){
const zone=document.getElementById(zoneId), inp=document.getElementById(inputId);
inp.addEventListener("change",e=>{[...e.target.files].forEach(onLoaded); e.target.value="";});
zone.addEventListener("dragover",e=>{e.preventDefault();zone.classList.add("drag");});
zone.addEventListener("dragleave",()=>zone.classList.remove("drag"));
zone.addEventListener("drop",e=>{e.preventDefault();zone.classList.remove("drag");[...e.dataTransfer.files].forEach(onLoaded);});
}
function showFileLoaded(nameEl,loadedEl,filename){
document.getElementById(nameEl).textContent=filename;
document.getElementById(loadedEl).style.display="flex";
}
// ─────────────────────────────────────────────────────────────────────
// PAGE 1 – DECOMP FILE UPLOAD & VISUALISATION
// ─────────────────────────────────────────────────────────────────────
setupUpload("decomp-upload-zone","decomp-file-input",f=>{
showFileLoaded("decomp-file-name","decomp-file-loaded",f.name);
document.getElementById("load-decomp-btn").disabled=false;
window._decompFile=f;
});
async function loadDecompFile(){
const f=window._decompFile; if(!f) return;
const btn=document.getElementById("load-decomp-btn");
btn.disabled=true; btn.textContent="Loading…";
try {
const fd=new FormData();
fd.append("file",f);
// Reuse /run endpoint to get decomp_data and actual_data from the server
clog("Β» Sending file to server for parsing…","info");
// Submit and poll (same pattern as forecast engine)
const submitResp=await fetch(SERVER_URL+"/run",{method:"POST",body:fd});
if(!submitResp.ok) throw new Error("Server error "+submitResp.status);
const submitJson=await submitResp.json();
if(submitJson.status!=="started") throw new Error(submitJson.message||"Pipeline error");
// Poll until done
let json=null;
while(true){
await new Promise(r=>setTimeout(r,4000));
const poll=await (await fetch(SERVER_URL+"/job/"+submitJson.job_id)).json();
if(poll.status==="running"){ btn.textContent="Loading… "+poll.elapsed_seconds+"s"; continue; }
if(poll.status==="error") throw new Error(poll.message||"Pipeline error");
if(poll.status==="done"){ json=poll.result; break; }
}
if(json.status!=="success") throw new Error(json.message||"Pipeline error");
// Build records list from decomp_data returned by pipeline
const records=(json.decomp_data||[]).map(r=>({
channel:r.channel, design_brand:r.design_brand, account:r.account,
date:r.date, fy:r.fy, dl1:r["Driver Level 1"]||r.dl1||"",
dl2:r["Driver Level 2"]||r.dl2||"", dl3:r.driver_l3||r.dl3||"",
volume:r.volume, price:r.price, value:r.value
}));
// Also stash actual_data for overlay
actualData=json.actual_data||[];
processDecompData(records);
btn.textContent="βœ“ Loaded";
btn.style.background="var(--green)";
clog("Β» Decomp data loaded βœ“ "+records.length+" records.","ok");
} catch(e){
btn.disabled=false; btn.textContent="Load & Visualise";
clog("βœ— "+e.message,"err");
alert("Error loading file: "+e.message);
}
}
function processDecompData(records){
decompData=records;
// Populate filters
const channels=[...new Set(records.map(r=>r.Channel||r.channel))].filter(Boolean);
const brands=[...new Set(records.map(r=>r.design_brand))].filter(Boolean);
const accounts=[...new Set(records.map(r=>r.Account||r.account))].filter(Boolean);
const fys=[...new Set(records.map(r=>r.FY||r.fy))].filter(Boolean).sort();
populateSelect("f-channel",channels);
populateSelect("f-brand",brands);
populateSelect("f-account",accounts);
populateSelect("f-fy",fys);
document.getElementById("decomp-filters").style.display="flex";
document.getElementById("decomp-kpis").style.display="grid";
document.getElementById("decomp-charts").style.display="block";
document.getElementById("decomp-empty").style.display="none";
renderDecomp();
}
function populateSelect(id,opts){
const el=document.getElementById(id);
const first=el.options[0]; el.innerHTML=""; el.appendChild(first);
opts.forEach(o=>{const op=document.createElement("option");op.value=op.textContent=o;el.appendChild(op);});
}
function getFilteredDecomp(){
const ch=document.getElementById("f-channel").value;
const br=document.getElementById("f-brand").value;
const ac=document.getElementById("f-account").value;
const fy=document.getElementById("f-fy").value;
return (decompData||[]).filter(r=>{
const rc=r.Channel||r.channel, rb=r.design_brand, ra=r.Account||r.account, rf=r.FY||r.fy;
return (ch==="ALL"||rc===ch)&&(br==="ALL"||rb===br)&&(ac==="ALL"||ra===ac)&&(fy==="ALL"||rf===fy);
});
}
function renderDecomp(){
const rows=getFilteredDecomp();
if(!rows.length) return;
const baseline=rows.filter(r=>r.dl3==="Baseline");
const actual=rows.filter(r=>r.dl3==="Actual Volume");
const drivers=rows.filter(r=>!["Baseline","Actual Volume","Predicted Volume"].includes(r.dl3));
// KPIs
const totalActual=actual.reduce((s,r)=>s+(+r.volume||0),0);
const totalBaseline=baseline.reduce((s,r)=>s+(+r.volume||0),0);
const totalIncr=drivers.reduce((s,r)=>s+(+r.volume||0),0);
const bShare=totalActual>0?totalBaseline/totalActual*100:0;
document.getElementById("kpi-actual").textContent=fmtNum(totalActual);
document.getElementById("kpi-baseline").textContent=fmtNum(totalBaseline);
document.getElementById("kpi-incr").textContent=fmtNum(totalIncr);
document.getElementById("kpi-bshare").textContent=bShare.toFixed(1)+"%";
// Get sorted dates from baseline
const dates=[...new Set(baseline.map(r=>r.date))].sort();
const allDriverTypes=[...new Set(rows.map(r=>r.dl3))].filter(d=>!["Actual Volume","Predicted Volume"].includes(d));
// Stacked chart datasets
const stackedDatasets=allDriverTypes.map((dt,i)=>{
const color=DRIVER_COLORS[dt]||DEFAULT_COLORS[i%DEFAULT_COLORS.length];
const data=dates.map(d=>{
const found=rows.filter(r=>r.dl3===dt&&r.date===d);
return found.reduce((s,r)=>s+(+r.volume||0),0);
});
return {label:dt,data,backgroundColor:color+"CC",borderColor:color,borderWidth:1,stack:"s"};
});
renderChart("stackedChart","bar",dates,stackedDatasets,{stacked:true,height:280});
// Actual vs Baseline line chart
const actualVols=dates.map(d=>actual.filter(r=>r.date===d).reduce((s,r)=>s+(+r.volume||0),0)||null);
const baselineVols=dates.map(d=>baseline.filter(r=>r.date===d).reduce((s,r)=>s+(+r.volume||0),0)||null);
renderChart("actualBaseChart","line",dates,[
{label:"Actual Volume",data:actualVols,borderColor:"#1D4E89",backgroundColor:"#1D4E8920",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false},
{label:"Baseline",data:baselineVols,borderColor:"#2D6A4F",backgroundColor:"#2D6A4F20",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false},
],{height:280});
// Driver share doughnut
const driverTotals={};
allDriverTypes.forEach(dt=>{
driverTotals[dt]=rows.filter(r=>r.dl3===dt).reduce((s,r)=>s+(+r.volume||0),0);
});
const dColors=allDriverTypes.map((dt,i)=>DRIVER_COLORS[dt]||DEFAULT_COLORS[i%DEFAULT_COLORS.length]);
renderChart("driverShareChart","bar",allDriverTypes,[
{label:"Total Volume",data:allDriverTypes.map(d=>Math.max(0,driverTotals[d]||0)),backgroundColor:dColors,borderRadius:4}
],{indexAxis:"y",height:220,legend:false});
// Driver summary table
const totalAbsVol=Object.values(driverTotals).reduce((s,v)=>s+Math.abs(v),0);
const tbody=document.getElementById("driver-tbody");
tbody.innerHTML=allDriverTypes.sort((a,b)=>Math.abs(driverTotals[b])-Math.abs(driverTotals[a])).map(dt=>{
const count=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.date))].length;
const tot=driverTotals[dt]||0;
const dl1=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.dl1))].join(", ");
const dl2=[...new Set(rows.filter(r=>r.dl3===dt).map(r=>r.dl2))].join(", ");
const share=totalAbsVol>0?(Math.abs(tot)/totalAbsVol*100).toFixed(1):0;
const color=DRIVER_COLORS[dt]||"#888";
return `<tr>
<td><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};margin-right:6px;vertical-align:middle"></span>${dt}</td>
<td style="color:var(--text2)">${dl1}</td>
<td style="color:var(--text2)">${dl2}</td>
<td>${fmtNum(tot)}</td>
<td>${count>0?fmtNum(tot/count):"β€”"}</td>
<td><div style="display:flex;align-items:center;gap:8px"><div class="progress-bar" style="width:80px"><div class="progress-fill" style="width:${share}%;background:${color}"></div></div><span>${share}%</span></div></td>
</tr>`;
}).join("");
}
// ─────────────────────────────────────────────────────────────────────
// PAGE 2 – FORECAST ENGINE
// ─────────────────────────────────────────────────────────────────────
setupUpload("script-upload-zone","script-file-input",f=>{
showFileLoaded("script-file-name","script-file-loaded",f.name);
const reader=new FileReader();
reader.onload=ev=>{
document.getElementById("editor-code").value=ev.target.result;
document.getElementById("p2-editor-filename").textContent=f.name;
clog("Β» Script loaded: "+f.name,"info");
};
reader.readAsText(f);
});
setupUpload("data-upload-zone","data-file-input",f=>{
showFileLoaded("data-file-name","data-file-loaded",f.name);
dataFile=f;
clog("Β» Data file ready: "+f.name,"info");
});
function loadDemoScript(){
const script=getDemoScript();
document.getElementById("editor-code").value=script;
document.getElementById("p2-editor-filename").textContent="dabur_baseline_forecast_v2.py";
clog("Β» Demo script loaded into editor.","info");
}
async function runForecastPipeline(){
if(!dataFile){clog("βœ— No data file uploaded.","err");return;}
const btn=document.getElementById("run-pipeline-btn");
const ebtn=document.getElementById("run-editor-btn");
btn.disabled=ebtn.disabled=true;
btn.textContent="⏳ Submitting…"; ebtn.textContent="⏳";
setStatus("loading","submitting job…");
document.getElementById("p2-console").innerHTML="";
clog("Β» Uploading file to Genesis AI server…","info");
clog(`Β» ${new Date().toLocaleTimeString()}`,"dim");
const horizon=parseInt(document.getElementById("cfg-horizon").value)||12;
const season=parseInt(document.getElementById("cfg-season").value)||12;
const cvh=parseInt(document.getElementById("cfg-cvh").value)||6;
const topn=parseInt(document.getElementById("cfg-topn").value)||3;
try {
const fd=new FormData();
fd.append("file",dataFile);
fd.append("horizon",horizon);
fd.append("season",season);
fd.append("cv_horizon",cvh);
fd.append("top_n",topn);
// Step 1: submit job – returns immediately with job_id
const submitResp=await fetch(SERVER_URL+"/run",{method:"POST",body:fd});
const submitJson=await submitResp.json();
if(submitJson.status!=="started") throw new Error(submitJson.message||"Failed to start job");
const jobId=submitJson.job_id;
clog("Β» Job started (id: "+jobId.slice(0,8)+"…)","info");
clog("Β» Pipeline running in background – polling for result…","dim");
btn.textContent="⏳ Running…";
// Step 2: poll /job/<id> every 5 seconds
await pollJob(jobId);
} catch(e){
clog("βœ— "+e.message,"err");
setStatus("error","pipeline error");
btn.disabled=ebtn.disabled=false;
btn.textContent="β–Ά Run Baseline Forecast"; ebtn.textContent="β–Ά Run";
}
}
async function pollJob(jobId){
const btn=document.getElementById("run-pipeline-btn");
const ebtn=document.getElementById("run-editor-btn");
const start=Date.now();
while(true){
await new Promise(r=>setTimeout(r,5000)); // wait 5 seconds between polls
let poll;
try { poll=await (await fetch(SERVER_URL+"/job/"+jobId)).json(); }
catch(e){ clog("⚠ Poll error: "+e.message,"warn"); continue; }
if(poll.status==="running"){
const elapsed=poll.elapsed_seconds||Math.round((Date.now()-start)/1000);
const mins=Math.floor(elapsed/60), secs=elapsed%60;
const t=mins>0?`${mins}m ${secs}s`:`${secs}s`;
clog(`Β» Still running… (${t} elapsed)`,"dim");
btn.textContent=`⏳ Running ${t}…`;
setStatus("loading",`running ${t}…`);
continue;
}
if(poll.status==="error"){
clog("βœ— Pipeline error: "+poll.message,"err");
setStatus("error","pipeline error");
break;
}
if(poll.status==="done"){
forecastResult=poll.result;
clog("Β» Pipeline complete βœ“ "+forecastResult.n_series+" series processed.","ok");
clog("Β» Skipped: "+forecastResult.n_skipped+" series (insufficient data).","dim");
clog("Β» Result saved – viewer users can now see the output.","info");
renderPage3();
setStatus("ready","forecast complete βœ“");
break;
}
}
btn.disabled=ebtn.disabled=false;
btn.textContent="β–Ά Run Baseline Forecast"; ebtn.textContent="β–Ά Run";
}
// ─────────────────────────────────────────────────────────────────────
// PAGE 3 – FORECAST OUTPUT
// ─────────────────────────────────────────────────────────────────────
function renderPage3(){
if(!forecastResult) return;
document.getElementById("p3-empty").style.display="none";
document.getElementById("p3-content").style.display="block";
const series=forecastResult.series||[];
const totalFcstVol=series.reduce((s,sr)=>s+(sr.total_forecast_vol||0),0);
// KPIs
const kpiEl=document.getElementById("p3-kpis");
kpiEl.innerHTML=`
<div class="metric green"><div class="metric-label">Series Modelled</div><div class="metric-value">${series.length}</div><div class="metric-sub">Channel Γ— Brand Γ— Account</div></div>
<div class="metric blue"><div class="metric-label">Total Forecast Vol</div><div class="metric-value">${fmtNum(totalFcstVol)}</div><div class="metric-sub">12-month horizon</div></div>
<div class="metric accent"><div class="metric-label">Forecast Period</div><div class="metric-value">${series[0]?.forecast?.[0]?.fy||"β€”"}</div><div class="metric-sub">to ${series[0]?.forecast?.slice(-1)[0]?.fy||"β€”"}</div></div>
<div class="metric amber"><div class="metric-label">Ensemble Models</div><div class="metric-value">${(series[0]?.top_models||[]).length}</div><div class="metric-sub">top-N by CV MAE</div></div>
<div class="metric"><div class="metric-label">Date Run</div><div class="metric-value" style="font-size:16px">${new Date().toLocaleDateString()}</div><div class="metric-sub">${new Date().toLocaleTimeString()}</div></div>
`;
// Series tabs
const tabsEl=document.getElementById("p3-series-tabs");
tabsEl.innerHTML=series.map((s,i)=>`<div class="stab ${i===0?"active":""}" onclick="selectSeries(${i},this)">${s.design_brand} Β· ${s.account}</div>`).join("");
activeSeriesIdx=0;
renderSeriesView(0);
}
function selectSeries(idx,el){
document.querySelectorAll(".stab").forEach(t=>t.classList.remove("active"));
el.classList.add("active");
activeSeriesIdx=idx;
renderSeriesView(idx);
}
function renderSeriesView(idx){
const series=(forecastResult?.series||[])[idx];
if(!series) return;
document.getElementById("p3-chart-title").textContent=`Baseline Forecast Β· ${series.design_brand} Γ— ${series.account}`;
// Top models chips
document.getElementById("p3-top-models").innerHTML=(series.top_models||[]).map(m=>`<span class="model-badge">${m}</span>`).join("");
// Forecast chart: history + forecast
const hist=series.history||[];
const fcst=series.forecast||[];
const histDates=hist.map(r=>r.date);
const fcstDates=fcst.map(r=>r.date);
const allDates=[...histDates,...fcstDates];
const actualLine=[...hist.map(r=>r.actual_vol), ...Array(fcstDates.length).fill(null)];
const baseLine= [...hist.map(r=>r.baseline_vol), ...Array(fcstDates.length).fill(null)];
const fcstLine= [...Array(histDates.length).fill(null),...fcst.map(r=>r.volume)];
// Connect last historical baseline to first forecast
if(hist.length>0 && fcst.length>0){
fcstLine[histDates.length-1]=hist[hist.length-1].baseline_vol;
}
renderChart("forecastChart","line",allDates,[
{label:"Actual Volume",data:actualLine,borderColor:"#1D4E89",borderWidth:2.5,pointRadius:2,tension:0.3,fill:false,spanGaps:false},
{label:"Historical Baseline",data:baseLine,borderColor:"#2D6A4F",borderWidth:2.5,pointRadius:2,tension:0.3,fill:false,spanGaps:false},
{label:"Baseline Forecast (12M)",data:fcstLine,borderColor:"#C84B31",borderWidth:2.5,pointRadius:3,tension:0.3,fill:false,spanGaps:false,borderDash:[6,3]},
],{height:280,annotation:hist.length>0?histDates[histDates.length-1]:null});
// MAE table
document.getElementById("mae-tbody").innerHTML=(series.mae_table||[]).map((r,i)=>`
<tr>
<td>${r.Model}<span style="margin-left:6px">${(series.top_models||[]).includes(r.Model)?'<span class="pill pill-green">ensemble</span>':""}</span></td>
<td style="font-family:var(--mono)">${(+r.MAE).toLocaleString(undefined,{maximumFractionDigits:0})}</td>
<td><span class="pill ${i===0?"pill-green":i<3?"pill-blue":"pill-amber"}">#${i+1}</span></td>
</tr>`).join("");
// Forecast table
document.getElementById("forecast-tbody").innerHTML=fcst.map((r,i)=>{
const prev=i>0?fcst[i-1].volume:null;
const mom=prev&&prev>0?((r.volume-prev)/prev*100):null;
const momStr=mom!==null?`<span class="${mom>=0?"pill pill-green":"pill pill-red"}">${mom>=0?"+":""}${mom.toFixed(1)}%</span>`:"β€”";
return `<tr>
<td style="font-family:var(--mono)">${r.date}</td>
<td><span class="pill pill-blue">${r.fy}</span></td>
<td style="font-family:var(--mono);font-weight:600">${fmtNum(r.volume)}</td>
<td style="font-family:var(--mono)">${(+r.price).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</td>
<td style="font-family:var(--mono)">β‚Ή${fmtNum(r.value)}</td>
<td>${momStr}</td>
</tr>`;
}).join("");
}
function downloadForecast(){
const series=(forecastResult?.series||[])[activeSeriesIdx];
if(!series) return;
const rows=[["Date","FY","Forecast Volume","Price","Forecast Value","Design Brand","Account","Channel"]];
(series.forecast||[]).forEach(r=>rows.push([r.date,r.fy,r.volume,r.price,r.value,series.design_brand,series.account,series.channel]));
const csv=rows.map(r=>r.join(",")).join("\n");
const a=document.createElement("a"); a.href="data:text/csv;charset=utf-8,"+encodeURIComponent(csv);
a.download=`dabur_baseline_forecast_${series.design_brand}_${series.account}.csv`.replace(/\s+/g,"_");
a.click();
}
// ─── CHART HELPER ─────────────────────────────────────────────────────
function renderChart(id,type,labels,datasets,opts={}){
if(chartInstances[id]){chartInstances[id].destroy();}
const ctx=document.getElementById(id);
if(!ctx) return;
ctx.parentElement.style.height=(opts.height||260)+"px";
chartInstances[id]=new Chart(ctx,{
type,
data:{labels,datasets},
options:{
responsive:true,maintainAspectRatio:false,
animation:{duration:500},
plugins:{
legend:{display:opts.legend!==false,position:"top",labels:{boxWidth:10,font:{size:11,family:"DM Mono"},padding:12}},
tooltip:{backgroundColor:"#1A1916",titleFont:{size:11,family:"DM Mono"},bodyFont:{size:11,family:"DM Mono"},padding:10}
},
scales:{
x:{stacked:opts.stacked||false,ticks:{font:{size:10,family:"DM Mono"},maxRotation:45},grid:{display:false}},
y:{stacked:opts.stacked||false,ticks:{font:{size:10,family:"DM Mono"},callback:v=>fmtNum(v)},grid:{color:"#F4F3EF"}},
...(opts.indexAxis==="y"?{y:{stacked:false,ticks:{font:{size:10,family:"DM Mono"}}},x:{ticks:{font:{size:10,family:"DM Mono"},callback:v=>fmtNum(v)}}}:{}),
},
indexAxis:opts.indexAxis||"x",
}
});
}
// ─── UTILS ────────────────────────────────────────────────────────────
function fmtNum(n){
const v=+n||0;
if(Math.abs(v)>=1e6) return (v/1e6).toFixed(1)+"M";
if(Math.abs(v)>=1e3) return (v/1e3).toFixed(1)+"K";
return Math.round(v).toLocaleString();
}
// ─── DEMO SCRIPT (inline copy of v2) ──────────────────────────────────
function getDemoScript(){
return `# =============================================================================
# DABUR BASELINE FORECAST PIPELINE v2
# Reads from Excel Β· outputs JSON to stdout for the web app
# =============================================================================
import io, json, warnings, sys
import numpy as np
import pandas as pd
from statsforecast import StatsForecast
from statsforecast.models import (
AutoETS, AutoARIMA, AutoTheta,
MSTL, HoltWinters, SeasonalNaive, WindowAverage,
)
warnings.filterwarnings("ignore")
CONFIG = {
"channel_col":"Channel","category_col":"Category","division_col":"Division",
"brand_group_col":"Brand Group","account_col":"Account","key_col":"Key",
"design_brand_col":"Design Brand","date_col":"DATE","fy_col":"FY",
"volume_col":"Volume","price_col":"Price","value_col":"Value",
"driver_col":"Driver Level 3",
"baseline_label":"Baseline","actual_label":"Actual Volume",
"forecast_label":"Baseline Forecast",
"freq":"MS","season_length":12,"forecast_horizon":12,
"top_n_ensemble":3,"cv_horizon":6,"cv_windows":2,
"sheet_name":"Base Sheet",
}
SERIES_KEYS = ["Channel","Design Brand","Account"]
def load_data(source, cfg):
if isinstance(source, pd.DataFrame): return source.copy()
df = pd.read_excel(source, sheet_name=cfg["sheet_name"])
df[cfg["date_col"]] = pd.to_datetime(df[cfg["date_col"]], errors="coerce")
df[cfg["date_col"]] = df[cfg["date_col"]].dt.to_period("M").dt.to_timestamp()
return df
def get_fy(date):
yr = date.year+1 if date.month>=4 else date.year
return f"FY{str(yr)[2:]}"
def preprocess_long_to_wide(df, cfg):
dc,vc,pc,datec,bl = cfg["driver_col"],cfg["volume_col"],cfg["price_col"],cfg["date_col"],cfg["baseline_label"]
df = df[df[dc].isin([bl,cfg["actual_label"]])].copy()
dim_cols = [cfg["channel_col"],cfg["category_col"],cfg["division_col"],cfg["brand_group_col"],cfg["account_col"],cfg["key_col"],cfg["design_brand_col"]]
pivot = df.pivot_table(index=dim_cols+[datec],columns=dc,values=vc,aggfunc="first").reset_index()
pivot.columns.name=None
rename={datec:"ds"}
if bl in pivot.columns: rename[bl]="baseline"
if cfg["actual_label"] in pivot.columns: rename[cfg["actual_label"]]="actual"
pivot.rename(columns=rename,inplace=True)
price_map = df[df[dc]==bl].set_index(dim_cols+[datec])[pc].rename("price")
pivot = pivot.join(price_map,on=dim_cols+["ds"])
pivot.sort_values(dim_cols+["ds"],inplace=True); pivot.reset_index(drop=True,inplace=True)
pivot["unique_id"] = pivot[SERIES_KEYS].apply(lambda r:" | ".join(r.astype(str)),axis=1)
for col in ["baseline","actual"]:
if col in pivot.columns: pivot[col]=pivot[col].clip(lower=0)
return pivot
def check_gaps(s_df, uid):
full=pd.date_range(s_df["ds"].min(),s_df["ds"].max(),freq="MS")
missing=full.difference(s_df["ds"])
if len(missing):
fill=pd.DataFrame({"ds":missing})
s_df=pd.concat([s_df,fill],ignore_index=True).sort_values("ds").ffill()
return s_df.reset_index(drop=True)
def build_models(sl):
return [AutoETS(season_length=sl),AutoARIMA(season_length=sl),AutoTheta(season_length=sl),
HoltWinters(season_length=sl,error_type="A",alias="HWAdd"),HoltWinters(season_length=sl,error_type="M",alias="HWMult"),
MSTL(season_length=sl,trend_forecaster=AutoTheta(),alias="MSTL_Theta"),SeasonalNaive(season_length=sl)]
def cross_validate_series(sf_df, cfg):
sf=StatsForecast(models=build_models(cfg["season_length"]),freq=cfg["freq"],fallback_model=WindowAverage(window_size=6),n_jobs=1)
cv=sf.cross_validation(df=sf_df,h=cfg["cv_horizon"],n_windows=cfg["cv_windows"],step_size=3)
model_cols=[c for c in cv.columns if c not in ["unique_id","ds","cutoff","y"]]
mae={m:np.mean(np.abs(cv[m]-cv["y"])) for m in model_cols}
return pd.DataFrame.from_dict(mae,orient="index",columns=["MAE"]).sort_values("MAE").reset_index().rename(columns={"index":"Model"})
def forecast_series(sf_df, top_models, cfg):
sf=StatsForecast(models=build_models(cfg["season_length"]),freq=cfg["freq"],fallback_model=WindowAverage(window_size=6),n_jobs=1)
fcst=sf.forecast(df=sf_df,h=cfg["forecast_horizon"])
if "ds" not in fcst.columns: fcst.reset_index(inplace=True)
available=[m for m in top_models if m in fcst.columns]
fcst["ensemble_baseline_forecast"]=fcst[available].mean(axis=1).clip(lower=0)
return fcst
def build_output_rows(wide_df, fcst_df, dim_meta, cfg):
last_price=wide_df["price"].dropna().tail(3).mean()
rows=[]
for _,row in fcst_df.iterrows():
vol=round(row["ensemble_baseline_forecast"],6); price=round(last_price,7); value=round(vol*price,3)
rows.append({cfg["channel_col"]:dim_meta[cfg["channel_col"]],cfg["category_col"]:dim_meta[cfg["category_col"]],
cfg["division_col"]:dim_meta[cfg["division_col"]],cfg["brand_group_col"]:dim_meta[cfg["brand_group_col"]],
cfg["account_col"]:dim_meta[cfg["account_col"]],cfg["key_col"]:dim_meta[cfg["key_col"]],
cfg["design_brand_col"]:dim_meta[cfg["design_brand_col"]],cfg["date_col"]:row["ds"].strftime("%Y-%m-%d"),
cfg["fy_col"]:get_fy(row["ds"]),cfg["volume_col"]:vol,cfg["price_col"]:price,cfg["value_col"]:value,
cfg["driver_col"]:cfg["forecast_label"]})
return pd.DataFrame(rows)
def run_pipeline(source, cfg=CONFIG):
raw_df=load_data(source,cfg)
wide_df=preprocess_long_to_wide(raw_df,cfg)
series_list=wide_df["unique_id"].unique().tolist()
all_forecast_rows=[]; mae_summary=[]; series_results=[]
for uid in series_list:
s_df=wide_df[wide_df["unique_id"]==uid].copy(); s_df=check_gaps(s_df,uid)
sf_input=s_df[["unique_id","ds","baseline"]].rename(columns={"baseline":"y"})
mae_df=cross_validate_series(sf_input,cfg)
top_models=mae_df["Model"].head(cfg["top_n_ensemble"]).tolist()
mae_df["series"]=uid; mae_summary.append(mae_df)
fcst_df=forecast_series(sf_input,top_models,cfg)
dim_meta=s_df.iloc[0][[cfg["channel_col"],cfg["category_col"],cfg["division_col"],cfg["brand_group_col"],cfg["account_col"],cfg["key_col"],cfg["design_brand_col"]]].to_dict()
out_rows=build_output_rows(s_df,fcst_df,dim_meta,cfg)
all_forecast_rows.append(out_rows)
hist=s_df[["ds","baseline","actual","price"]].copy()
hist["ds"]=hist["ds"].dt.strftime("%Y-%m-%d")
series_results.append({
"uid":uid,"channel":dim_meta[cfg["channel_col"]],"design_brand":dim_meta[cfg["design_brand_col"]],
"account":dim_meta[cfg["account_col"]],"top_models":top_models,
"mae_table":mae_df[["Model","MAE"]].round(2).to_dict(orient="records"),
"history":hist.rename(columns={"ds":"date","baseline":"baseline_vol","actual":"actual_vol","price":"price"}).to_dict(orient="records"),
"forecast":[{"date":r[cfg["date_col"]],"fy":r[cfg["fy_col"]],"volume":round(r[cfg["volume_col"]],2),"price":round(r[cfg["price_col"]],4),"value":round(r[cfg["value_col"]],0)} for _,r in out_rows.iterrows()],
"total_forecast_vol":round(out_rows[cfg["volume_col"]].sum(),2),
})
decomp_drivers=raw_df[~raw_df[cfg["driver_col"]].isin([cfg["actual_label"],"Predicted Volume"])].copy()
decomp_drivers["DATE_str"]=decomp_drivers[cfg["date_col"]].dt.strftime("%Y-%m-%d")
decomp_records=decomp_drivers[[cfg["channel_col"],cfg["design_brand_col"],cfg["account_col"],"DATE_str",cfg["fy_col"],"Driver Level 1","Driver Level 2",cfg["driver_col"],cfg["volume_col"],cfg["price_col"],cfg["value_col"]]].rename(columns={cfg["channel_col"]:"channel",cfg["design_brand_col"]:"design_brand",cfg["account_col"]:"account","DATE_str":"date",cfg["fy_col"]:"fy",cfg["driver_col"]:"driver_l3",cfg["volume_col"]:"volume",cfg["price_col"]:"price",cfg["value_col"]:"value"}).to_dict(orient="records")
actual_records=raw_df[raw_df[cfg["driver_col"]]==cfg["actual_label"]].copy()
actual_records["DATE_str"]=actual_records[cfg["date_col"]].dt.strftime("%Y-%m-%d")
actual_out=actual_records[[cfg["design_brand_col"],cfg["account_col"],"DATE_str",cfg["volume_col"]]].rename(columns={cfg["design_brand_col"]:"design_brand",cfg["account_col"]:"account","DATE_str":"date",cfg["volume_col"]:"actual_volume"}).to_dict(orient="records")
output={"status":"success","n_series":len(series_list),"series_ids":series_list,"series":series_results,"decomp_data":decomp_records,"actual_data":actual_out}
print(json.dumps(output))
return output
if __name__=="__main__":
src=sys.argv[1] if len(sys.argv)>1 else None
if src: run_pipeline(src)
`;
}
// ─── BOOT ─────────────────────────────────────────────────────────────
initPyodide();
</script>
</body>
</html>