| <!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"/> |
| |
| <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> |
|
|
| |
| |
| |
| <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> |
|
|
|
|
| |
| |
| |
| <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> |
|
|
|
|
| |
| |
| |
| <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> |
|
|
| |
| <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 & Visualise</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
|
|
| |
| |
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
|
|
| |
| |
| |
| <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"> |
| |
| <div class="forecast-summary" id="p3-kpis" style="margin-bottom:16px"></div> |
|
|
| |
| <div class="series-tabs" id="p3-series-tabs"></div> |
|
|
| |
| <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> |
|
|
|
|
| |
| |
| |
| <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> |
|
|
|
|
| |
| <script> |
| |
| const SERVER_URL = ""; |
| let decompData = null, actualData = null; |
| let forecastResult = null; |
| let activeSeriesIdx = 0; |
| let dataFile = null; |
| let chartInstances = {}; |
| let currentUser = null; |
| |
| 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"]; |
| |
| |
| 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); |
| |
| (async function boot(){ |
| |
| 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); |
| } |
| |
| } catch(e){ } |
| })(); |
| |
| 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){ |
| |
| 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"){ |
| |
| 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(){ |
| |
| 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){ } |
| } |
| |
| |
| 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; |
| } |
| |
| |
| function goPage(id,el){ |
| |
| 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"); |
| } |
| |
| |
| 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"; |
| } |
| |
| |
| |
| |
| 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); |
| |
| clog("Β» Sending file to server for parsingβ¦","info"); |
| |
| 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"); |
| |
| |
| 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"); |
| |
| 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 |
| })); |
| |
| 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; |
| |
| 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)); |
| |
| |
| 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)+"%"; |
| |
| |
| 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)); |
| |
| |
| 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}); |
| |
| |
| 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}); |
| |
| |
| 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}); |
| |
| |
| 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(""); |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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β¦"; |
| |
| |
| 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)); |
| |
| 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"; |
| } |
| |
| |
| |
| |
| 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); |
| |
| |
| 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> |
| `; |
| |
| |
| 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}`; |
| |
| |
| document.getElementById("p3-top-models").innerHTML=(series.top_models||[]).map(m=>`<span class="model-badge">${m}</span>`).join(""); |
| |
| |
| 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)]; |
| |
| 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}); |
| |
| |
| 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(""); |
| |
| |
| 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(); |
| } |
| |
| |
| 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", |
| } |
| }); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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) |
| `; |
| } |
| |
| |
| initPyodide(); |
| </script> |
| </body> |
| </html> |
|
|