Hugdrigo's picture
Upload 3 files
3a9a28e verified
Raw History Blame Contribute Delete
53.5 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#080d19">
<meta name="description" content="FlowPilot: an open-source, reproducible operations digital twin for simulating staffing decisions, queue risk and total cost.">
<title>FlowPilot | Operations Digital Twin</title>
<style>
:root{color-scheme:dark;--bg:#080d19;--surface:#0d1423;--panel:#101827;--panel2:#111b2b;--stroke:#243148;--muted:#8695b1;--text:#f1f5ff;--sub:#b5bfd5;--accent:#50e3bd;--violet:#a79af8;--orange:#ffb57b;--danger:#fa929e;--sans:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;--shadow:0 18px 50px #00000021}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 var(--sans)}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}a{color:inherit;text-decoration:none}.skip-link{position:absolute;top:-60px;left:15px;padding:12px;background:var(--accent);z-index:30;color:#06151a}.skip-link:focus{top:10px}:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.app-shell{display:flex;min-height:100vh;max-width:1900px;margin:auto}.sidebar{width:306px;min-width:306px;background:#0b1220;border-right:1px solid var(--stroke);height:100vh;position:sticky;top:0;display:flex;flex-direction:column;z-index:3}.side-head{min-height:91px;border-bottom:1px solid var(--stroke);display:flex;align-items:center;justify-content:space-between;padding:24px 20px}.brand{display:flex;align-items:center;gap:11px}.brand-mark{font-size:41px;line-height:1;color:var(--accent);transform:rotate(22deg)}.brand-name{font-size:22px;font-weight:820;letter-spacing:-1.1px;line-height:1}.brand-name>span{color:var(--accent)}.brand-name small{display:block;font-size:7px;letter-spacing:2.0px;font-weight:750;color:var(--muted);margin-top:7px}.badge{display:inline-flex;align-items:center;border:1px solid #35514e;background:#102d2b;color:var(--accent);font-size:9px;font-weight:800;letter-spacing:1px;border-radius:4px;padding:6px}.side-scroll{padding:22px 20px 35px;overflow:auto;scrollbar-width:thin;scrollbar-color:#35445a transparent}.sidebar-section{padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--stroke)}.side-label{font-size:10px;letter-spacing:1.4px;color:#84a5af;font-weight:750;margin-bottom:13px}.sidebar label{display:block;color:#b8c3d8;font-size:11px;font-weight:700;margin-bottom:7px}.sidebar select,.sidebar input:not([type=file]){width:100%;border:1px solid #34425a;background:#101a2b;color:var(--text);border-radius:7px;padding:10px 11px;min-height:39px;outline:none}.sidebar select:focus,.sidebar input:focus{border-color:var(--accent)}.side-help{color:var(--muted);font-size:11px;margin:10px 0 0}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 11px}.field.full{grid-column:1/-1}.field label{font-size:10.5px;margin-bottom:6px}.field input{padding:8px 9px!important;font-size:13px}.row-split{display:flex;align-items:center;justify-content:space-between}.row-split .side-label{margin-bottom:6px}.text-button{border:0;background:none;color:var(--accent);font-size:11px;padding:2px 0}.profile-hint{margin:3px 0 12px}.hourly-inputs{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.hourly-item label{text-align:center;color:var(--muted);font-size:10px;margin:0 0 4px}.hourly-item input{text-align:center;padding:7px 2px!important;min-height:34px!important}.upload-inline{display:block;margin-top:13px!important;padding:10px;text-align:center;border-radius:7px;border:1px dashed #3e5364;cursor:pointer;color:var(--accent)!important}.upload-inline:hover{border-color:var(--accent)}.run-button{width:100%;border:0;display:flex;align-items:center;gap:9px;background:var(--accent);color:#062723;font-size:13px;font-weight:850;border-radius:8px;padding:13px 16px;box-shadow:0 10px 25px #48e1ba1c}.run-button:hover{filter:brightness(1.06)}.run-arrow{margin-left:auto}.form-error{background:#452332;border:1px solid #894256;border-radius:6px;color:#ffe1e4;padding:10px;font-size:12px}.privacy-box{margin-top:22px;display:flex;gap:11px;border:1px solid #2c4b48;border-radius:10px;padding:14px;background:#0c2426}.privacy-box>span{color:var(--accent);font-size:23px}.privacy-box strong{color:#c3f0e4;font-size:12px}.privacy-box p{font-size:11px;color:#a0bcba;margin:3px 0 0}.main{flex:1;min-width:0}.topbar{display:flex;align-items:center;justify-content:space-between;padding:24px 41px;border-bottom:1px solid var(--stroke);height:91px}.breadcrumbs{font-size:10px;letter-spacing:1.35px;font-weight:750;color:#7f8da6}.breadcrumbs span{padding:0 12px;color:#4c5c73}.breadcrumbs b{color:#c4cee0}.top-actions{display:flex;align-items:center;gap:20px}.status{color:#a7b4c9;font-size:10px;letter-spacing:.8px;font-weight:700}.status i{display:inline-block;height:7px;width:7px;background:var(--accent);border-radius:50%;box-shadow:0 0 8px #40e6b6;margin-right:5px}.mini-button{border:1px solid var(--stroke);background:transparent;color:var(--sub);font-size:19px;border-radius:7px;width:34px;height:34px}.content{max-width:1440px;margin:auto;padding:33px 40px 0}.hero{background:radial-gradient(circle at 83% 42%,#23544e50,transparent 37%),linear-gradient(119deg,#152538,#0e1c2a 65%,#16273b);border:1px solid #365565;border-radius:13px;position:relative;overflow:hidden;padding:32px 33px 23px;box-shadow:var(--shadow)}.hero:before{content:"";position:absolute;inset:0;opacity:.16;background-image:linear-gradient(90deg,#b4ddff11 1px,transparent 1px),linear-gradient(#b4ddff11 1px,transparent 1px);background-size:32px 32px;pointer-events:none}.hero-eyebrow{font-size:10px;letter-spacing:2.2px;font-weight:850;color:var(--accent);display:flex;align-items:center;gap:8px}.eyebrow-line{width:20px;height:2px;background:var(--accent)}.hero-grid{display:flex;justify-content:space-between;align-items:center;position:relative;gap:25px}.hero h1{font-size:clamp(27px,3.2vw,48px);line-height:1.13;letter-spacing:-1.65px;margin:18px 0 14px;font-weight:780}.hero h1 em{font-style:normal;color:var(--accent)}.hero p{color:#b6cbd7;max-width:620px;font-size:13px;line-height:1.75;margin:0 0 25px}.hero-symbol{position:relative;width:194px;height:156px;flex:0 0 194px;display:flex;justify-content:center;align-items:center}.hero-ring{position:absolute;border:1px solid #51e3bb47;border-radius:50%;transform:rotate(-28deg)}.r1{width:170px;height:90px}.r2{width:125px;height:147px;border-color:#57d8b42f;transform:rotate(55deg)}.hero-diamond{font-size:118px;font-weight:100;color:var(--accent);text-shadow:0 0 45px #4ceabb45;line-height:1}.hero-bottom{display:flex;gap:26px;border-top:1px solid #36526082;padding-top:18px;color:#a6c3ca;font-size:9px;font-weight:800;letter-spacing:1px;position:relative;flex-wrap:wrap}.hero-bottom span:before{content:"•";color:var(--accent);margin-right:9px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:15px;margin:35px 0 17px}.section-kicker{font-size:10px;letter-spacing:1.7px;color:#6eceba;font-weight:800;margin-bottom:6px}.section-heading h2,.method h2{font-size:22px;letter-spacing:-.65px;margin:0;font-weight:780}.heading-right{display:flex;gap:14px;align-items:center}.freshness{background:#12312e;border:1px solid #306257;border-radius:20px;padding:5px 10px;font-size:10px;color:var(--accent);font-weight:720}.freshness.stale{color:var(--orange);background:#31251e;border-color:#745039}.tiny{color:var(--muted);font-size:11px}.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.kpi{background:var(--panel);border:1px solid var(--stroke);border-radius:11px;padding:20px 19px;min-height:139px}.kpi-top{display:flex;align-items:center;justify-content:space-between;gap:6px;color:#9cacc4;font-size:10px;letter-spacing:.9px;font-weight:780}.kpi-icon{font-size:19px;color:var(--accent)}.kpi strong{display:block;font-size:32px;letter-spacing:-1.4px;font-weight:800;margin:9px 0 4px;line-height:1.15;font-variant-numeric:tabular-nums}.kpi-sub{font-size:11px;color:var(--muted);line-height:1.45}.section-heading.smaller{margin-top:39px}.section-heading.smaller p{color:var(--muted);font-size:11px;max-width:250px;text-align:right;margin:0}.chart-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}.panel{background:var(--panel);border:1px solid var(--stroke);border-radius:11px;min-width:0;overflow:hidden}.chart-grid .panel{padding:22px 20px 17px}.panel-head{display:flex;justify-content:space-between;align-items:start;gap:10px}.panel-overline{font-size:10px;letter-spacing:1.6px;color:#84a7bb;font-weight:800}.panel h3{margin:5px 0 0;font-size:16px;letter-spacing:-.25px;font-weight:760}.info-dot{color:#69879c;font-size:13px}.legend-chip{font-size:9px;font-weight:790;letter-spacing:.6px;color:#9db0c7;white-space:nowrap;margin-top:13px}.legend-chip i{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:6px}.cyan{background:var(--accent)}.purple{background:var(--violet)}.chart{height:240px;margin:12px 0 0}.chart svg{width:100%;height:100%;overflow:visible}.chart svg text{font:10px var(--sans);fill:var(--muted)}.chart .gridline{stroke:var(--stroke);stroke-dasharray:3 5}.chart .goal{stroke:var(--orange);stroke-dasharray:5 4}.chart-note{border-top:1px solid var(--stroke);padding-top:12px;font-size:11px;color:#9caec5;min-height:30px}.lower-grid{display:grid;grid-template-columns:1.08fr 1fr;gap:15px;margin-top:15px}.decision-panel,.risk-panel{padding:22px}.rec-badge{font-size:9px;letter-spacing:.8px;color:var(--muted);border:1px solid var(--stroke);padding:5px 8px;border-radius:5px}.rec-badge.good{color:var(--accent);border-color:#326354;background:#12332b}.rec-badge.warn{color:var(--orange);border-color:#785236;background:#34251f}.recommendation{min-height:171px;padding:16px 0}.recommendation p{color:var(--muted);font-size:12px}.rec-main{display:flex;align-items:center;gap:16px}.rec-count{font-size:58px;letter-spacing:-4px;color:var(--accent);line-height:1;font-weight:850}.rec-label{font-size:12px;color:var(--muted)}.rec-label strong{display:block;color:var(--text);font-size:15px}.rec-stats{display:flex;gap:9px;flex-wrap:wrap;margin:16px 0}.rec-stat{border:1px solid var(--stroke);border-radius:7px;padding:8px 11px;min-width:110px}.rec-stat small{font-size:9px;color:var(--muted);display:block}.rec-stat b{font-size:16px;letter-spacing:-.3px}.rec-explainer{font-size:11px;color:var(--muted);line-height:1.5;margin:7px 0}.decision-actions{display:flex;gap:9px;flex-wrap:wrap}.action-primary,.action-secondary{border-radius:7px;padding:10px 13px;font-size:12px;font-weight:750}.action-primary{border:0;background:var(--accent);color:#082922}.action-primary span{margin-left:14px}.action-secondary{border:1px solid #375069;color:#bed3e6;background:#152438}.action-secondary:hover{border-color:#7995aa}.risk-rows{min-height:172px;padding-top:15px}.risk-item{padding:12px 0;border-bottom:1px solid var(--stroke)}.risk-item:last-child{border:0}.risk-label{display:flex;justify-content:space-between;font-size:11px;color:var(--sub);margin-bottom:8px}.risk-label strong{font-size:12px;color:var(--text)}.risk-track{height:7px;border-radius:8px;background:#2a3a4b;position:relative}.risk-track .range{position:absolute;top:0;bottom:0;background:#6f73bb;border-radius:6px}.risk-track .median{position:absolute;top:-4px;width:3px;height:15px;background:var(--accent);border-radius:3px}.risk-footnote{font-size:10px;line-height:1.55;color:var(--muted);border-top:1px solid var(--stroke);padding-top:11px}.muted{color:var(--muted)}.table-panel{margin-top:15px}.table-panel .panel-head{padding:21px 22px 17px}.action-secondary.compact{padding:8px 11px;margin-top:2px}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums;white-space:nowrap}thead{background:#152133}th{text-align:left;font-size:10px;color:#8da3bc;letter-spacing:.75px;font-weight:850;padding:13px 19px}td{font-size:12px;padding:13px 19px;border-top:1px solid var(--stroke);color:#c3cce0}td:first-child{font-weight:850;color:var(--text)}tbody tr{transition:background .2s}tbody tr:hover{background:#19273b}tbody tr.baseline{background:#1a273b}tbody tr.recommended{background:#122c2b}.table-empty{text-align:center;padding:36px;color:var(--muted)}.row-tag{display:inline-block;font-size:9px;font-weight:800;letter-spacing:.4px;border-radius:4px;padding:3px 6px;color:#97bcf6;background:#1a3551}.row-tag.best{color:var(--accent);background:#1b3933}.row-tag.miss{color:#a2a9b8;background:#242a37}.method{display:grid;grid-template-columns:1.6fr 1fr;gap:50px;margin:48px 0 37px;border-top:1px solid var(--stroke);padding:30px 0 0}.method h2{font-size:28px;margin:9px 0 14px}.method p{font-size:12px;color:#9fb0c8;max-width:730px;line-height:1.8}.method-note strong{color:var(--sub)}.method-links{padding-top:4px}.method-links>div{padding:10px 0;border-bottom:1px solid var(--stroke)}.method-links span{font-size:9px;letter-spacing:1.2px;font-weight:800;color:#73cbb7;display:block}.method-links strong{font-size:12px;font-weight:650;color:#c4d0e1}.method-links>button,.import-scenario{border:1px solid var(--stroke);background:var(--panel);color:var(--sub);padding:8px 11px;border-radius:6px;margin:15px 8px 0 0;font-weight:750;font-size:11px;display:inline-block;cursor:pointer}.import-scenario:hover,.method-links>button:hover{border-color:var(--accent)}footer{border-top:1px solid var(--stroke);padding:20px 0 30px;color:#667893;display:flex;justify-content:space-between;gap:10px;font-size:9px;letter-spacing:.7px;font-weight:800}.toast{position:fixed;bottom:25px;right:25px;padding:11px 16px;background:#193a37;color:#b7f5e3;border:1px solid #3c8b7d;z-index:30;border-radius:8px;box-shadow:var(--shadow);font-size:12px;opacity:0;pointer-events:none;transform:translateY(9px);transition:.2s}.toast.show{opacity:1;transform:translateY(0)}.toast.error{background:#3b2228;color:#ffe3e6;border-color:#914855}
body.light{color-scheme:light;--bg:#f3f7fc;--surface:#fff;--panel:#fff;--panel2:#fff;--stroke:#dbe4ed;--muted:#61718a;--text:#101d31;--sub:#3f4c64;--accent:#178e77;--violet:#7062bd;--orange:#c47c25;--danger:#bb4055;--shadow:0 18px 50px #12334b12}body.light .sidebar{background:#f8fbff}body.light .sidebar input:not([type=file]),body.light .sidebar select{background:#fff;color:#26334c;border-color:#cad7e3}body.light .sidebar label{color:#35445a}body.light .side-label{color:#4b817e}body.light .privacy-box{background:#e0f7f0;border-color:#bee5d9}body.light .privacy-box strong{color:#135648}body.light .privacy-box p{color:#41665f}body.light .run-button,body.light .action-primary{color:#fff;background:#138c76}body.light .brand-name small{color:#71859e}body.light .kpi-top{color:#697b91}body.light .panel-overline{color:#55768a}body.light .legend-chip{color:#59708a}body.light .section-kicker{color:#178e77}body.light .hero{color:#fff}body.light .kpi-sub{color:#6c7c92}body.light .rec-label strong,body.light .rec-stat b{color:var(--text)}body.light .action-secondary{background:#eff5fa;color:#43566d}body.light thead{background:#edf3fa}body.light td{color:#42526a}body.light td:first-child{color:#101d31}body.light tbody tr:hover{background:#eff7f5}body.light tbody tr.baseline{background:#eff4fa}body.light tbody tr.recommended{background:#e3f5ec}body.light .row-tag{color:#22658e;background:#e6f1fa}body.light .row-tag.best{color:#147e65;background:#d8f5e9}body.light .method p{color:#687b92}body.light .method-links strong{color:#40536b}body.light .method-links span{color:#137d68}body.light .breadcrumbs b{color:#3c4d65}body.light .chart .gridline{stroke:#dbe4ed}body.light .badge{color:#137d68;background:#e5f9f1;border-color:#b3ddcf}body.light .freshness{background:#e6f7f0;color:#15745e;border-color:#c5e7da}body.light .freshness.stale{color:#9a6221;background:#fff0d9;border-color:#e7c596}
@media(max-width:1240px){.sidebar{width:283px;min-width:283px}.content{padding:26px 25px 0}.topbar{padding:24px 25px}.kpi{padding:17px 14px}.kpi strong{font-size:27px}.hero-symbol{width:135px;flex-basis:135px}.hero h1{font-size:32px}.chart-grid{gap:11px}.chart-grid .panel{padding:18px 13px}.legend-chip{font-size:8px}}
@media(max-width:1030px){.app-shell{display:block}.sidebar{width:100%;min-width:0;height:auto;position:static}.side-head{min-height:72px}.side-scroll{overflow:visible;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:17px 22px;padding:18px 22px}.sidebar-section{margin:0}.sidebar-section:nth-child(3){grid-row:span 2}.run-button,.privacy-box,.form-error{align-self:center}.content{padding:25px 22px 0}.topbar{height:68px}.kpis{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:720px){.side-scroll{grid-template-columns:1fr}.sidebar-section:nth-child(3){grid-row:auto}.topbar{padding:14px 18px}.breadcrumbs{font-size:9px}.status{font-size:0}.status i{font-size:10px}.content{padding:20px 14px 0}.hero{padding:24px 20px 18px}.hero h1{font-size:29px;letter-spacing:-1.1px}.hero-symbol{display:none}.hero-bottom{gap:9px}.hero-bottom span{font-size:8px}.section-heading{align-items:start}.section-heading.smaller p{display:none}.heading-right{flex-direction:column;align-items:flex-end;gap:5px}.kpis{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.kpi{min-height:122px}.kpi strong{font-size:27px}.chart-grid,.lower-grid{grid-template-columns:1fr}.chart{height:225px}.method{grid-template-columns:1fr;gap:14px}.method h2{font-size:24px}footer{flex-direction:column}.panel-head{flex-wrap:wrap}.section-heading h2{font-size:19px}.table-panel .panel-head{padding:17px}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
</style>
</head>
<body>
<a href="#workspace" class="skip-link">Skip to dashboard</a>
<div class="app-shell">
<aside class="sidebar">
<div class="side-head">
<a href="#workspace" class="brand" aria-label="FlowPilot dashboard"><span class="brand-mark">◈</span><span class="brand-name">flow<span>pilot</span><small>OPERATIONS INTELLIGENCE</small></span></a>
<span class="badge beta">PROJECT 23</span>
</div>
<div class="side-scroll">
<div class="sidebar-section">
<div class="side-label">01 / WORKSPACE</div>
<label for="preset">Scenario template</label>
<select id="preset">
<option value="retail">Retail & customer service</option>
<option value="health">Healthcare reception</option>
<option value="support">Technical support desk</option>
<option value="custom">Imported / custom scenario</option>
</select>
<div class="side-help">Choose a real-world operations case, then customize assumptions.</div>
</div>
<div class="sidebar-section">
<div class="side-label">02 / SERVICE MODEL</div>
<div class="field-grid">
<div class="field"><label for="agents">Staff members</label><input id="agents" type="number" min="1" max="20" step="1"></div>
<div class="field"><label for="patience">Patience · min</label><input id="patience" type="number" min="1" max="180" step="1"></div>
<div class="field"><label for="serviceMin">Service min · min</label><input id="serviceMin" type="number" min="0.2" max="90" step="0.5"></div>
<div class="field"><label for="serviceMode">Most likely · min</label><input id="serviceMode" type="number" min="0.2" max="90" step="0.5"></div>
<div class="field"><label for="serviceMax">Service max · min</label><input id="serviceMax" type="number" min="0.2" max="90" step="0.5"></div>
<div class="field"><label for="slaMinutes">SLA within · min</label><input id="slaMinutes" type="number" min="0" max="120" step="1"></div>
</div>
</div>
<div class="sidebar-section">
<div class="row-split"><div class="side-label">03 / HOURLY ARRIVALS</div><button id="downloadCsvTemplate" class="text-button" type="button">CSV template ↗</button></div>
<div class="side-help profile-hint">Expected arrivals in each hour of an 8-hour shift.</div>
<div id="hourlyInputs" class="hourly-inputs" aria-label="Expected arrivals per hour"></div>
<label class="upload-inline" for="uploadCsv">↑ Import 8-hour demand CSV <input id="uploadCsv" type="file" accept=".csv,text/csv" hidden></label>
</div>
<div class="sidebar-section">
<div class="side-label">04 / GOALS & COSTS</div>
<div class="field-grid">
<div class="field"><label for="slaGoal">SLA goal · %</label><input id="slaGoal" type="number" min="1" max="100" step="1"></div>
<div class="field"><label for="wage">Wage · $/hr</label><input id="wage" type="number" min="0" max="500" step="1"></div>
<div class="field"><label for="abandonmentCost">Lost customer · $</label><input id="abandonmentCost" type="number" min="0" max="500" step="1"></div>
<div class="field"><label for="waitingCost">Wait cost · $/min</label><input id="waitingCost" type="number" min="0" max="20" step="0.01"></div>
</div>
</div>
<div class="sidebar-section">
<div class="side-label">05 / SIMULATION SETTINGS</div>
<div class="field-grid">
<div class="field"><label for="trials">Replications</label><input id="trials" type="number" min="10" max="300" step="10"></div>
<div class="field"><label for="seed">Random seed</label><input id="seed" type="number" min="0" max="4294967295" step="1"></div>
<div class="field full"><label for="optimizerMaxAgents">Staffing search maximum</label><input id="optimizerMaxAgents" type="number" min="2" max="20" step="1"></div>
</div>
</div>
<button id="run" class="run-button" type="button"><span>▶</span> Run simulation <span class="run-arrow">↗</span></button>
<p id="formError" class="form-error" role="alert" hidden></p>
<div class="privacy-box"><span>♧</span><div><strong>Local by design</strong><p>No backend, tracking, API keys or uploads. Data stays in your browser.</p></div></div>
</div>
</aside>
<main id="workspace" class="main">
<header class="topbar"><div class="breadcrumbs">WORKSPACE <span>/</span> <b>OPERATIONS DIGITAL TWIN</b></div><div class="top-actions"><span class="status"><i></i> BROWSER COMPUTE</span><button id="themeToggle" class="mini-button" type="button" aria-label="Toggle light or dark theme">☼</button></div></header>
<div class="content">
<div class="hero">
<div class="hero-eyebrow"><span class="eyebrow-line"></span> DECISION SCIENCE / 001</div>
<div class="hero-grid"><div><h1>Turn operational uncertainty<br><em>into better decisions.</em></h1><p>Simulate demand, test staffing strategies and understand the trade-offs between service quality, customer abandonment and cost.</p></div><div class="hero-symbol" aria-hidden="true"><div class="hero-ring r1"></div><div class="hero-ring r2"></div><div class="hero-diamond">◇</div></div></div>
<div class="hero-bottom"><span>DISCRETE-EVENT SIMULATION</span><span>SEEDED MONTE CARLO</span><span>REPRODUCIBLE ANALYSIS</span></div>
</div>
<div class="section-heading"><div><div class="section-kicker">01 — OVERVIEW</div><h2>Performance snapshot</h2></div><div class="heading-right"><span id="freshness" class="freshness">● Ready to analyze</span><span id="runMeta" class="tiny">80 runs · 8-hour shift</span></div></div>
<div class="kpis">
<div class="kpi"><div class="kpi-top"><span>SERVICE LEVEL</span><span class="kpi-icon">◎</span></div><strong id="kpiSla">—</strong><div id="kpiSlaSub" class="kpi-sub">Customers served within SLA</div></div>
<div class="kpi"><div class="kpi-top"><span>TOTAL EXPECTED COST</span><span class="kpi-icon">$</span></div><strong id="kpiCost">—</strong><div id="kpiCostSub" class="kpi-sub">Across an 8-hour shift</div></div>
<div class="kpi"><div class="kpi-top"><span>AVERAGE WAIT</span><span class="kpi-icon">◷</span></div><strong id="kpiWait">—</strong><div id="kpiWaitSub" class="kpi-sub">Among served customers</div></div>
<div class="kpi"><div class="kpi-top"><span>CUSTOMER ABANDONMENT</span><span class="kpi-icon">↘</span></div><strong id="kpiAbandon">—</strong><div id="kpiAbandonSub" class="kpi-sub">Expected customers lost</div></div>
</div>
<div class="section-heading smaller"><div><div class="section-kicker">02 — INTELLIGENCE</div><h2>Staffing frontier</h2></div><p>Each point uses the same simulated arrival cohorts for fair comparison.</p></div>
<div class="chart-grid">
<section class="panel"><div class="panel-head"><div><div class="panel-overline">ECONOMIC TRADE-OFF</div><h3>Expected shift cost <span class="info-dot" title="Wages + overtime work + waiting minutes + customer abandonment">ⓘ</span></h3></div><span class="legend-chip"><i class="cyan"></i> TOTAL COST</span></div><div id="costChart" class="chart" role="img" aria-label="Expected total shift cost by staffing level"></div><div id="costSummary" class="chart-note">Run simulation to view the optimization curve.</div></section>
<section class="panel"><div class="panel-head"><div><div class="panel-overline">CUSTOMER EXPERIENCE</div><h3>Service-level performance <span class="info-dot" title="Share of all arrivals who begin service within the SLA threshold">ⓘ</span></h3></div><span class="legend-chip"><i class="purple"></i> SLA ATTAINMENT</span></div><div id="slaChart" class="chart" role="img" aria-label="Service-level attainment by staffing level, with SLA goal"></div><div id="slaSummary" class="chart-note">Choose a service-level target and compare options.</div></section>
</div>
<div class="lower-grid">
<section class="panel decision-panel"><div class="panel-head"><div><div class="panel-overline">03 — DECISION SUPPORT</div><h3>Recommended strategy</h3></div><span id="recBadge" class="rec-badge">WAITING FOR RUN</span></div><div id="recommendation" class="recommendation"><p>Use <strong>Run simulation</strong> to find the cheapest staffing level that achieves your SLA target.</p></div><div class="decision-actions"><button type="button" id="applyRecommended" class="action-primary" disabled>Apply recommendation <span>↗</span></button><button type="button" id="exportJson" class="action-secondary" disabled>↓ Export report</button></div></section>
<section class="panel risk-panel"><div class="panel-head"><div><div class="panel-overline">04 — UNCERTAINTY</div><h3>Variability across simulations</h3></div><span class="legend-chip">P10–P90 BAND</span></div><div id="riskRows" class="risk-rows"><p class="muted">Replicated simulation output will appear here.</p></div><div class="risk-footnote">P10–P90 is the middle 80% of simulated outcomes. It is not a guarantee or a confidence interval for future performance.</div></section>
</div>
<section class="panel table-panel"><div class="panel-head"><div><div class="panel-overline">05 — ALL ALTERNATIVES</div><h3>Staffing comparison</h3></div><button type="button" id="exportCsv" class="action-secondary compact" disabled>↓ Download CSV</button></div><div class="table-wrap"><table><thead><tr><th>AGENTS</th><th>SLA</th><th>AVG WAIT</th><th>LOST / SHIFT</th><th>UTILIZATION</th><th>TOTAL COST</th><th>FLAG</th></tr></thead><tbody id="comparisonBody"><tr><td colspan="7" class="table-empty">Run the simulation to generate comparison data.</td></tr></tbody></table></div></section>
<section class="method"><div><div class="section-kicker">HOW IT WORKS</div><h2>Transparent by design.</h2><p>This model assumes hourly Poisson arrivals, triangular service duration, first-come-first-served queues, fixed customer patience and fixed staffing. It simulates an 8-hour shift with optional overtime, compares identical sampled cohorts across staffing levels and reports cost in illustrative USD.</p><p class="method-note"><strong>Important:</strong> This is a planning sandbox with synthetic demand, not an AI predictor or a validated production forecast. Check assumptions against actual operations before making decisions. For zero-arrival shifts the modeled SLA is defined as 100%.</p></div><div class="method-links"><div><span>SIMULATION ENGINE</span><strong>Deterministic seeded Monte Carlo</strong></div><div><span>SERVICE DISCIPLINE</span><strong>FCFS + fixed abandonment patience</strong></div><div><span>MODEL VERSION</span><strong>v1.0 · MIT · local execution</strong></div><button id="exportScenario" type="button">↓ Save scenario</button><label for="importScenario" class="import-scenario">↑ Load scenario <input id="importScenario" type="file" accept=".json,application/json" hidden></label></div></section>
<footer><span>FLOWPILOT © 2026 · BUILT FOR TRANSPARENT OPERATIONS RESEARCH</span><span>PROJECT 23 / OPEN SOURCE PORTFOLIO</span></footer>
</div>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script>
/* CORE: deterministic simulations */
/** FlowPilot 1.0: deterministic browser-native operations simulation. MIT. */
const SHIFT_HOURS = 8;
const MODEL_VERSION = '1.0.0';
const EPS = 1e-10;
function makeRng(seed) {
let state = (Number(seed) >>> 0);
return () => {
state = (state + 0x6D2B79F5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function triangular(rng, low, mode, high) {
if (low === high) return low;
const u = rng();
const p = (mode - low) / (high - low);
return u < p ? low + Math.sqrt(u * (high - low) * (mode - low))
: high - Math.sqrt((1 - u) * (high - low) * (high - mode));
}
function normalizeConfig(obj = {}) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('Scenario must be an object.');
const num = (v, key, min, max, integer = false) => {
const n = Number(v);
if (v === '' || v === null || v === undefined || !Number.isFinite(n) || n < min || n > max || (integer && !Number.isInteger(n))) {
throw new Error(`${key} must be ${integer ? 'an integer' : 'a number'} between ${min} and ${max}.`);
}
return n;
};
const profile = obj.hourlyArrivals;
if (!Array.isArray(profile) || profile.length !== SHIFT_HOURS) throw new Error('Hourly demand needs exactly eight numbers.');
const cfg = {
name: typeof obj.name === 'string' ? obj.name.slice(0, 80) : 'Custom scenario',
hourlyArrivals: profile.map((x, i) => num(x, `Hour ${i + 1} arrivals`, 0, 140)),
agents: num(obj.agents, 'Agents', 1, 20, true),
serviceMin: num(obj.serviceMin, 'Minimum service time', 0.2, 90),
serviceMode: num(obj.serviceMode, 'Most likely service time', 0.2, 90),
serviceMax: num(obj.serviceMax, 'Maximum service time', 0.2, 90),
patience: num(obj.patience, 'Patience', 1, 180),
slaMinutes: num(obj.slaMinutes, 'SLA wait threshold', 0, 120),
slaGoal: num(obj.slaGoal, 'Target SLA', 1, 100),
wage: num(obj.wage, 'Hourly wage', 0, 500),
abandonmentCost: num(obj.abandonmentCost, 'Abandonment cost', 0, 500),
waitingCost: num(obj.waitingCost, 'Waiting cost per minute', 0, 20),
seed: num(obj.seed, 'Random seed', 0, 4294967295, true),
trials: num(obj.trials, 'Simulation runs', 10, 300, true),
optimizerMaxAgents: num(obj.optimizerMaxAgents ?? 12, 'Maximum staffing levels', 2, 20, true),
};
if (!(cfg.serviceMin <= cfg.serviceMode && cfg.serviceMode <= cfg.serviceMax)) {
throw new Error('Service duration must follow minimum ≤ most likely ≤ maximum.');
}
return cfg;
}
// Piecewise-constant Poisson arrivals per hour; deterministic service draws per customer.
// All staffing alternatives use the SAME customers, arrival times and service durations.
function generateCohort(config, seed) {
const cfg = normalizeConfig(config);
const rng = makeRng(seed);
const customers = [];
for (let h = 0; h < SHIFT_HOURS; h++) {
const rate = cfg.hourlyArrivals[h] / 60;
if (rate === 0) continue;
let minute = h * 60;
const end = (h + 1) * 60;
while (true) {
minute += -Math.log(Math.max(EPS, 1 - rng())) / rate;
if (minute >= end) break;
customers.push({arrival: minute, service: triangular(rng, cfg.serviceMin, cfg.serviceMode, cfg.serviceMax)});
}
}
return customers;
}
// O(n*c), simple and auditable; typical c <= 20, n <= 1120 per run.
// Fixed patience implies abandonment deadlines preserve arrival order.
function simulateCohort(customers, config, agents = config.agents) {
const cfg = normalizeConfig(config);
if (!Number.isInteger(agents) || agents < 1 || agents > 20) throw new Error('Agents must be between 1 and 20.');
let previousArrival = -Infinity;
const ready = Array(agents).fill(0);
let served = 0, abandoned = 0, withinSla = 0;
let waitMinutes = 0, abandonedWaitMinutes = 0, busyInShift = 0, busyOvertime = 0;
const waits = [];
let lastServiceFinish = 0;
for (const customer of customers) {
const t = customer.arrival, duration = customer.service;
if (!Number.isFinite(t) || t < previousArrival || t < 0 || t >= 480 || !Number.isFinite(duration) || duration <= 0) {
throw new Error('Customer data is invalid or not sorted.');
}
previousArrival = t;
let best = 0;
for (let i = 1; i < ready.length; i++) if (ready[i] < ready[best]) best = i;
const start = Math.max(t, ready[best]);
const delay = start - t;
if (delay > cfg.patience + EPS) {
abandoned++;
abandonedWaitMinutes += cfg.patience;
continue;
}
served++;
waitMinutes += delay;
waits.push(delay);
if (delay <= cfg.slaMinutes + EPS) withinSla++;
const finish = start + duration;
ready[best] = finish;
lastServiceFinish = Math.max(lastServiceFinish, finish);
busyInShift += Math.max(0, Math.min(finish, 480) - Math.min(Math.max(start, 0), 480));
busyOvertime += Math.max(0, finish - Math.max(start, 480));
}
waits.sort((a, b) => a - b);
const n = customers.length;
const meanWait = served ? waitMinutes / served : 0;
const staffCost = agents * 8 * cfg.wage + busyOvertime / 60 * cfg.wage * 1.5;
const delayCost = (waitMinutes + abandonedWaitMinutes) * cfg.waitingCost;
const churnCost = abandoned * cfg.abandonmentCost;
const totalCost = staffCost + delayCost + churnCost;
return {
agents, arrivals: n, served, abandoned, withinSla,
serviceLevel: n ? withinSla / n * 100 : 100,
abandonmentRate: n ? abandoned / n * 100 : 0,
meanWait, p90Wait: percentile(waits, 90),
utilization: busyInShift / (agents * 480) * 100,
overtimeMinutes: Math.max(0, lastServiceFinish - 480),
staffCost, delayCost, churnCost, totalCost,
};
}
function mean(values) { return values.length ? values.reduce((a,b) => a+b,0) / values.length : 0; }
function percentile(sortedAscending, p) {
if (!sortedAscending.length) return 0;
const point = (sortedAscending.length - 1) * p / 100;
const a = Math.floor(point), b = Math.ceil(point);
return sortedAscending[a] + (sortedAscending[b] - sortedAscending[a]) * (point - a);
}
const MEASURES = ['arrivals','served','abandoned','withinSla','serviceLevel','abandonmentRate','meanWait','p90Wait','utilization','overtimeMinutes','staffCost','delayCost','churnCost','totalCost'];
function summarize(rows, agents) {
const out = { agents, trials: rows.length };
for (const key of MEASURES) {
const values = rows.map(r => r[key]);
out[key] = mean(values);
const sorted = values.slice().sort((a,b) => a-b);
out[key + 'P10'] = percentile(sorted, 10);
out[key + 'P90'] = percentile(sorted, 90);
}
return out;
}
function analyzeScenario(rawConfig) {
const cfg = normalizeConfig(rawConfig);
const options = [...new Set([...Array.from({length:cfg.optimizerMaxAgents},(_,i)=>i+1),cfg.agents])].sort((a,b)=>a-b);
const rowsByAgents = new Map(options.map(n => [n, []]));
for (let i = 0; i < cfg.trials; i++) {
const trialSeed = (cfg.seed + Math.imul(i, 0x9E3779B9)) >>> 0;
const cohort = generateCohort(cfg, trialSeed);
for (const agents of options) rowsByAgents.get(agents).push(simulateCohort(cohort, cfg, agents));
}
const curve = options.map(agents => summarize(rowsByAgents.get(agents), agents));
const baseline = curve.find(x=>x.agents===cfg.agents);
// Cheapest expected total cost satisfying SLA. Mean SLA target is a planning assumption,
// not a probabilistic service-level guarantee.
const feasible = curve.filter(x=>x.serviceLevel + EPS >= cfg.slaGoal);
feasible.sort((a,b)=>a.totalCost-b.totalCost || a.agents-b.agents);
const recommended = feasible[0] || null;
const cheapest = curve.reduce((a,b)=>b.totalCost<a.totalCost ? b:a);
return {version: MODEL_VERSION, config: cfg, baseline, recommended, cheapest, curve, feasibleCount: feasible.length, generatedAt: new Date().toISOString()};
}
function parseHourlyCsv(csv) {
if (typeof csv !== 'string' || csv.length > 50000) throw new Error('The CSV is missing or too large.');
const lines = csv.replace(/^\uFEFF/, '').trim().split(/\r?\n/).filter(Boolean).map(x=>x.split(',').map(v=>v.trim()));
if (lines.length !== 9) throw new Error('CSV needs a header and eight rows.');
if (lines[0].join(',').toLowerCase() !== 'hour,arrivals') throw new Error('CSV header must be: hour,arrivals');
const hours = lines.slice(1).map(x=>Number(x[0]));
if (!hours.every(Number.isInteger) || new Set(hours).size !== 8) throw new Error('Hours must be unique integers.');
const zeroBased = hours.every(x=>x>=0&&x<=7);
const oneBased = hours.every(x=>x>=1&&x<=8);
if (!zeroBased && !oneBased) throw new Error('Hours must be 1–8 or 0–7.');
const result = Array(8).fill(null);
for(const cells of lines.slice(1)) {
if(cells.length !== 2 || cells[1] === '') throw new Error('Every row needs an hour and arrival count.');
const h = Number(cells[0]) - (zeroBased ? 0 : 1);
const n = Number(cells[1]);
if (!Number.isFinite(n) || n < 0 || n > 140) throw new Error('Hourly arrivals must be between 0 and 140.');
result[h] = n;
}
if (result.some(x=>x===null)) throw new Error('All eight hours are required.');
return result;
}
function toCsv(analysis) {
const columns = ['agents','serviceLevel','meanWait','p90Wait','abandonmentRate','utilization','arrivals','abandoned','staffCost','delayCost','churnCost','totalCost'];
return [columns.join(','), ...analysis.curve.map(x=>columns.map(k=>Number.isFinite(x[k]) ? x[k].toFixed(4) : x[k]).join(','))].join('\n');
}
/* DEMONSTRATION PRESETS */
const PRESETS = {
retail: {
name: 'Retail & customer service', hourlyArrivals: [12,16,24,40,33,26,18,10],
agents: 4, serviceMin: 3, serviceMode: 6, serviceMax: 11,
patience: 18, slaMinutes: 8, slaGoal: 85,
wage: 19, abandonmentCost: 35, waitingCost: 0.10,
seed: 23061, trials: 80, optimizerMaxAgents: 12,
},
health: {
name: 'Healthcare reception', hourlyArrivals: [8,12,19,22,28,22,14,10],
agents: 4, serviceMin: 6, serviceMode: 9, serviceMax: 16,
patience: 30, slaMinutes: 12, slaGoal: 90,
wage: 25, abandonmentCost: 65, waitingCost: 0.22,
seed: 23062, trials: 80, optimizerMaxAgents: 12,
},
support: {
name: 'Technical support desk', hourlyArrivals: [10,12,14,17,16,23,25,15],
agents: 5, serviceMin: 5, serviceMode: 9, serviceMax: 20,
patience: 25, slaMinutes: 10, slaGoal: 85,
wage: 24, abandonmentCost: 50, waitingCost: 0.15,
seed: 23063, trials: 80, optimizerMaxAgents: 12,
},
};
/* USER INTERFACE */
const $ = id => document.getElementById(id);
const FIELDS = ['agents','serviceMin','serviceMode','serviceMax','patience','slaMinutes','slaGoal','wage','abandonmentCost','waitingCost','seed','trials','optimizerMaxAgents'];
const fmtNum = (n, d=1) => Number(n).toLocaleString('en-US',{maximumFractionDigits:d,minimumFractionDigits:d});
const usd = n => '$' + Number(n).toLocaleString('en-US',{maximumFractionDigits:0,minimumFractionDigits:0});
const pct = n => fmtNum(n,1) + '%';
let analysis = null;
let toastTimer = null;
let isStale = false;
function notify(message, error=false) {
const toast=$('toast');toast.textContent=message;toast.classList.toggle('error',error);toast.classList.add('show');
clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.classList.remove('show'),3500);
}
function demandInputs(){
const box=$('hourlyInputs');
for(let i=0;i<8;i++){
const div=document.createElement('div');div.className='hourly-item';
const label=document.createElement('label');label.htmlFor=`h${i}`;label.textContent=`H${i+1}`;
const input=document.createElement('input');input.id=`h${i}`;input.type='number';input.min='0';input.max='140';input.step='1';input.setAttribute('aria-label',`Expected arrivals, hour ${i+1}`);
input.addEventListener('input',markStale);
div.append(label,input);box.append(div);
}
}
function applyConfig(config,presetKey='custom'){
for(const field of FIELDS) $(field).value = config[field];
for(let i=0;i<8;i++) $(`h${i}`).value=config.hourlyArrivals[i];
$('preset').value=presetKey;
markStale();
}
function readConfig(){
const raw={name:$('preset').selectedOptions[0]?.textContent || 'Custom scenario'};
for(const field of FIELDS) raw[field]=$(field).value;
raw.hourlyArrivals=Array.from({length:8},(_,i)=>$(`h${i}`).value);
return normalizeConfig(raw);
}
function markStale(){
if(!analysis)return;
isStale=true;$('freshness').textContent='● Settings changed · run again';$('freshness').classList.add('stale');
$('applyRecommended').disabled=true;$('exportJson').disabled=true;$('exportCsv').disabled=true;
}
function renderKpis(a){
$('kpiSla').textContent=pct(a.serviceLevel);
$('kpiSlaSub').textContent=`Goal ${pct(analysis.config.slaGoal)} · ${fmtNum(a.withinSla,0)} of ${fmtNum(a.arrivals,0)} arrivals`;
$('kpiCost').textContent=usd(a.totalCost);
$('kpiCostSub').textContent=`P10–P90 ${usd(a.totalCostP10)} to ${usd(a.totalCostP90)}`;
$('kpiWait').textContent=fmtNum(a.meanWait,1)+' min';
$('kpiWaitSub').textContent=`90th percentile ≈ ${fmtNum(a.p90Wait,1)} min (mean)`;
$('kpiAbandon').textContent=pct(a.abandonmentRate);
$('kpiAbandonSub').textContent=`${fmtNum(a.abandoned,1)} lost / shift on average`;
}
function graphic(curve,key,{color,goal=null,money=false}){
const W=550,H=238,L=60,R=21,T=21,B=37,innerW=W-L-R,innerH=H-T-B;
const maxVal=Math.max(...curve.map(r=>r[key]),goal||0, 1);
const yMax=key==='serviceLevel'?100:Math.ceil(maxVal*1.14 / 25)*25;
const y=v=>T+(1-v/yMax)*innerH;
const x=i=>L+(curve.length===1?innerW/2:i*innerW/(curve.length-1));
const axes=[0,.25,.5,.75,1].map(k=>{
const n=yMax*(1-k);
return `<line class="gridline" x1="${L}" x2="${W-R}" y1="${y(n)}" y2="${y(n)}"/><text x="${L-9}" y="${y(n)+4}" text-anchor="end">${money ? ('$'+(n>=1000?(n/1000).toFixed(1)+'k':Math.round(n))) : Math.round(n)+'%'}</text>`;
}).join('');
const xs=curve.map((r,i)=>`<text x="${x(i)}" y="${H-13}" text-anchor="middle">${r.agents}</text>`).join('');
const points=curve.map((r,i)=>`${x(i)},${y(r[key])}`).join(' ');
const area=`${L},${T+innerH} ${points} ${x(curve.length-1)},${T+innerH}`;
const circles=curve.map((r,i)=>{
const isBase=r.agents===analysis.config.agents;
const isRec=analysis.recommended&&r.agents===analysis.recommended.agents;
return `<circle cx="${x(i)}" cy="${y(r[key])}" r="${isRec?6:isBase?5:3}" fill="${isRec?'#f9c87a':color}" stroke="var(--panel)" stroke-width="${isRec||isBase?2:1}"><title>${r.agents} agents: ${money?usd(r[key]):pct(r[key])}${isRec?' · recommended':''}${isBase?' · current':''}</title></circle>`;
}).join('');
const goalLine=goal===null?'':`<line class="goal" x1="${L}" x2="${W-R}" y1="${y(goal)}" y2="${y(goal)}"/><text x="${W-R-3}" y="${Math.max(T+9,y(goal)-5)}" text-anchor="end" style="fill:var(--orange)">GOAL ${fmtNum(goal,0)}%</text>`;
const baseIdx=curve.findIndex(r=>r.agents===analysis.config.agents);
const baseLine=baseIdx<0?'':`<line x1="${x(baseIdx)}" x2="${x(baseIdx)}" y1="${T}" y2="${H-B}" stroke="#6e8ca5" opacity=".6" stroke-dasharray="3 5"/>`;
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><linearGradient id="fill-${key}" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".22"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>${axes}${baseLine}${goalLine}<polygon points="${area}" fill="url(#fill-${key})"/><polyline points="${points}" fill="none" stroke="${color}" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>${circles}${xs}<text x="${W/2}" y="${H-1}" text-anchor="middle">STAFF MEMBERS</text></svg>`;
}
function renderCharts(){
const {curve,baseline,recommended,config}=analysis;
$('costChart').innerHTML=graphic(curve,'totalCost',{color:'#50e3bd',money:true});
$('slaChart').innerHTML=graphic(curve,'serviceLevel',{color:'#a79af8',goal:config.slaGoal});
const low=analysis.cheapest;
$('costSummary').textContent=`Lowest modeled cost: ${usd(low.totalCost)} with ${low.agents} agents. Costs include staff, waiting and abandonment.`;
$('slaSummary').textContent=recommended?`${analysis.feasibleCount} staffing levels meet the ${fmtNum(config.slaGoal,0)}% mean SLA goal.`:`No evaluated staffing level meets the ${fmtNum(config.slaGoal,0)}% mean SLA goal.`;
$('runMeta').textContent=`${config.trials} runs · 8-hour shift · seed ${config.seed}`;
}
function recStat(label,value){return `<div class="rec-stat"><small>${label}</small><b>${value}</b></div>`;}
function renderRecommendation(){
const {baseline,recommended,cheapest,config}=analysis;
const recBox=$('recommendation'),tag=$('recBadge');
if(!recommended){
tag.className='rec-badge warn';tag.textContent='TARGET NOT REACHED';
const bestSla=analysis.curve.reduce((a,b)=>a.serviceLevel>b.serviceLevel? a:b);
recBox.innerHTML=`<div class="rec-main"><span class="rec-count">!</span><div class="rec-label"><strong>No feasible staffing level found</strong>The best tested mean SLA is ${pct(bestSla.serviceLevel)} with ${bestSla.agents} staff.</div></div><p class="rec-explainer">Try raising the maximum staffing count, reducing service time, or revisiting the target. Do not treat the cheapest scenario as SLA-compliant.</p>`;
$('applyRecommended').disabled=true;
return;
}
tag.className='rec-badge good';tag.textContent='MEETS AVERAGE SLA TARGET';
const delta=recommended.totalCost-baseline.totalCost;
const saved=baseline.totalCost-recommended.totalCost;
const change= recommended.agents-baseline.agents;
recBox.innerHTML=`<div class="rec-main"><span class="rec-count">${recommended.agents}</span><div class="rec-label"><strong>staff members</strong>Cheapest expected-cost option that meets your mean SLA goal.</div></div><div class="rec-stats">${recStat('EXPECTED SLA',pct(recommended.serviceLevel))}${recStat('COST / SHIFT',usd(recommended.totalCost))}${recStat('CHANGE VS NOW',`${delta<=0?'-':'+'}${usd(Math.abs(delta)).replace('$',' $')}`)}</div><p class="rec-explainer">${change===0?'Your current staffing level is already the least-cost SLA-compliant option in the tested range.':`Compared with your ${baseline.agents}-person baseline, this scenario uses ${Math.abs(change)} ${change>0?'more':'fewer'} staff and ${saved>0?`reduces expected shift cost by ${usd(saved)}`:`increases expected shift cost by ${usd(-saved)}`} while meeting the average SLA target.`}</p>`;
$('applyRecommended').disabled = baseline.agents===recommended.agents;
}
function riskItem(label,key,format){
const a=analysis.baseline;
const lo=a[key+'P10'],hi=a[key+'P90'],value=a[key];
const min=Math.min(0,lo*0.8),max=Math.max(hi*1.2,1),spread=max-min;
const left=Math.max(0,(lo-min)/spread*100),right=Math.min(100,(hi-min)/spread*100),center=Math.max(0,Math.min(100,(value-min)/spread*100));
return `<div class="risk-item"><div class="risk-label"><span>${label}</span><strong>${format(lo)} – ${format(hi)}</strong></div><div class="risk-track"><span class="range" style="left:${left}%;width:${Math.max(1,right-left)}%"></span><span class="median" style="left:${center}%" title="Mean result"></span></div></div>`;
}
function renderRisk(){
$('riskRows').innerHTML=riskItem('SLA attainment','serviceLevel',pct)+riskItem('Total shift cost','totalCost',usd)+riskItem('Customer abandonment','abandoned',n=>fmtNum(n,1));
}
function renderTable(){
const {curve,baseline,recommended}=analysis;
$('comparisonBody').innerHTML=curve.map(row=>{
const base=row.agents===baseline.agents, rec=recommended&&row.agents===recommended.agents;
const tag=rec?'<span class="row-tag best">OPTIMAL SLA</span>':base?'<span class="row-tag">CURRENT</span>':row.serviceLevel+1e-10>=analysis.config.slaGoal?'<span class="row-tag">ON TARGET</span>':'<span class="row-tag miss">BELOW GOAL</span>';
return `<tr class="${rec?'recommended':base?'baseline':''}"><td>${row.agents}</td><td>${pct(row.serviceLevel)}</td><td>${fmtNum(row.meanWait,1)} min</td><td>${fmtNum(row.abandoned,1)}</td><td>${pct(row.utilization)}</td><td>${usd(row.totalCost)}</td><td>${tag}</td></tr>`;
}).join('');
}
function run(){
const error=$('formError');error.hidden=true;
let cfg;
try{cfg=readConfig();}catch(e){error.textContent=e.message;error.hidden=false;notify(e.message,true);return;}
$('run').disabled=true;$('run').firstElementChild.textContent='⋯';
// Allow the UI to paint a working state before heavy deterministic computation.
setTimeout(()=>{
try{
analysis=analyzeScenario(cfg);isStale=false;
$('freshness').textContent='● Analysis up to date';$('freshness').classList.remove('stale');
renderKpis(analysis.baseline);renderCharts();renderRecommendation();renderRisk();renderTable();
$('exportJson').disabled=false;$('exportCsv').disabled=false;
notify(`Analyzed ${cfg.trials} common-demand simulation runs`);
}catch(e){error.textContent=e.message;error.hidden=false;notify('Could not run simulation: '+e.message,true);}
finally{$('run').disabled=false;$('run').firstElementChild.textContent='▶';}
},35);
}
function download(content,name,mime){
const blob=new Blob([content],{type:mime});const url=URL.createObjectURL(blob);
const link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
async function loadJson(file){
if(!file)return;
try{
if(file.size>100000)throw new Error('The scenario file exceeds 100 KB.');
const obj=JSON.parse(await file.text());
const raw=obj.scenario??obj;
const cfg=normalizeConfig(raw);
applyConfig(cfg);
notify('Imported scenario successfully. Run simulation to refresh.');
}catch(e){notify('Scenario import failed: '+e.message,true);}
finally{$('importScenario').value='';}
}
async function loadCsv(file){
if(!file)return;
try{
if(file.size>50000)throw new Error('CSV exceeds 50 KB.');
const hourly=parseHourlyCsv(await file.text());
hourly.forEach((v,i)=>$(`h${i}`).value=v);
$('preset').value='custom';markStale();notify('Demand curve imported. Run simulation to refresh.');
}catch(e){notify('CSV import failed: '+e.message,true);}
finally{$('uploadCsv').value='';}
}
function boot(){
demandInputs();applyConfig(PRESETS.retail,'retail');
$('preset').addEventListener('change',e=>{
if(PRESETS[e.target.value])applyConfig(PRESETS[e.target.value],e.target.value);
else markStale();
});
FIELDS.forEach(id=>$(id).addEventListener('input',markStale));
$('run').addEventListener('click',run);
$('applyRecommended').addEventListener('click',()=>{
if(!analysis || isStale || !analysis.recommended)return;
$('agents').value=analysis.recommended.agents;markStale();run();
});
$('exportJson').addEventListener('click',()=>{if(!analysis||isStale)return;download(JSON.stringify(analysis,null,2),'flowpilot-simulation-report.json','application/json');notify('Report exported as JSON.');});
$('exportCsv').addEventListener('click',()=>{if(!analysis||isStale)return;download(toCsv(analysis),'flowpilot-staffing-comparison.csv','text/csv;charset=utf-8');notify('Staffing comparison exported as CSV.');});
$('exportScenario').addEventListener('click',()=>{try{download(JSON.stringify({format:'flowpilot-scenario-v1',scenario:readConfig()},null,2),'flowpilot-scenario.json','application/json');notify('Scenario saved.');}catch(e){notify(e.message,true);}});
$('importScenario').addEventListener('change',e=>loadJson(e.target.files?.[0]));
$('uploadCsv').addEventListener('change',e=>loadCsv(e.target.files?.[0]));
$('downloadCsvTemplate').addEventListener('click',()=>download('hour,arrivals\n1,12\n2,16\n3,24\n4,40\n5,33\n6,26\n7,18\n8,10\n','flowpilot-hourly-demand-template.csv','text/csv;charset=utf-8'));
$('themeToggle').addEventListener('click',()=>{document.body.classList.toggle('light');$('themeToggle').textContent=document.body.classList.contains('light')?'☾':'☼';});
run();
}
boot();
</script>
</body>
</html>