Upload 3 files
Browse files- LICENSE +21 -0
- README.md +112 -6
- index.html +54 -18
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Hugdrigo
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,118 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: mit
|
| 9 |
-
short_description: Offline workforce scheduling and coverage analysis
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "ShiftForge | Workforce Scheduling Intelligence"
|
| 3 |
+
emoji: "📅"
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
+
short_description: "Offline workforce scheduling and coverage analysis"
|
| 11 |
+
tags:
|
| 12 |
+
- operations-research
|
| 13 |
+
- workforce-scheduling
|
| 14 |
+
- optimization
|
| 15 |
+
- business-intelligence
|
| 16 |
+
- decision-support
|
| 17 |
+
- javascript
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# ShiftForge | Workforce Scheduling Intelligence
|
| 21 |
+
|
| 22 |
+
**ShiftForge** is an offline-first operations research laboratory for scheduling workers to seven days of morning, evening and night shifts. It solves a constrained *minimum-cost flow* assignment problem and compares the exact modeled optimum against a deterministic greedy baseline.
|
| 23 |
+
|
| 24 |
+
## What the application does
|
| 25 |
+
|
| 26 |
+
- **Command Center:** staffing coverage, shortage counts, labor costs, and model objective compared with a greedy allocator.
|
| 27 |
+
- **Roster Planner:** complete worker-to-shift assignments, qualifications, and per-assignment modeled costs.
|
| 28 |
+
- **Coverage Heatmap:** 7-day × 3-shift coverage matrix and shortage register.
|
| 29 |
+
- **Workload Balance:** hours and shifts by worker and a squared workload concentration penalty.
|
| 30 |
+
- **Scenario Laboratory:** change the shortage penalty, workload balance, night premium, and shift preference penalty, with live recomputation.
|
| 31 |
+
- **Data & Methodology:** import your own data from two validated CSV formats and download audit JSON, roster/gaps/data CSV, and a Markdown methodology report.
|
| 32 |
+
|
| 33 |
+
## No installation required
|
| 34 |
+
|
| 35 |
+
This is a free **Static** Hugging Face Space. The only files required in the Space root are:
|
| 36 |
+
|
| 37 |
+
```text
|
| 38 |
+
README.md
|
| 39 |
+
index.html
|
| 40 |
+
LICENSE
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
No Python backend, CDN, package manager, external API, login, server-side computation, or paid plan is required. All calculations and uploaded CSV parsing happen inside the visitor's browser. **Refreshing the page resets in-memory edits**, so export data before leaving.
|
| 44 |
+
|
| 45 |
+
## Demonstration datasets
|
| 46 |
+
|
| 47 |
+
1. Retail operations.
|
| 48 |
+
2. Hospitality front desk.
|
| 49 |
+
3. Customer support.
|
| 50 |
+
|
| 51 |
+
**All demonstration data, wages, worker profiles, preferences and demand values are synthetic.** No real employee records are included.
|
| 52 |
+
|
| 53 |
+
## Model details
|
| 54 |
+
|
| 55 |
+
Each demand slot is a specific weekday and one of three shift periods. Demand is a non-negative integer between 0 and 8. Each available worker has a skill level, hourly wage, preferred shift, and maximum number of weekly shifts. The solver enforces all of the following **hard** constraints:
|
| 56 |
+
|
| 57 |
+
1. Each assigned worker meets the minimum skill level required by a slot.
|
| 58 |
+
2. The worker explicitly marks that slot as available.
|
| 59 |
+
3. A worker receives at most one shift per day.
|
| 60 |
+
4. Each worker stays within their weekly maximum number of shifts.
|
| 61 |
+
5. No slot is assigned more workers than requested.
|
| 62 |
+
|
| 63 |
+
The objective is the sum of:
|
| 64 |
+
|
| 65 |
+
- Wage expense per eight-hour shift, with the configurable night shift premium.
|
| 66 |
+
- A soft penalty for a shift outside an employee's indicated preference.
|
| 67 |
+
- A quadratic per-worker workload concentration penalty, **balance × (number of assigned shifts)^2**.
|
| 68 |
+
- A penalty for every unfilled required shift.
|
| 69 |
+
|
| 70 |
+
The exact min-cost flow construction represents the quadratic workload term with successive marginal edges. It includes the possibility of leaving shifts unfilled at a penalty, making the objective feasible even when demand exceeds eligible supply. The algorithm is exact **for this model and these constraints**, not for the broader real-world workforce scheduling problem. Money values are illustrative model amounts, denominated in USD.
|
| 71 |
+
|
| 72 |
+
The standalone greedy comparison uses a locally cheapest eligible worker at each slot and may produce a higher objective because it lacks global look-ahead. **The objective is not the payroll figure**. The payroll metric excludes the preference and coverage penalties.
|
| 73 |
+
|
| 74 |
+
## Import your data
|
| 75 |
+
|
| 76 |
+
**Workers CSV**, with columns in any order (all required):
|
| 77 |
+
|
| 78 |
+
`id,name,skill,wage,cap,pref,available`
|
| 79 |
+
|
| 80 |
+
Example:
|
| 81 |
+
|
| 82 |
+
```csv
|
| 83 |
+
id,name,skill,wage,cap,pref,available
|
| 84 |
+
W01,Alex,2,20,5,0,0:0|0:1|1:0|2:0
|
| 85 |
+
W02,Jordan,3,25,4,2,0:2|1:2|2:2
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
- `skill`: integer 1–3; `wage`: non-negative hourly cost (max 250).
|
| 89 |
+
- `cap`: maximum shifts in a week, integer 0–7.
|
| 90 |
+
- `pref`: 0 = Morning, 1 = Evening, 2 = Night.
|
| 91 |
+
- `available`: pipe-separated weekday/shift keys where weekday 0 = Monday, 6 = Sunday, and shift 0 = Morning, 2 = Night. Do not include extra delimiters.
|
| 92 |
+
- Worker IDs must be unique, and contain only letters, numbers, underscores or hyphens.
|
| 93 |
+
- Workers accepted: 1–35.
|
| 94 |
+
|
| 95 |
+
**Demand CSV**, exactly 21 unique rows:
|
| 96 |
+
|
| 97 |
+
`day,shift,required,skill`
|
| 98 |
+
|
| 99 |
+
- `day`: integer 0–6.
|
| 100 |
+
- `shift`: integer 0–2.
|
| 101 |
+
- `required`: workers requested for that slot, integer 0–8.
|
| 102 |
+
- `skill`: minimum level, integer 1–3.
|
| 103 |
+
|
| 104 |
+
A malformed CSV, invalid skill, duplicate ID, invalid availability, or incomplete demand grid is rejected **before** replacing current in-memory data. Download the bundled examples via the *workers template* and *demand template* actions.
|
| 105 |
+
|
| 106 |
+
## Statistical and ethical limitations
|
| 107 |
+
|
| 108 |
+
This prototype does **not** model mandatory inter-day rest, collective bargaining rules, overtime premiums, break periods, labor law, vacations, worker consent, different shift lengths, commuting, or discrimination and protected-category fairness. A manager must verify labor regulations, contracts, actual skills and employee needs before any operational use. Higher balance penalties only discourage uneven shift counts; they are not demographic fairness or equitable employment guarantees.
|
| 109 |
+
|
| 110 |
+
The application does not integrate live schedules, attendance systems, or HR databases. No employee data is transmitted. For sensitive records, use only in an appropriately secured local environment, consider anonymization, and do not upload private personal information to a public Space repository. Data imported by a visitor remains in their own browser until that page is closed or refreshed.
|
| 111 |
+
|
| 112 |
+
## Reproducibility and testing
|
| 113 |
+
|
| 114 |
+
Tested locally in Chromium on desktop and mobile. Additional independent small-case exhaustive search tests compare the objective against the actual optimum. See the separate **technical source and tests package** for reproducibility scripts and local test results. Browser checks do not replace a post-publication review of the Hugging Face app.
|
| 115 |
+
|
| 116 |
+
## License
|
| 117 |
+
|
| 118 |
+
MIT (see `LICENSE`). © 2026 Hugdrigo.
|
index.html
CHANGED
|
@@ -1,19 +1,55 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<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>
|
| 3 |
+
<style>
|
| 4 |
+
: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}}
|
| 5 |
+
</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>
|
| 6 |
+
<script>
|
| 7 |
+
"use strict";
|
| 8 |
+
const DAYS=['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],SHIFTS=['Morning','Evening','Night'];
|
| 9 |
+
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]));
|
| 10 |
+
function seeded(seed){let a=seed>>>0;return()=>((a=(1664525*a+1013904223)>>>0)/4294967296)}
|
| 11 |
+
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}}
|
| 12 |
+
const PRESETS={retail:makePreset('retail'),hospitality:makePreset('hospitality'),support:makePreset('support')};
|
| 13 |
+
let state={page:'overview',model:structuredClone(PRESETS.retail),settings:{gapPenalty:650,balance:12,nightPremium:15,preferencePenalty:12},selectedDay:0,selectedShift:0};
|
| 14 |
+
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.']};
|
| 15 |
+
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}
|
| 16 |
+
for(const preset of Object.values(PRESETS))validateModel(preset);
|
| 17 |
+
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)}
|
| 18 |
+
function eligible(w,slot){return w.skill>=slot.skill&&w.available.includes(`${slot.day}:${slot.shift}`)}
|
| 19 |
+
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.
|
| 20 |
+
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]}
|
| 21 |
+
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.
|
| 22 |
+
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);
|
| 23 |
+
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)}}
|
| 24 |
+
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}}
|
| 25 |
+
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()}
|
| 26 |
+
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>`}
|
| 27 |
+
function header(t,detail,extra=''){return `<div class="section-head"><div><h2>${t}</h2><p class="small">${detail}</p></div>${extra}</div>`}
|
| 28 |
+
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>'}
|
| 29 |
+
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>'}
|
| 30 |
+
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>`}
|
| 31 |
+
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>`}
|
| 32 |
+
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>`}
|
| 33 |
+
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>`}
|
| 34 |
+
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>`}
|
| 35 |
+
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>`}
|
| 36 |
+
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])();}
|
| 37 |
+
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)}
|
| 38 |
+
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}
|
| 39 |
+
function toCsv(head,rows){return [head,...rows].map(r=>r.map(csvEscape).join(',')).join('\r\n')+'\r\n'}
|
| 40 |
+
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}
|
| 41 |
+
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]])))}
|
| 42 |
+
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()}
|
| 43 |
+
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()}
|
| 44 |
+
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']}}
|
| 45 |
+
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)}
|
| 46 |
+
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)}
|
| 47 |
+
function closeMenu(){$('#aside').classList.remove('open');$('#shade').classList.remove('open')}
|
| 48 |
+
$('#menuToggle').addEventListener('click',()=>{$('#aside').classList.add('open');$('#shade').classList.add('open')});$('#shade').addEventListener('click',closeMenu);
|
| 49 |
+
$('#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)});
|
| 50 |
+
$('#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')});
|
| 51 |
+
$('#exportTop').addEventListener('click',()=>download('audit'));
|
| 52 |
+
$('#content').addEventListener('input',e=>{if(e.target.dataset.setting){state.settings[e.target.dataset.setting]=+e.target.value;refresh()}});
|
| 53 |
+
$('#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)}});
|
| 54 |
+
render();
|
| 55 |
+
</script></body></html>
|