File size: 40,741 Bytes
9fae8ca 3e25750 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | <!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="#08121e"><title>ShiftForge | Workforce Scheduling Intelligence</title>
<style>
:root{color-scheme:dark;--bg:#08121e;--side:#0c1724;--card:#101f2e;--card2:#152638;--line:#263c4d;--text:#eaf4f6;--muted:#8fa3b2;--mint:#67e5c0;--aqua:#5bc4ef;--warn:#ffca80;--red:#ff888c;--purple:#bba7ff;--rad:16px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.48 system-ui,-apple-system,"Segoe UI",sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--mint);outline-offset:2px}.app{display:flex;min-height:100vh}.aside{width:252px;flex-shrink:0;background:var(--side);border-right:1px solid var(--line);padding:26px 17px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh}.brand{display:flex;align-items:center;gap:12px;padding:0 11px 28px}.brandmark{display:grid;place-items:center;width:41px;height:41px;border-radius:12px;background:linear-gradient(135deg,var(--mint),#4f91bf);color:#062036;font-size:21px;font-weight:900;box-shadow:0 8px 30px #2ed9a825}.brand b{display:block;font-size:20px;letter-spacing:-.65px}.brand small{display:block;color:var(--muted);font-size:10px;letter-spacing:1.65px}.nav-caption{margin:12px 12px;color:#6e8798;font-size:10px;letter-spacing:2px;font-weight:800}.nav{display:flex;flex-direction:column;gap:6px}.nav button{color:#a4b9c6;background:transparent;border:1px solid transparent;border-radius:10px;padding:12px 13px;text-align:left;display:flex;align-items:center;gap:11px;font-weight:600;width:100%}.nav button:hover{background:#172838}.nav button.active{background:#17392f;color:var(--mint);border-color:#255b4e}.nav .glyph{font-size:18px;width:22px;text-align:center}.aside-bottom{margin-top:auto;border:1px solid var(--line);border-radius:12px;padding:13px;background:#101f2b;color:var(--muted);font-size:12px}.aside-bottom strong{display:block;color:var(--mint);padding-bottom:5px}.main{min-width:0;flex:1;padding:29px clamp(18px,3.4vw,52px) 70px;max-width:1640px;margin:auto}.topline{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:30px}.eyebrow{color:var(--mint);font-size:11px;font-weight:800;letter-spacing:2px;text-transform:uppercase}.topright{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.pill{color:var(--mint);border:1px solid #275b51;background:#14352e;border-radius:99px;padding:6px 11px;font-size:11px;font-weight:800}.badge{font-size:11px;color:#9ec0ca;background:#152c3b;border:1px solid #2b4b5f;padding:6px 11px;border-radius:99px}h1{font-size:clamp(29px,3vw,42px);line-height:1.1;letter-spacing:-1.5px;margin:7px 0 10px}h2{font-size:17px;letter-spacing:-.3px;margin:0 0 5px}h3{font-size:13px;margin:0 0 7px}p{margin:0}.subtitle{color:var(--muted);max-width:810px}.headline{display:flex;align-items:end;justify-content:space-between;gap:18px;margin-bottom:24px;flex-wrap:wrap}.controls{display:flex;gap:9px;flex-wrap:wrap}select,input[type=number]{background:#142434;color:var(--text);border:1px solid var(--line);border-radius:9px;padding:10px 11px;min-width:132px}input[type=range]{width:100%;accent-color:var(--mint)}.btn{border-radius:9px;border:1px solid #31536a;background:#1a3548;color:#ddecf3;padding:10px 14px;font-weight:700;white-space:nowrap}.btn:hover{border-color:#6ac8ca;background:#204258}.primary{color:#0b2a2b;background:var(--mint);border-color:var(--mint)}.primary:hover{background:#8ff7da;color:#082020}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:22px 0}.metric,.panel{background:var(--card);border:1px solid var(--line);border-radius:var(--rad)}.metric{padding:20px 19px;min-height:125px}.metric .k{font-size:10px;font-weight:800;letter-spacing:1.5px;color:#94adba;text-transform:uppercase}.metric .v{font-size:clamp(25px,2.2vw,36px);font-weight:800;margin:11px 0 4px;letter-spacing:-1px}.metric .h{color:var(--muted);font-size:12px}.positive{color:var(--mint)}.warning{color:var(--warn)}.negative{color:var(--red)}.panel{padding:20px;margin-bottom:16px;min-width:0}.section-head{display:flex;align-items:start;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:17px}.small{color:var(--muted);font-size:12px}.grid2{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(290px,1fr);gap:16px}.grid2.equal{grid-template-columns:1fr 1fr}.grid3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.mini-title{color:#8caaba;font-size:10px;letter-spacing:1.3px;font-weight:800;text-transform:uppercase;margin-bottom:8px}.chart{width:100%;max-height:300px;overflow:hidden;display:block}.axistext{font-size:11px;fill:#8fa3b2}.legend{display:flex;gap:15px;flex-wrap:wrap;color:var(--muted);font-size:12px}.dot{display:inline-block;width:9px;height:9px;border-radius:4px;margin-right:5px;background:var(--mint)}.dot.blue{background:var(--aqua)}.dot.red{background:var(--red)}.table-wrap{overflow:auto;max-width:100%;border-radius:10px;border:1px solid #264253}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}th,td{padding:11px 13px;border-bottom:1px solid #22394a;font-size:12px}th{background:#162c3b;color:#a5bbca;font-size:10px;letter-spacing:.9px;text-transform:uppercase}tr:last-child td{border-bottom:0}tbody tr:hover{background:#193040}.value{font-variant-numeric:tabular-nums}.tag{display:inline-block;border:1px solid #375a60;color:#bce9e1;background:#173833;padding:3px 8px;border-radius:99px;font-size:11px;font-weight:700}.tag.warn{color:#ffd2a3;background:#3b2c24;border-color:#73573c}.tag.fail{color:#ffabae;background:#402329;border-color:#864550}.heat-scroll{overflow:auto}.heatgrid{min-width:650px;display:grid;grid-template-columns:83px repeat(7,1fr);gap:5px}.heathead{color:#9db5c0;text-align:center;padding:8px 1px;font-size:11px;font-weight:800}.heatlabel{display:flex;align-items:center;color:#aec3cb;font-weight:700;font-size:12px}.heatcell{min-height:91px;border:1px solid #2d4d5c;background:#142737;border-radius:10px;padding:11px 7px;text-align:center;display:flex;flex-direction:column;gap:5px;justify-content:center}.heatcell strong{font-size:19px}.heatcell span{font-size:10px;color:#adc5cd}.heatcell.good{background:#154334;border-color:#2b7656}.heatcell.short{background:#482b29;border-color:#a0524e}.heatcell.off{background:#132531}.hint{border-left:3px solid var(--aqua);background:#122939;border-radius:0 9px 9px 0;padding:11px 13px;color:#b1c8d3;font-size:12px;margin:12px 0}.sliders{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 24px}.sliderbox{padding:14px;background:#122638;border:1px solid #284355;border-radius:12px}.sliderhead{display:flex;justify-content:space-between;align-items:center;gap:10px;font-weight:700;margin-bottom:12px}.sliderhead strong{color:var(--mint)}.listcards{display:flex;flex-direction:column;gap:10px}.listcard{border:1px solid #2c4557;background:#142739;padding:13px;border-radius:10px;display:flex;justify-content:space-between;gap:13px;align-items:center}.listcard b{font-size:13px}.muted{color:var(--muted)}.empty{padding:24px;color:var(--muted);border:1px dashed #395263;border-radius:10px}.filehelp{background:#152d3c;border-radius:10px;padding:15px;color:#bfd3dc;font-size:12px;line-height:1.7}.filehelp code{background:#08202b;padding:3px 5px;border-radius:4px;color:var(--mint);white-space:normal}.import-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}.filepick{border:1px dashed #507381;border-radius:10px;padding:10px;max-width:100%;color:#bed3dd}a{color:var(--mint)}.footer{margin-top:24px;font-size:11px;color:#718a9b}#toast{position:fixed;bottom:22px;right:22px;padding:13px 17px;border-radius:11px;background:#1b463d;color:#d5fff2;border:1px solid #367861;box-shadow:0 12px 40px #0007;max-width:350px;z-index:5;display:none}#toast.show{display:block}#toast.error{background:#512c2d;color:#ffe8e6;border-color:#924e50}#menuToggle{display:none}#shade{display:none}@media(max-width:1100px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.grid2{grid-template-columns:1fr}.grid3{grid-template-columns:1fr 1fr}}@media(max-width:740px){.app{display:block}.aside{position:fixed;height:100vh;left:0;top:0;z-index:12;transform:translateX(-105%);transition:transform .2s}.aside.open{transform:translateX(0)}#shade.open{display:block;position:fixed;inset:0;background:#0009;z-index:11}#menuToggle{display:inline-block}.main{padding:19px 15px 48px}.topline{margin-bottom:22px}.headline{align-items:start}.metrics{gap:9px}.metric{padding:13px;min-height:105px}.metric .v{font-size:26px}.metric .k{font-size:9px}.metric .h{font-size:11px}.panel{padding:15px}.grid3,.grid2.equal,.sliders{grid-template-columns:1fr}.controls{width:100%}.controls select{flex:1;min-width:115px}.chart{max-height:240px}.topright{gap:5px}.brand b{font-size:18px}}
</style></head><body><div id="shade"></div><div class="app"><aside class="aside" id="aside"><div class="brand"><div class="brandmark">S</div><div><b>ShiftForge</b><small>WORKFORCE LAB</small></div></div><div class="nav-caption">WORKSPACE</div><nav class="nav" id="nav"><button data-page="overview" class="active"><span class="glyph">◫</span>Command Center</button><button data-page="roster"><span class="glyph">▦</span>Roster Planner</button><button data-page="coverage"><span class="glyph">▤</span>Coverage Heatmap</button><button data-page="fairness"><span class="glyph">◉</span>Workload Balance</button><button data-page="scenario"><span class="glyph">◈</span>Scenario Laboratory</button><button data-page="data"><span class="glyph">⇄</span>Data & Methodology</button></nav><div class="aside-bottom"><strong>● Offline-ready simulation</strong>Runs entirely in your browser. Synthetic examples only. No worker information is transmitted.</div></aside><main class="main"><div class="topline"><div><button class="btn" id="menuToggle" type="button" aria-label="Open navigation">☰</button> <span class="eyebrow">WORKFORCE DECISION INTELLIGENCE / 60</span></div><div class="topright"><span class="pill">● LOCAL ENGINE</span><span class="badge">STATIC / NO API</span></div></div><div class="headline"><div><div class="eyebrow" id="page-eyebrow">OPERATIONS OVERVIEW</div><h1 id="page-title">Plan better shifts.<br>Cover every hour.</h1><p class="subtitle" id="page-desc">Cost-aware workforce allocation with transparent eligibility, capacity constraints, and reproducible optimization.</p></div><div class="controls"><select id="dataset" aria-label="Scenario"><option value="retail">Retail operations</option><option value="hospitality">Hospitality front desk</option><option value="support">Customer support</option></select><button class="btn primary" id="exportTop" type="button">↓ Export audit</button></div></div><div id="content"></div><div class="footer">ShiftForge 1.0 · Demonstration data are synthetic · Model-based scheduling support, not a legal compliance or personnel selection system.</div></main></div><div id="toast" role="status" aria-live="polite"></div>
<script>
"use strict";
const DAYS=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],SHIFTS=['Morning','Evening','Night'];
const $=s=>document.querySelector(s), money=n=>'$'+Math.round(n).toLocaleString('en-US'), pct=n=>(100*n).toFixed(1)+'%', esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function seeded(seed){let a=seed>>>0;return()=>((a=(1664525*a+1013904223)>>>0)/4294967296)}
function makePreset(id){const cfg={retail:{name:'Retail operations',seed:602,staff:14,shiftBase:[2,2,1],weekendExtra:1,max:5},hospitality:{name:'Hospitality front desk',seed:887,staff:12,shiftBase:[2,2,2],weekendExtra:1,max:5},support:{name:'Customer support',seed:194,staff:15,shiftBase:[3,2,1],weekendExtra:0,max:5}}[id]; const rng=seeded(cfg.seed);const workers=[];const names=['Alex','Sam','Jordan','Taylor','Morgan','Casey','Cameron','Avery','Jamie','Riley','Noah','Quinn','Harper','Parker','Sky','Robin'];for(let i=0;i<cfg.staff;i++){let skill=(i%5===0?3:i%3===0?2:1);let available=[];for(let d=0;d<7;d++){for(let s=0;s<3;s++){if(rng()>.19&&(!((i+s+d)%17===0)))available.push(`${d}:${s}`)}}workers.push({id:'W'+String(i+1).padStart(2,'0'),name:names[i],skill,wage:15+(i%5)*2+skill,cap:cfg.max-(i%7===0?1:0),pref:i%3,available})}const demand=[];for(let d=0;d<7;d++)for(let s=0;s<3;s++){let required=cfg.shiftBase[s]+(d>4&&s===1?cfg.weekendExtra:0);demand.push({day:d,shift:s,required,skill:s===2?2:1})}return {id,name:cfg.name,workers,demand}}
const PRESETS={retail:makePreset('retail'),hospitality:makePreset('hospitality'),support:makePreset('support')};
let state={page:'overview',model:structuredClone(PRESETS.retail),settings:{gapPenalty:650,balance:12,nightPremium:15,preferencePenalty:12},selectedDay:0,selectedShift:0};
const titles={overview:['OPERATIONS OVERVIEW','Plan better shifts. Cover every hour.','A transparent allocation engine balancing coverage, staffing costs, and workload concentration.'],roster:['EXACT ASSIGNMENT ENGINE','Your optimized roster.','Every assignment respects skills, availability, one shift per day, and weekly shift limits.'],coverage:['STAFFING GAP INTELLIGENCE','Where coverage breaks.','Demand and staffed assignments for every day and shift, including shortages and excess capacity.'],fairness:['WORKLOAD DIAGNOSTICS','Balance workloads visibly.','Inspect hours per worker and workload dispersion. No demographic fairness assessment is performed.'],scenario:['CONTROLLED WHAT-IF ANALYSIS','Test the planning trade-offs.','Change shortage penalties, shift premiums, or fairness weight. The model recalculates immediately.'],data:['REPRODUCIBILITY & GOVERNANCE','Bring your own records.','Upload validated CSV files, export a complete audit, and inspect the solver assumptions.']};
function validateModel(m){if(!m||!Array.isArray(m.workers)||!Array.isArray(m.demand))throw Error('Expected workers and demand arrays');if(m.workers.length<1||m.workers.length>35)throw Error('Worker count must be 1 to 35');const ids=new Set;for(const w of m.workers){if(!/^[A-Za-z0-9_-]{1,28}$/.test(w.id)||ids.has(w.id))throw Error('Worker IDs must be unique and use letters, numbers, underscore or hyphen');ids.add(w.id);if(typeof w.name!=='string'||!w.name.trim()||w.name.length>65)throw Error('Invalid worker name');if(!Number.isInteger(w.skill)||w.skill<1||w.skill>3||!Number.isFinite(w.wage)||w.wage<0||w.wage>250||!Number.isInteger(w.cap)||w.cap<0||w.cap>7||!Number.isInteger(w.pref)||w.pref<0||w.pref>2||!Array.isArray(w.available))throw Error('Invalid worker constraints');if(w.available.length>21||w.available.some(x=>!/^([0-6]):([0-2])$/.test(x)))throw Error('Invalid worker availability')}if(m.demand.length!==21)throw Error('Demand must have 21 rows (7 days × 3 shifts)');const keys=new Set;for(const d of m.demand){let k=`${d.day}:${d.shift}`;if(keys.has(k)||!Number.isInteger(d.day)||d.day<0||d.day>6||!Number.isInteger(d.shift)||d.shift<0||d.shift>2||!Number.isInteger(d.required)||d.required<0||d.required>8||!Number.isInteger(d.skill)||d.skill<1||d.skill>3)throw Error('Invalid or duplicate demand slot');keys.add(k)}return m}
for(const preset of Object.values(PRESETS))validateModel(preset);
function costs(w,slot,cfg){return Math.round(w.wage*8*(slot.shift===2?(1+cfg.nightPremium/100):1)*100)+((w.pref===slot.shift)?0:cfg.preferencePenalty*100)}
function eligible(w,slot){return w.skill>=slot.skill&&w.available.includes(`${slot.day}:${slot.shift}`)}
function compute(model,cfg){validateModel(model);const wk=model.workers, slots=model.demand, W=wk.length,N=slots.length;let next=1;const S=0,workerIds=wk.map(()=>next++),wd=wk.map(()=>Array.from({length:7},()=>next++)),slotIds=slots.map(()=>next++),T=next++;const graph=Array.from({length:next},()=>[]);function edge(a,b,cap,cost,info=null){const p={to:b,rev:graph[b].length,cap,cost,original:cap,info},q={to:a,rev:graph[a].length,cap:0,cost:-cost,original:0,info:null};graph[a].push(p);graph[b].push(q);return p}let assignEdges=[];for(let i=0;i<W;i++){const w=wk[i];for(let j=1;j<=w.cap;j++)edge(S,workerIds[i],1,cfg.balance*100*(2*j-1));for(let d=0;d<7;d++)edge(workerIds[i],wd[i][d],1,0);for(let d=0;d<7;d++)for(let s=0;s<N;s++)if(slots[s].day===d&&eligible(w,slots[s]))assignEdges.push({i,s,edge:edge(wd[i][d],slotIds[s],1,costs(w,slots[s],cfg),{i,s})})}let unmet=[];for(let s=0;s<N;s++){edge(slotIds[s],T,slots[s].required,0);unmet.push(edge(S,slotIds[s],slots[s].required,cfg.gapPenalty*100))}let needed=slots.reduce((t,x)=>t+x.required,0),flow=0,totalCost=0; // Shortest augmenting paths with residual Bellman-Ford, deterministic exact min-cost flow for modeled constraints.
while(flow<needed){const dist=Array(next).fill(Infinity),pv=Array(next).fill(-1),pe=Array(next).fill(-1);dist[S]=0;for(let iter=0;iter<next-1;iter++){let change=false;for(let v=0;v<next;v++){if(!Number.isFinite(dist[v]))continue;for(let j=0;j<graph[v].length;j++){let e=graph[v][j];if(e.cap>0&&dist[v]+e.cost<dist[e.to]){dist[e.to]=dist[v]+e.cost;pv[e.to]=v;pe[e.to]=j;change=true}}}if(!change)break}if(pv[T]===-1)throw Error('No feasible residual path');let add=needed-flow;for(let v=T;v!==S;v=pv[v])add=Math.min(add,graph[pv[v]][pe[v]].cap);for(let v=T;v!==S;v=pv[v]){const e=graph[pv[v]][pe[v]];e.cap-=add;graph[v][e.rev].cap+=add}flow+=add;totalCost+=add*dist[T]}
const assignments=assignEdges.filter(a=>a.edge.cap===0).map(a=>({worker:wk[a.i],slot:slots[a.s],workerIndex:a.i,slotIndex:a.s,cost:costs(wk[a.i],slots[a.s],cfg)/100}));const shortages=unmet.map((e,s)=>({slot:slots[s],count:slots[s].required-e.cap})); // Flow on artificial shortage arc = original capacity - residual capacity.
const used=assignments.length,miss=shortages.reduce((t,x)=>t+x.count,0),assigned=wk.map(w=>assignments.filter(a=>a.worker.id===w.id).length);const prefCost=assignments.reduce((t,a)=>t+(a.worker.pref===a.slot.shift?0:cfg.preferencePenalty),0),labor=assignments.reduce((t,a)=>t+a.cost,0)-prefCost,fairness=assigned.reduce((t,n)=>t+cfg.balance*n*n,0);
return {assignments,shortages,assigned,coverage:needed?used/needed:1,needed,used,miss,labor,objective:totalCost/100,fairness,prefCost,mean:assigned.reduce((a,b)=>a+b,0)/W,active:assigned.filter(x=>x).length,range:Math.max(...assigned)-Math.min(...assigned)}}
function greedy(model,cfg){const assigned=Array(model.workers.length).fill(0),days=Array.from({length:model.workers.length},()=>new Set),assignments=[];for(const slot of model.demand){let n=slot.required;while(n>0){let ix=-1,cb=Infinity;model.workers.forEach((w,i)=>{if(assigned[i]>=w.cap||days[i].has(slot.day)||!eligible(w,slot))return;const cost=costs(w,slot,cfg)/100+cfg.balance*(2*assigned[i]+1);if(cost<cb){cb=cost;ix=i}});if(ix<0)break;days[ix].add(slot.day);assigned[ix]++;assignments.push({worker:model.workers[ix],slot,cost:costs(model.workers[ix],slot,cfg)/100});n--}}const used=assignments.length,needed=model.demand.reduce((x,s)=>x+s.required,0),miss=needed-used,labor=assignments.reduce((t,a)=>t+a.cost,0);return {assigned,assignments,used,miss,labor,objective:labor+cfg.balance*assigned.reduce((t,n)=>t+n*n,0)+cfg.gapPenalty*miss}}
let solved=compute(state.model,state.settings),baseline=greedy(state.model,state.settings);function refresh(){solved=compute(state.model,state.settings);baseline=greedy(state.model,state.settings);render()}
function metric(label,val,h,cls=''){return `<div class="metric"><div class="k">${esc(label)}</div><div class="v ${cls}">${val}</div><div class="h">${esc(h)}</div></div>`}
function header(t,detail,extra=''){return `<div class="section-head"><div><h2>${t}</h2><p class="small">${detail}</p></div>${extra}</div>`}
function barChart(data,colors=['#67e5c0','#5bc4ef']){const max=Math.max(1,...data.flatMap(d=>d.values));const width=680,height=260,left=43,bottom=230,top=16,space=(width-left-12)/data.length;let svg=`<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="Comparison bar chart">`;for(let k=0;k<=4;k++){let y=bottom-(bottom-top)*k/4;svg+=`<line x1="${left}" y1="${y}" x2="${width-5}" y2="${y}" stroke="#294458" stroke-dasharray="3 5"/><text class="axistext" x="${left-8}" y="${y+4}" text-anchor="end">${Math.round(max*k/4)}</text>`}data.forEach((d,i)=>{let groupX=left+i*space+space*.13;d.values.forEach((v,j)=>{let bw=space*.70/d.values.length;let h=(bottom-top)*v/max;svg+=`<rect x="${groupX+j*bw}" y="${bottom-h}" width="${Math.max(4,bw-3)}" height="${h}" rx="4" fill="${colors[j%colors.length]}"/>`});svg+=`<text class="axistext" text-anchor="middle" x="${left+(i+.5)*space}" y="${bottom+17}">${esc(d.label)}</text>`});return svg+'</svg>'}
function timegrid(){let out='<div class="heat-scroll"><div class="heatgrid"><div></div>'+DAYS.map(d=>`<div class="heathead">${d}</div>`).join('');for(let s=0;s<3;s++){out+=`<div class="heatlabel">${SHIFTS[s]}</div>`;for(let d=0;d<7;d++){let slot=state.model.demand.find(q=>q.day===d&&q.shift===s);let a=solved.assignments.filter(x=>x.slot.day===d&&x.slot.shift===s).length;const miss=slot.required-a;out+=`<div class="heatcell ${miss?'short':'good'}" title="${DAYS[d]} ${SHIFTS[s]}; ${a} assigned of ${slot.required}"><strong>${a} / ${slot.required}</strong><span>${miss?miss+' uncovered':'Fully covered'}</span></div>`}}return out+'</div></div>'}
function overview(){const dif=baseline.objective-solved.objective;return `<div class="metrics">${metric('COVERAGE',pct(solved.coverage),`${solved.used} / ${solved.needed} required shifts`,solved.coverage>.9?'positive':'warning')}${metric('UNCOVERED SHIFTS',solved.miss,solved.miss?'Needs manual review':'All modeled demand filled',solved.miss?'negative':'positive')}${metric('WEEKLY LABOR COST',money(solved.labor),'Wages including the night premium')}${metric('OBJECTIVE IMPROVEMENT',money(Math.max(0,dif)),'vs. deterministic greedy baseline','positive')}</div><div class="grid2"><div class="panel">${header('Seven-day coverage','Assigned shifts versus required shifts')}${barChart(DAYS.map((d,i)=>({label:d,values:[state.model.demand.filter(x=>x.day===i).reduce((t,x)=>t+x.required,0),solved.assignments.filter(x=>x.slot.day===i).length]})),['#5bc4ef','#67e5c0'])}<div class="legend"><span><i class="dot blue"></i>Demand</span><span><i class="dot"></i>Assigned</span></div></div><div class="panel">${header('Planning decisions','Exact solver versus a simple greedy allocator')}<div class="listcards"><div class="listcard"><span>Optimal modeled objective</span><b class="positive">${money(solved.objective)}</b></div><div class="listcard"><span>Greedy baseline objective</span><b>${money(baseline.objective)}</b></div><div class="listcard"><span>Coverage gap penalty</span><b>${money(solved.miss*state.settings.gapPenalty)}</b></div><div class="listcard"><span>Workload dispersion</span><b>${solved.range} shifts</b></div></div><div class="hint">The objective combines modeled staffing expense, uncovered demand penalties and workload balance. It is not the same as payroll cost.</div></div></div><div class="panel">${header('Coverage by shift','Red blocks show remaining gaps')}${timegrid()}</div>`}
function roster(){let rows=solved.assignments.slice().sort((a,b)=>a.slot.day-b.slot.day||a.slot.shift-b.slot.shift||a.worker.id.localeCompare(b.worker.id));return `<div class="metrics">${metric('ASSIGNED',solved.used,'Worker–shift placements','positive')}${metric('ACTIVE EMPLOYEES',solved.active,`${state.model.workers.length} in the candidate roster`)}${metric('WEEKLY PAYROLL',money(solved.labor),'Wages excluding preference penalties')}${metric('SHORTAGES',solved.miss,'Assignments still needing action',solved.miss?'negative':'positive')}</div><div class="panel">${header('Optimized assignment roster','All assignments from the min-cost flow solver',`<button class="btn" data-download="roster">↓ Download CSV</button>`)}<div class="table-wrap"><table><thead><tr><th>Day</th><th>Shift</th><th>Worker</th><th>Skill</th><th>Modeled shift cost</th><th>Preference</th></tr></thead><tbody>${rows.map(a=>`<tr><td>${DAYS[a.slot.day]}</td><td>${SHIFTS[a.slot.shift]}</td><td>${esc(a.worker.name)} <span class="small">(${esc(a.worker.id)})</span></td><td>Level ${a.worker.skill}</td><td class="value">${money(a.cost)}</td><td>${a.worker.pref===a.slot.shift?'<span class="tag">Preferred</span>':'<span class="tag warn">Other shift</span>'}</td></tr>`).join('')||'<tr><td colspan="6">No eligible assignments.</td></tr>'}</tbody></table></div></div>`}
function coverage(){let slots=state.model.demand.map(x=>{let assign=solved.assignments.filter(a=>a.slot.day===x.day&&a.slot.shift===x.shift);return {slot:x,n:assign.length}});return `<div class="metrics">${metric('TOTAL DEMAND',solved.needed,'Required staffed shifts')}${metric('COVERED',solved.used,'Filled worker shifts','positive')}${metric('UNCOVERED',solved.miss,'Unmet demand',solved.miss?'negative':'positive')}${metric('DAILY COVERAGE',pct(solved.coverage),'Ratio of filled demand')}</div><div class="panel">${header('Coverage matrix','Every cell is a day and shift, red means shortage')}${timegrid()}</div><div class="panel">${header('Gap register','Slots that need additional staff or revised demand')}<div class="table-wrap"><table><thead><tr><th>Day</th><th>Shift</th><th>Required</th><th>Assigned</th><th>Shortage</th><th>Minimum skill</th></tr></thead><tbody>${slots.filter(x=>x.n<x.slot.required).map(x=>`<tr><td>${DAYS[x.slot.day]}</td><td>${SHIFTS[x.slot.shift]}</td><td>${x.slot.required}</td><td>${x.n}</td><td><span class="tag fail">${x.slot.required-x.n} missing</span></td><td>${x.slot.skill}</td></tr>`).join('')||'<tr><td colspan="6">No gaps under current assumptions.</td></tr>'}</tbody></table></div></div>`}
function fairness(){const rows=state.model.workers.map((w,i)=>({w,n:solved.assigned[i],h:solved.assigned[i]*8}));const variance=rows.reduce((t,x)=>t+(x.n-solved.mean)**2,0)/rows.length;return `<div class="metrics">${metric('AVERAGE SHIFTS',solved.mean.toFixed(2),'Per worker')}${metric('MAX–MIN SPREAD',solved.range,'Weekly shifts')}${metric('STD. DEVIATION',Math.sqrt(variance).toFixed(2),'Weekly shift counts')}${metric('WORKLOAD PENALTY',money(solved.fairness),'Objective component')}</div><div class="grid2"><div class="panel">${header('Workload across employees','Eight hours per assigned shift')}${barChart(rows.map(x=>({label:x.w.id,values:[x.h]})),['#67e5c0'])}</div><div class="panel">${header('Model boundaries','What the workload balance metric does and does not show')}<div class="hint">The regularizer penalizes concentrated assignments using the sum of squared shift counts. It does not evaluate protected characteristics, employee consent, employment law, or safe rest between days.</div><div class="listcards"><div class="listcard"><span>One shift per calendar day</span><b class="positive">Enforced</b></div><div class="listcard"><span>Worker weekly maximum</span><b class="positive">Enforced</b></div><div class="listcard"><span>Skill and availability</span><b class="positive">Enforced</b></div><div class="listcard"><span>Legal rest periods</span><b class="warning">Not modeled</b></div></div></div></div><div class="panel">${header('Worker workload register','Assignments, limits and preferred shift')}<div class="table-wrap"><table><thead><tr><th>Worker</th><th>Skill</th><th>Assigned</th><th>Hours</th><th>Max shifts</th><th>Prefers</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${esc(x.w.name)} (${esc(x.w.id)})</td><td>Level ${x.w.skill}</td><td>${x.n}</td><td>${x.h}</td><td>${x.w.cap}</td><td>${SHIFTS[x.w.pref]}</td></tr>`).join('')}</tbody></table></div></div>`}
function scenario(){let s=state.settings;return `<div class="metrics">${metric('COVERAGE',pct(solved.coverage),'Optimized current plan',solved.miss?'warning':'positive')}${metric('UNFILLED SHIFTS',solved.miss,'Penalty affects priorities',solved.miss?'negative':'positive')}${metric('PAYROLL',money(solved.labor),'Direct modeled shift cost')}${metric('OBJECTIVE',money(solved.objective),'Total penalized score')}</div><div class="panel">${header('Live optimization controls','Changes recompute every assignment using the same worker and demand data')}<div class="sliders"><div class="sliderbox"><div class="sliderhead"><span>Uncovered shift penalty</span><strong id="labgap">${money(s.gapPenalty)}</strong></div><input type="range" min="0" max="1200" step="50" data-setting="gapPenalty" value="${s.gapPenalty}" aria-label="Gap penalty"><div class="small">Higher values prioritize covering demand over cost.</div></div><div class="sliderbox"><div class="sliderhead"><span>Workload balance weight</span><strong id="labbal">${money(s.balance)}</strong></div><input type="range" min="0" max="90" step="3" data-setting="balance" value="${s.balance}" aria-label="Workload balance weight"><div class="small">Higher values discourage concentrated assignments.</div></div><div class="sliderbox"><div class="sliderhead"><span>Night-shift premium</span><strong id="labnight">${s.nightPremium}%</strong></div><input type="range" min="0" max="60" step="5" data-setting="nightPremium" value="${s.nightPremium}" aria-label="Night shift premium"><div class="small">Added to modeled labor cost for night assignments.</div></div><div class="sliderbox"><div class="sliderhead"><span>Non-preferred shift cost</span><strong id="labpref">${money(s.preferencePenalty)}</strong></div><input type="range" min="0" max="70" step="2" data-setting="preferencePenalty" value="${s.preferencePenalty}" aria-label="Preference penalty"><div class="small">Soft cost of assigning another available shift.</div></div></div><div class="hint">All sliders affect a simulated optimization objective. They do not modify legal overtime rules, contractual pay, or mandatory breaks.</div><button class="btn" id="resetSettings">↻ Restore default controls</button></div><div class="panel">${header('Outcome by day','Comparison of demand and optimized assignments')}${barChart(DAYS.map((d,i)=>({label:d,values:[state.model.demand.filter(q=>q.day===i).reduce((t,q)=>t+q.required,0),solved.assignments.filter(q=>q.slot.day===i).length]})),['#5bc4ef','#67e5c0'])}</div>`}
function dataPage(){return `<div class="grid2 equal"><div class="panel">${header('Upload workers CSV','Replace the roster only after full validation')}<div class="filehelp"><b>Required headers:</b><br><code>id,name,skill,wage,cap,pref,available</code><br>Skill = 1 to 3; wage = hourly USD; cap = max weekly shifts; pref = 0 Morning, 1 Evening, 2 Night; available = pipe-separated keys, e.g. <code>0:0|0:1|1:2</code>.</div><div class="import-row"><input class="filepick" type="file" id="workersCsv" accept=".csv,text/csv" aria-label="Workers CSV"><button class="btn" id="importWorkers">Import workers</button></div><button class="btn" data-download="workers-template">↓ Download workers template</button></div><div class="panel">${header('Upload demand CSV','Exactly 21 slots, 7 days by 3 shifts')}<div class="filehelp"><b>Required headers:</b><br><code>day,shift,required,skill</code><br>Days = 0 Monday through 6 Sunday. Shifts = 0 Morning, 1 Evening, 2 Night. Required = 0 to 8. Skill = minimum accepted level 1 to 3.</div><div class="import-row"><input class="filepick" type="file" id="demandCsv" accept=".csv,text/csv" aria-label="Demand CSV"><button class="btn" id="importDemand">Import demand</button></div><button class="btn" data-download="demand-template">↓ Download demand template</button></div></div><div class="grid2 equal"><div class="panel">${header('Audit exports','All downloads reflect the current optimized scenario')}<div class="controls"><button class="btn primary" data-download="audit">↓ Full JSON audit</button><button class="btn" data-download="roster">↓ Roster CSV</button><button class="btn" data-download="gaps">↓ Gaps CSV</button><button class="btn" data-download="workers">↓ Workers CSV</button><button class="btn" data-download="demand">↓ Demand CSV</button><button class="btn" data-download="report">↓ Methods report</button></div><div class="hint">Exports are created locally. Data are not sent to a server. Files opened in spreadsheet software have formula-like fields escaped.</div></div><div class="panel">${header('Calculation protocol','Exact only for the clearly stated linear model')}<div class="listcards"><div class="listcard"><span>Optimization</span><b>Min-cost max-flow</b></div><div class="listcard"><span>Hard rules</span><b>Skills / shifts / availability</b></div><div class="listcard"><span>Fairness</span><b>Squared shift-count penalty</b></div><div class="listcard"><span>Unmet demand</span><b>Modeled as a costly option</b></div></div><div class="hint">Not modeled: worker holidays, labor contracts, statutory overtime, travel, worker consent, required between-day rest, or different shift lengths. Results are decision-support demonstrations and must be reviewed before real deployment.</div></div></div>`}
function render(){let [eye,title,desc]=titles[state.page];$('#page-eyebrow').textContent=eye;$('#page-title').textContent=title;$('#page-desc').textContent=desc;$('#nav').querySelectorAll('button').forEach(b=>b.classList.toggle('active',b.dataset.page===state.page));$('#content').innerHTML=({overview,roster,coverage,fairness,scenario,data:dataPage}[state.page])();}
function toast(s,error=false){const el=$('#toast');el.textContent=s;el.className='show'+(error?' error':'');clearTimeout(toast.timeout);toast.timeout=setTimeout(()=>{el.className='';el.textContent=''},3900)}
function csvEscape(v){let s=String(v??'');if(/^[\s]*[=+@]/.test(s)||/^[\s]*-[^\d\s]/.test(s))s="'"+s;return /[",\r\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s}
function toCsv(head,rows){return [head,...rows].map(r=>r.map(csvEscape).join(',')).join('\r\n')+'\r\n'}
function parseCsv(t){if(t.length>1500000)throw Error('CSV exceeds 1.5 MB');t=t.replace(/^\uFEFF/,'');const rows=[];let row=[],field='',quoted=false,after=false;for(let i=0;i<t.length;i++){let c=t[i];if(quoted){if(c==='"'&&t[i+1]==='"'){field+='"';i++}else if(c==='"'){quoted=false;after=true}else field+=c}else if(after){if(c===','){row.push(field);field='';after=false}else if(c==='\n'||c==='\r'){row.push(field);rows.push(row);row=[];field='';after=false;if(c==='\r'&&t[i+1]==='\n')i++}else throw Error('Unexpected characters after closing CSV quote')}else if(c==='"'){if(field!=='')throw Error('Unexpected CSV quote');quoted=true}else if(c===','){row.push(field);field=''}else if(c==='\n'||c==='\r'){row.push(field);rows.push(row);row=[];field='';if(c==='\r'&&t[i+1]==='\n')i++}else field+=c}if(quoted)throw Error('Unclosed CSV quote');if(field!==''||row.length){row.push(field);rows.push(row)}if(rows.length<2||rows.length>220)throw Error('CSV needs 1–219 data rows');const width=rows[0].length;if(rows.some(r=>r.length!==width))throw Error('Inconsistent number of CSV columns');return rows}
function recordsCSV(text,required){const r=parseCsv(text),headers=r.shift().map(s=>s.trim());if(new Set(headers).size!==headers.length||required.some(k=>!headers.includes(k)))throw Error('Missing or duplicate CSV headers');return r.map(row=>Object.fromEntries(headers.map((h,i)=>[h,row[i]])))}
function importWorkers(text){const rows=recordsCSV(text,['id','name','skill','wage','cap','pref','available']);const workers=rows.map(r=>({id:r.id.trim(),name:r.name.trim(),skill:+r.skill,wage:+r.wage,cap:+r.cap,pref:+r.pref,available:r.available?r.available.split('|').map(x=>x.trim()).filter(Boolean):[]}));if(rows.some(r=>['skill','wage','cap','pref'].some(k=>r[k].trim()==='')))throw Error('Missing numeric values');const candidate={...state.model,workers};validateModel(candidate);state.model=candidate;refresh()}
function importDemand(text){const rows=recordsCSV(text,['day','shift','required','skill']);if(rows.some(r=>['day','shift','required','skill'].some(k=>r[k].trim()==='')))throw Error('Missing numeric values');const demand=rows.map(r=>({day:+r.day,shift:+r.shift,required:+r.required,skill:+r.skill})).sort((a,b)=>a.day-b.day||a.shift-b.shift);const candidate={...state.model,demand};validateModel(candidate);state.model=candidate;refresh()}
function audit(){return {project:'ShiftForge 1.0',scenario:state.model.name,synthetic:['Retail operations','Hospitality front desk','Customer support'].includes(state.model.name),settings:{...state.settings},model:state.model,metrics:{demand:solved.needed,assigned:solved.used,uncovered:solved.miss,coverage:solved.coverage,payroll:solved.labor,preferencePenalty:solved.prefCost,objective:solved.objective,baselineObjective:baseline.objective,fairnessPenalty:solved.fairness},assignments:solved.assignments.map(a=>({workerId:a.worker.id,workerName:a.worker.name,day:DAYS[a.slot.day],shift:SHIFTS[a.slot.shift],shiftCost:a.cost})),gaps:solved.shortages.filter(x=>x.count>0).map(x=>({day:DAYS[x.slot.day],shift:SHIFTS[x.slot.shift],uncovered:x.count})),limitations:['No inter-day rest rules','No labor contract or overtime rules','No protected-group fairness analysis','No legal compliance validation','No worker consent modeling']}}
function downloadFile(name,content,type){const blob=new Blob([content],{type}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000)}
function download(kind){let name='shiftforge-'+kind,body,type='text/csv;charset=utf-8';if(kind==='audit'){name+='.json';body=JSON.stringify(audit(),null,2);type='application/json;charset=utf-8'}else if(kind==='roster'){name+='.csv';body=toCsv(['day','shift','worker_id','worker_name','cost'],solved.assignments.map(a=>[DAYS[a.slot.day],SHIFTS[a.slot.shift],a.worker.id,a.worker.name,a.cost]))}else if(kind==='gaps'){name+='.csv';body=toCsv(['day','shift','required','uncovered'],solved.shortages.filter(x=>x.count).map(x=>[DAYS[x.slot.day],SHIFTS[x.slot.shift],x.slot.required,x.count]))}else if(kind==='workers'||kind==='workers-template'){name+='.csv';body=toCsv(['id','name','skill','wage','cap','pref','available'],state.model.workers.map(w=>[w.id,w.name,w.skill,w.wage,w.cap,w.pref,w.available.join('|')]))}else if(kind==='demand'||kind==='demand-template'){name+='.csv';body=toCsv(['day','shift','required','skill'],state.model.demand.map(s=>[s.day,s.shift,s.required,s.skill]))}else if(kind==='report'){name+='.md';type='text/markdown;charset=utf-8';body=`# ShiftForge audit\n\nScenario: ${state.model.name}\n\nRequired shifts: ${solved.needed}\n\nAssigned: ${solved.used}\n\nUncovered: ${solved.miss}\n\nLabor cost: ${money(solved.labor)}\n\nOptimized objective: ${money(solved.objective)}\n\nGreedy objective: ${money(baseline.objective)}\n\n## Method\nExact min-cost max-flow for a limited, explicit set of constraints. Uncovered demand has a cost penalty and worker workload has a squared-count penalty. Results are synthetic simulations unless validated external data are imported.\n\n## Exclusions\nInter-day rest, labor law, overtime, leave, consent, travel and protected-characteristic equity are not modeled.\n`}else throw Error('Unknown report type');downloadFile(name,body,type);toast('Downloaded '+name)}
function closeMenu(){$('#aside').classList.remove('open');$('#shade').classList.remove('open')}
$('#menuToggle').addEventListener('click',()=>{$('#aside').classList.add('open');$('#shade').classList.add('open')});$('#shade').addEventListener('click',closeMenu);
$('#nav').addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(!b)return;state.page=b.dataset.page;render();closeMenu();window.scrollTo(0,0)});
$('#dataset').addEventListener('change',e=>{state.model=structuredClone(PRESETS[e.target.value]);state.settings={gapPenalty:650,balance:12,nightPremium:15,preferencePenalty:12};refresh();toast('Loaded '+state.model.name+' synthetic dataset')});
$('#exportTop').addEventListener('click',()=>download('audit'));
$('#content').addEventListener('input',e=>{if(e.target.dataset.setting){state.settings[e.target.dataset.setting]=+e.target.value;refresh()}});
$('#content').addEventListener('click',async e=>{let b=e.target.closest('button');if(!b)return;try{if(b.dataset.download){download(b.dataset.download);return}if(b.id==='resetSettings'){state.settings={gapPenalty:650,balance:12,nightPremium:15,preferencePenalty:12};refresh();toast('Default controls restored');return}if(b.id==='importWorkers'||b.id==='importDemand'){const isW=b.id==='importWorkers',f=$(isW?'#workersCsv':'#demandCsv').files[0];if(!f)throw Error('Choose a CSV file first');const t=await f.text();if(isW)importWorkers(t);else importDemand(t);toast(isW?'Worker roster imported':'Demand slots imported')}}catch(err){toast(err.message,true)}});
render();
</script></body></html>
|