Spaces:
Running
Running
Add 3 files
Browse files- README.md +6 -4
- index.html +818 -18
- prompts.txt +0 -0
README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
---
|
| 2 |
+
title: prdtrend1-0
|
| 3 |
+
emoji: ⚛️
|
| 4 |
+
colorFrom: green
|
| 5 |
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- QwenSite
|
| 10 |
---
|
| 11 |
|
| 12 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
index.html
CHANGED
|
@@ -1,19 +1,819 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<title>Curve Master Productivity Monitor</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0f172a; --panel:#101827; --panel2:#0b1222; --text:#e5e7eb; --muted:#94a3b8;
|
| 10 |
+
--teal:#06b6d4; --sky:#38bdf8; --blue:#2563eb; --green:#22c55e; --amber:#facc15; --red:#ef4444; --border:#20304d;
|
| 11 |
+
--radius:14px; --gap:16px;
|
| 12 |
+
}
|
| 13 |
+
*{box-sizing:border-box}
|
| 14 |
+
body{margin:0;font-family:Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)}
|
| 15 |
+
|
| 16 |
+
/* ===== Brand header ===== */
|
| 17 |
+
header{
|
| 18 |
+
display:flex;align-items:center;justify-content:space-between;gap:12px;
|
| 19 |
+
background:#0c1526; padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.08)
|
| 20 |
+
}
|
| 21 |
+
.brand{display:flex;align-items:center;gap:12px}
|
| 22 |
+
.brand-icon{
|
| 23 |
+
width:36px;height:36px;border-radius:10px;background:rgba(255,255,255,.06);
|
| 24 |
+
display:grid;place-items:center;border:1px solid rgba(255,255,255,.18);
|
| 25 |
+
box-shadow:inset 0 0 12px rgba(255,255,255,.06);
|
| 26 |
+
}
|
| 27 |
+
.brand-icon svg{width:22px;height:22px}
|
| 28 |
+
.brand-text{line-height:1}
|
| 29 |
+
.brand-title{
|
| 30 |
+
font-family:Georgia,"Times New Roman",serif;
|
| 31 |
+
font-size:28px; letter-spacing:.2px; color:#dbeafe;
|
| 32 |
+
text-shadow:0 1px 0 rgba(0,0,0,.5);
|
| 33 |
+
}
|
| 34 |
+
.brand-sub{margin-top:2px; font-size:12px; color:#b9c8ff; opacity:.95}
|
| 35 |
+
.hdr-actions{display:flex;gap:8px}
|
| 36 |
+
.btn{height:34px;padding:6px 12px;border-radius:10px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.08);color:#fff;cursor:pointer;font-weight:600}
|
| 37 |
+
.btn:hover{filter:brightness(1.06)}
|
| 38 |
+
.btn-danger{background:#dc2626;border-color:#b91c1c}
|
| 39 |
+
|
| 40 |
+
main{max-width:1200px;margin:20px auto;padding:0 var(--gap)}
|
| 41 |
+
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--gap)}
|
| 42 |
+
.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
|
| 43 |
+
.span-4{grid-column:span 4}.span-8{grid-column:span 8}.span-12{grid-column:span 12}
|
| 44 |
+
h2{margin:0 0 12px 0;font-size:18px;color:#e6f9ff}
|
| 45 |
+
.sub{font-size:12px;color:var(--muted);margin-bottom:10px}
|
| 46 |
+
|
| 47 |
+
.uploader{border:2px dashed var(--sky);padding:16px;text-align:center;border-radius:var(--radius);background:var(--panel2)}
|
| 48 |
+
input[type="file"]{display:block;margin:8px auto;color:var(--muted)}
|
| 49 |
+
label{font-size:12px;color:var(--muted)}
|
| 50 |
+
.controls-row{display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
| 51 |
+
input[type="text"], input[type="number"]{
|
| 52 |
+
background:#0b162c;color:var(--text);border:1px solid var(--border);
|
| 53 |
+
padding:8px 10px;border-radius:10px;outline:none;height:36px
|
| 54 |
+
}
|
| 55 |
+
.num{width:110px}
|
| 56 |
+
.tog{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted);height:36px}
|
| 57 |
+
.pill{padding:6px 12px;border-radius:999px;background:#0b162c;border:1px solid var(--border);color:#cbd5e1;font-size:12px;cursor:pointer}
|
| 58 |
+
|
| 59 |
+
/* Contractor picker */
|
| 60 |
+
#contractorBox{width:100%;height:260px;border:1px solid var(--border);border-radius:10px;background:#0b162c;overflow:auto;padding:6px}
|
| 61 |
+
.c-item{display:flex;align-items:center;gap:8px;padding:6px 6px;border-radius:8px}
|
| 62 |
+
.c-item:hover{background:#0c1426}
|
| 63 |
+
|
| 64 |
+
/* Table */
|
| 65 |
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
| 66 |
+
th,td{padding:10px 12px;border-bottom:1px solid var(--border);text-align:left}
|
| 67 |
+
th{color:var(--sky);background:#0c1426;position:sticky;top:0}
|
| 68 |
+
tr:hover{background:#0c1426}
|
| 69 |
+
.flag{font-weight:700}.flag.red{color:var(--red)}.flag.amber{color:var(--amber)}.flag.green{color:var(--green)}
|
| 70 |
+
|
| 71 |
+
/* Charts */
|
| 72 |
+
.chart{height:380px} canvas{width:100%!important;height:100%!important}
|
| 73 |
+
|
| 74 |
+
/* Slider & Presets */
|
| 75 |
+
.slider-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
| 76 |
+
.chip{display:inline-block;padding:6px 10px;border-radius:999px;border:1px solid var(--border);background:#0b162c;color:#cbd5e1;font-size:12px;margin-right:6px}
|
| 77 |
+
.chip.good{color:var(--green)}.chip.watch{color:var(--amber)}.chip.low{color:var(--red)}
|
| 78 |
+
.preset{cursor:pointer;border:1px solid var(--border);background:#0b162c;padding:6px 10px;border-radius:999px;color:#cbd5e1;font-size:12px}
|
| 79 |
+
.preset.active{background:linear-gradient(90deg,var(--teal),var(--blue));color:#fff;border:none}
|
| 80 |
+
.foot{font-size:12px;color:var(--muted)}
|
| 81 |
+
@media (max-width:900px){.span-4,.span-8,.span-12{grid-column:span 12}}
|
| 82 |
+
|
| 83 |
+
/* Heatmap */
|
| 84 |
+
.heat-wrap{overflow:auto;border:1px solid var(--border);border-radius:12px}
|
| 85 |
+
.heat-table{border-collapse:separate;border-spacing:6px;width:max-content;min-width:100%;background:#0b1325}
|
| 86 |
+
.heat-table thead th{
|
| 87 |
+
position:sticky; top:0; z-index:2;
|
| 88 |
+
background:#0c1426; color:#cde9ff; font-weight:700; padding:10px 8px; border-radius:8px;
|
| 89 |
+
}
|
| 90 |
+
.heat-table tbody th{
|
| 91 |
+
position:sticky; left:0; z-index:1;
|
| 92 |
+
background:#0c1426; color:#e5eefc; font-weight:600; padding:10px 8px; border-radius:8px; white-space:nowrap;
|
| 93 |
+
}
|
| 94 |
+
.hm-cell{
|
| 95 |
+
min-width:74px; text-align:center; font-weight:700; color:white; padding:10px 6px; border-radius:10px;
|
| 96 |
+
box-shadow:inset 0 0 0 1px rgba(0,0,0,.25);
|
| 97 |
+
}
|
| 98 |
+
.hm-red{background:rgba(239,68,68,.9)}
|
| 99 |
+
.hm-amber{background:rgba(250,204,21,.92); color:#1b1b1b}
|
| 100 |
+
.hm-green{background:rgba(34,197,94,.9)}
|
| 101 |
+
.hm-empty{background:#0c1426; color:#64748b; font-weight:600}
|
| 102 |
+
.heat-legend{display:flex;gap:12px;align-items:center;margin-top:8px;color:var(--muted);font-size:12px}
|
| 103 |
+
.leg-chip{width:20px;height:14px;border-radius:4px;display:inline-block}
|
| 104 |
+
|
| 105 |
+
/* Sparklines */
|
| 106 |
+
.spark{display:block;width:120px;height:26px}
|
| 107 |
+
</style>
|
| 108 |
+
</head>
|
| 109 |
+
<body>
|
| 110 |
+
<header>
|
| 111 |
+
<div class="brand">
|
| 112 |
+
<div class="brand-icon" aria-hidden="true">
|
| 113 |
+
<svg viewBox="0 0 24 24" fill="none">
|
| 114 |
+
<path d="M4 17 L10 11 L14 15 L21 8" stroke="#dbeafe" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
| 115 |
+
<circle cx="21" cy="8" r="1.6" fill="#dbeafe"/>
|
| 116 |
+
</svg>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="brand-text">
|
| 119 |
+
<div class="brand-title">Curve Master Productivity Monitor</div>
|
| 120 |
+
<div class="brand-sub">Advanced Productivity Monitor</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="hdr-actions">
|
| 124 |
+
<button id="exportPDF" class="btn">Export PDF + PNG + CSV</button>
|
| 125 |
+
<button id="clearData" class="btn btn-danger">Reset</button>
|
| 126 |
+
</div>
|
| 127 |
+
</header>
|
| 128 |
+
|
| 129 |
+
<main>
|
| 130 |
+
<!-- Upload + Summary (left), Filters (right) -->
|
| 131 |
+
<section class="grid">
|
| 132 |
+
<div class="card span-4" style="display:flex;flex-direction:column;gap:12px">
|
| 133 |
+
<h2>Upload Daily CSV</h2>
|
| 134 |
+
<div class="uploader">
|
| 135 |
+
<input id="fileInput" type="file" accept=".csv" />
|
| 136 |
+
<div class="sub">Headers (aliases OK): Date, Contractor, EarnedHrs, ActualHrs</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div>
|
| 140 |
+
<h2 style="margin-bottom:6px">Import Summary</h2>
|
| 141 |
+
<div id="summary" class="sub">No data yet.</div>
|
| 142 |
+
<div id="importLog" class="sub"></div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div class="card span-8">
|
| 147 |
+
<h2>Filters</h2>
|
| 148 |
+
<div style="display:grid;grid-template-columns:1fr;gap:10px">
|
| 149 |
+
<div>
|
| 150 |
+
<label>Contractor</label>
|
| 151 |
+
<input id="contractorSearch" type="text" placeholder="Search contractors…" />
|
| 152 |
+
<div id="contractorBox"></div>
|
| 153 |
+
<div class="controls-row" style="margin-top:6px">
|
| 154 |
+
<button id="selectAll" class="pill">Select All</button>
|
| 155 |
+
<button id="clearPick" class="pill">Clear</button>
|
| 156 |
+
<div class="tog"><input type="checkbox" id="excludeSundays" checked><label for="excludeSundays">Exclude Sundays</label></div>
|
| 157 |
+
<div class="tog"><input type="checkbox" id="excludeToday" checked><label for="excludeToday">Exclude Today from rolling</label></div>
|
| 158 |
+
<button id="clearFilters" class="pill">Reset Filters</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
</section>
|
| 164 |
+
|
| 165 |
+
<!-- League -->
|
| 166 |
+
<section class="grid" style="margin-top:16px">
|
| 167 |
+
<div class="card span-12" id="leagueCard">
|
| 168 |
+
<h2>Contractor Performance</h2>
|
| 169 |
+
<div class="sub">Sorted by worst 7-Day Avg CPI. Filters apply.</div>
|
| 170 |
+
<div id="leagueScroll" style="max-height:360px;overflow:auto">
|
| 171 |
+
<table id="leagueTable">
|
| 172 |
+
<thead>
|
| 173 |
+
<tr>
|
| 174 |
+
<th>Contractor</th>
|
| 175 |
+
<th>7-Day Avg CPI</th>
|
| 176 |
+
<th>5-Day Avg Earned</th>
|
| 177 |
+
<th>5-Day Avg Burn</th>
|
| 178 |
+
<th>Cum CPI</th>
|
| 179 |
+
<th>Trend (7–10d)</th>
|
| 180 |
+
<th>Flags</th>
|
| 181 |
+
</tr>
|
| 182 |
+
</thead>
|
| 183 |
+
<tbody></tbody>
|
| 184 |
+
</table>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</section>
|
| 188 |
+
|
| 189 |
+
<!-- Rolling CPI -->
|
| 190 |
+
<section class="grid" style="margin-top:16px">
|
| 191 |
+
<div class="card span-12" id="rollingCard">
|
| 192 |
+
<h2>Rolling CPI (Avg of Selected Contractors)</h2>
|
| 193 |
+
<div class="sub"><b>Target = 1.00</b>; green line shows the visible average.</div>
|
| 194 |
+
<div class="slider-row">
|
| 195 |
+
<span>Presets:</span>
|
| 196 |
+
<button class="preset" data-preset="3">3d</button>
|
| 197 |
+
<button class="preset" data-preset="5">5d</button>
|
| 198 |
+
<button class="preset" data-preset="7">7d</button>
|
| 199 |
+
<button class="preset" data-preset="10">10d</button>
|
| 200 |
+
<button class="preset" data-preset="all">All</button>
|
| 201 |
+
<span style="margin-left:16px">Look-back:</span>
|
| 202 |
+
<input id="lookback" type="range" min="3" max="30" value="7"/>
|
| 203 |
+
<span id="lookbackLabel" class="sub">7 days</span>
|
| 204 |
+
</div>
|
| 205 |
+
<div class="chart"><canvas id="rollingChart"></canvas></div>
|
| 206 |
+
<div id="perfChips" style="margin-top:8px"></div>
|
| 207 |
+
</div>
|
| 208 |
+
</section>
|
| 209 |
+
|
| 210 |
+
<!-- Projection -->
|
| 211 |
+
<section class="grid" style="margin-top:16px">
|
| 212 |
+
<div class="card span-12" id="projCard">
|
| 213 |
+
<h2>CPI Projection
|
| 214 |
+
|
| 215 |
+
</h2>
|
| 216 |
+
<div class="sub">Estimates future daily CPI from recent behavior of the selected contractors. Bands show 10–90% and 25–75% percentiles; line is the median path.</div>
|
| 217 |
+
<div class="controls-row" style="margin-bottom:6px">
|
| 218 |
+
<label>Target CPI</label>
|
| 219 |
+
<input id="projTarget" class="num" type="number" step="0.01" value="1.00"/>
|
| 220 |
+
<label>Horizon (days)</label>
|
| 221 |
+
<input id="projHorizon" class="num" type="number" min="15" max="120" step="5" value="90"/>
|
| 222 |
+
<label>Model lookback (days)</label>
|
| 223 |
+
<input id="projLookback" class="num" type="number" min="10" max="60" step="5" value="30"/>
|
| 224 |
+
<span class="sub">Uses most recent CPI dates; Sundays honored by your filter.</span>
|
| 225 |
+
</div>
|
| 226 |
+
<div class="chart"><canvas id="projChart"></canvas></div>
|
| 227 |
+
<div id="projStats" class="sub" style="margin-top:6px"></div>
|
| 228 |
+
</div>
|
| 229 |
+
</section>
|
| 230 |
+
|
| 231 |
+
<!-- Cumulative -->
|
| 232 |
+
<section class="grid" style="margin-top:16px">
|
| 233 |
+
<div class="card span-12" id="cumCard">
|
| 234 |
+
<h2>Cumulative Earned vs Actual</h2>
|
| 235 |
+
<div class="chart"><canvas id="cumChart"></canvas></div>
|
| 236 |
+
<div class="foot">Cumulative across filtered contractors & dates.</div>
|
| 237 |
+
</div>
|
| 238 |
+
</section>
|
| 239 |
+
|
| 240 |
+
<!-- Daily CPI Heatmap -->
|
| 241 |
+
<section class="grid" style="margin-top:16px">
|
| 242 |
+
<div class="card span-12" id="heatCard">
|
| 243 |
+
<h2>Daily CPI Heatmap</h2>
|
| 244 |
+
<div class="heat-wrap" id="heatWrap">
|
| 245 |
+
<table class="heat-table" id="heatTable"></table>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="heat-legend">
|
| 248 |
+
<span class="leg-chip" style="background:rgba(239,68,68,.9)"></span> < 0.80
|
| 249 |
+
<span class="leg-chip" style="background:rgba(250,204,21,.92)"></span> 0.80–0.99
|
| 250 |
+
<span class="leg-chip" style="background:rgba(34,197,94,.9)"></span> ≥ 1.00
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
</section>
|
| 254 |
+
</main>
|
| 255 |
+
|
| 256 |
+
<!-- Libs -->
|
| 257 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
|
| 258 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/dayjs.min.js"></script>
|
| 259 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script>
|
| 260 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
| 261 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 262 |
+
|
| 263 |
+
<script>
|
| 264 |
+
/* ===== STATE ===== */
|
| 265 |
+
const state = {
|
| 266 |
+
rows: [],
|
| 267 |
+
filters: { contractors:[], excludeSundays:true, excludeTodayInRolling:true },
|
| 268 |
+
charts: { cum:null, rolling:null, proj:null },
|
| 269 |
+
lookbackDays: 'all'
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const HEADER_ALIASES = {
|
| 273 |
+
Date:['date','workdate','reportdate'],
|
| 274 |
+
Contractor:['contractor','company','vendor'],
|
| 275 |
+
EarnedHrs:['earnedhrs','earned','evhrs','scheduleearn'],
|
| 276 |
+
ActualHrs:['actualhrs','actual','paidhours','paidhrs','timesheethrs','timesheet']
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
/* ===== HELPERS ===== */
|
| 280 |
+
const el = id => document.getElementById(id);
|
| 281 |
+
const toISO = v => { const d = dayjs(v); return d.isValid()? d.format('YYYY-MM-DD'):null; };
|
| 282 |
+
const isSunday = iso => dayjs(iso).day()===0;
|
| 283 |
+
const normalizeHeader=(h)=>{ if(!h) return null; const k=h.toString().trim().toLowerCase();
|
| 284 |
+
for(const canon in HEADER_ALIASES){ const set=[canon.toLowerCase(),...HEADER_ALIASES[canon]]; if(set.includes(k)) return canon; } return null; };
|
| 285 |
+
|
| 286 |
+
// BLANKS -> null, numbers left as numbers; zeros are kept as 0 (not null)
|
| 287 |
+
const num = x => {
|
| 288 |
+
if (x === undefined || x === null || String(x).trim() === '') return null;
|
| 289 |
+
const n = Number(String(x).replace(/,/g,'').trim());
|
| 290 |
+
return Number.isFinite(n) ? n : null;
|
| 291 |
+
};
|
| 292 |
+
|
| 293 |
+
/* ===== IMPORT ===== */
|
| 294 |
+
el('fileInput').addEventListener('change', (e)=>{
|
| 295 |
+
const f=e.target.files?.[0]; if(!f) return;
|
| 296 |
+
Papa.parse(f,{header:true,skipEmptyLines:true,complete:(res)=>{
|
| 297 |
+
const headerMap={}; for(const h of res.meta.fields){ const canon=normalizeHeader(h); if(canon) headerMap[canon]=h; }
|
| 298 |
+
for(const must of ['Date','Contractor','EarnedHrs','ActualHrs']) if(!headerMap[must]){ el('importLog').innerHTML='<span style="color:#ef4444">Missing required headers.</span>'; return; }
|
| 299 |
+
const rows=[];
|
| 300 |
+
for(const r of res.data){
|
| 301 |
+
const d=toISO(r[headerMap.Date]); const c=(r[headerMap.Contractor]||'').trim();
|
| 302 |
+
const e=num(r[headerMap.EarnedHrs]); const a=num(r[headerMap.ActualHrs]);
|
| 303 |
+
if(!d || !c) continue; rows.push({Date:d, Contractor:c, EarnedHrs:e, ActualHrs:a});
|
| 304 |
+
}
|
| 305 |
+
state.rows.push(...rows);
|
| 306 |
+
el('importLog').textContent = `Imported ${rows.length} rows.`;
|
| 307 |
+
buildContractorList(); renderAll();
|
| 308 |
+
}});
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
/* ===== HEADER ACTIONS (EXPORT) ===== */
|
| 312 |
+
document.getElementById('clearData').addEventListener('click', ()=>{
|
| 313 |
+
state.rows=[]; state.filters={ contractors:[], excludeSundays:true, excludeTodayInRolling:true };
|
| 314 |
+
el('contractorSearch').value=''; el('importLog').textContent='Cleared all data.'; buildContractorList(); destroyCharts(); renderAll();
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
document.getElementById('exportPDF').addEventListener('click', async ()=>{
|
| 318 |
+
const { jsPDF } = window.jspdf;
|
| 319 |
+
|
| 320 |
+
const ts = dayjs().format('YYYY-MM-DD_HH-mm');
|
| 321 |
+
const runLabel = `Run: ${dayjs().format('YYYY-MM-DD HH:mm')}`;
|
| 322 |
+
const titleTxt = 'Curve Master Productivity Monitor';
|
| 323 |
+
|
| 324 |
+
const doc = new jsPDF({orientation:'landscape', unit:'pt', format:'letter'}); // 792x612
|
| 325 |
+
const pageW = doc.internal.pageSize.getWidth();
|
| 326 |
+
const pageH = doc.internal.pageSize.getHeight();
|
| 327 |
+
const M = 24, headerH = 44;
|
| 328 |
+
|
| 329 |
+
function drawHeader(){
|
| 330 |
+
doc.setFillColor(12,21,38); doc.rect(0,0,pageW,headerH,'F');
|
| 331 |
+
doc.setFont('helvetica','bold'); doc.setFontSize(16); doc.setTextColor(219,234,254);
|
| 332 |
+
doc.text(titleTxt, M, 28);
|
| 333 |
+
doc.setFont('helvetica','normal'); doc.setFontSize(11); doc.setTextColor(185,200,255);
|
| 334 |
+
const w = doc.getTextWidth(runLabel); doc.text(runLabel, pageW-M-w, 28);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
drawHeader();
|
| 338 |
+
let y = M + headerH;
|
| 339 |
+
|
| 340 |
+
async function addCard(selector, before, after){
|
| 341 |
+
const node = document.querySelector(selector); if(!node) return;
|
| 342 |
+
if(before) before();
|
| 343 |
+
const canvas = await html2canvas(node, { backgroundColor:'#0f172a', useCORS:true, scale:2 });
|
| 344 |
+
if(after) after();
|
| 345 |
+
const img = canvas.toDataURL('image/png');
|
| 346 |
+
const drawW = pageW - 2*M;
|
| 347 |
+
const drawH = canvas.height * (drawW / canvas.width);
|
| 348 |
+
if (y + drawH > pageH - M){ doc.addPage(); drawHeader(); y = M + headerH; }
|
| 349 |
+
doc.addImage(img, 'PNG', M, y, drawW, drawH);
|
| 350 |
+
y += drawH + 12;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
const expandLeague = ()=>{ const sc = document.getElementById('leagueScroll'); if(!sc) return; sc._prev={h:sc.style.maxHeight,o:sc.style.overflow}; sc.style.maxHeight='none'; sc.style.overflow='visible'; };
|
| 354 |
+
const restoreLeague= ()=>{ const sc = document.getElementById('leagueScroll'); if(sc&&sc._prev){ sc.style.maxHeight=sc._prev.h; sc.style.overflow=sc._prev.o; sc._prev=null; } };
|
| 355 |
+
const expandHeat = ()=>{ const hw = document.getElementById('heatWrap'); if(!hw) return; hw._prev={o:hw.style.overflow}; hw.style.overflow='visible'; };
|
| 356 |
+
const restoreHeat = ()=>{ const hw = document.getElementById('heatWrap'); if(hw&&hw._prev){ hw.style.overflow=hw._prev.o; hw._prev=null; } };
|
| 357 |
+
|
| 358 |
+
await addCard('#leagueCard', expandLeague, restoreLeague);
|
| 359 |
+
await addCard('#rollingCard');
|
| 360 |
+
await addCard('#projCard');
|
| 361 |
+
await addCard('#cumCard');
|
| 362 |
+
await addCard('#heatCard', expandHeat, restoreHeat);
|
| 363 |
+
|
| 364 |
+
doc.save(`CurveMaster_Report_${ts}.pdf`);
|
| 365 |
+
|
| 366 |
+
// Extras
|
| 367 |
+
const heatCard = document.querySelector('#heatWrap');
|
| 368 |
+
if (heatCard){
|
| 369 |
+
const heatCanvas = await html2canvas(heatCard, { backgroundColor:'#0f172a', useCORS:true, scale:1.5 });
|
| 370 |
+
const link = document.createElement('a');
|
| 371 |
+
link.href = heatCanvas.toDataURL('image/png');
|
| 372 |
+
link.download = `CurveMaster_Heatmap_${ts}.png`;
|
| 373 |
+
link.click();
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
const csv = buildMetricsCSV();
|
| 377 |
+
if (csv){
|
| 378 |
+
const blob = new Blob(["\ufeff"+csv], {type: "text/csv;charset=utf-8;"});
|
| 379 |
+
const link = document.createElement('a');
|
| 380 |
+
link.href = URL.createObjectURL(blob);
|
| 381 |
+
link.download = `CurveMaster_Metrics_${ts}.csv`;
|
| 382 |
+
link.click();
|
| 383 |
+
}
|
| 384 |
+
});
|
| 385 |
+
|
| 386 |
+
function destroyCharts(){
|
| 387 |
+
if(state.charts.cum){state.charts.cum.destroy();state.charts.cum=null}
|
| 388 |
+
if(state.charts.rolling){state.charts.rolling.destroy();state.charts.rolling=null}
|
| 389 |
+
if(state.charts.proj){state.charts.proj.destroy();state.charts.proj=null}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
/* ===== CONTRACTOR PICKER ===== */
|
| 393 |
+
function buildContractorList(){
|
| 394 |
+
const box=el('contractorBox'); const q=el('contractorSearch').value?.toLowerCase()||'';
|
| 395 |
+
const names=[...new Set(state.rows.map(r=>r.Contractor))].sort().filter(n=>n.toLowerCase().includes(q));
|
| 396 |
+
const sel=new Set(state.filters.contractors);
|
| 397 |
+
box.innerHTML = names.map(n=>`<label class="c-item"><input type="checkbox" value="${n.replace(/"/g,'"')}" ${sel.has(n)?'checked':''}/> ${n}</label>`).join('') || '<div class="sub">No contractors</div>';
|
| 398 |
+
box.querySelectorAll('input[type="checkbox"]').forEach(cb=>{
|
| 399 |
+
cb.addEventListener('change', ()=>{
|
| 400 |
+
const set=new Set(state.filters.contractors);
|
| 401 |
+
if(cb.checked) set.add(cb.value); else set.delete(cb.value);
|
| 402 |
+
state.filters.contractors=[...set]; renderAll();
|
| 403 |
+
});
|
| 404 |
+
});
|
| 405 |
+
}
|
| 406 |
+
el('contractorSearch').addEventListener('input', buildContractorList);
|
| 407 |
+
el('selectAll').addEventListener('click',()=>{ const names=[...new Set(state.rows.map(r=>r.Contractor))]; state.filters.contractors=names; buildContractorList(); renderAll(); });
|
| 408 |
+
el('clearPick').addEventListener('click',()=>{ state.filters.contractors=[]; buildContractorList(); renderAll(); });
|
| 409 |
+
|
| 410 |
+
/* ===== FILTERED DATA & METRICS ===== */
|
| 411 |
+
function filteredRows(){
|
| 412 |
+
let rows=[...state.rows];
|
| 413 |
+
if(state.filters.contractors.length){ const set=new Set(state.filters.contractors); rows=rows.filter(r=>set.has(r.Contractor)); }
|
| 414 |
+
if(state.filters.excludeSundays) rows=rows.filter(r=>!isSunday(r.Date));
|
| 415 |
+
return rows.sort((a,b)=>a.Date.localeCompare(b.Date)||a.Contractor.localeCompare(b.Contractor));
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// CPI: if Actual missing or 0 -> null; else (Earned null treated as 0) / Actual
|
| 419 |
+
const calcDaily = r => {
|
| 420 |
+
const E = r.EarnedHrs;
|
| 421 |
+
const A = r.ActualHrs;
|
| 422 |
+
const CPI = (A == null || A === 0) ? null : ((E ?? 0) / A);
|
| 423 |
+
return { ...r, CPI, BurnHrs: A };
|
| 424 |
+
};
|
| 425 |
+
|
| 426 |
+
function groupByContractor(rows){
|
| 427 |
+
const m=new Map();
|
| 428 |
+
for(const r of rows){ if(!m.has(r.Contractor)) m.set(r.Contractor,[]); m.get(r.Contractor).push(r); }
|
| 429 |
+
for(const v of m.values()) v.sort((a,b)=>a.Date.localeCompare(b.Date));
|
| 430 |
+
return m;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// Per-day aggregation keeping zeros; whole-day missing -> null
|
| 434 |
+
function daysFor(rows){
|
| 435 |
+
const by = new Map();
|
| 436 |
+
for (const r of rows){
|
| 437 |
+
const d = r.Date;
|
| 438 |
+
if (!by.has(d)) by.set(d, { Date:d, EarnedHrs:0, BurnHrs:0, hasE:false, hasB:false });
|
| 439 |
+
const agg = by.get(d);
|
| 440 |
+
if (r.EarnedHrs != null) { agg.EarnedHrs += r.EarnedHrs; agg.hasE = true; }
|
| 441 |
+
if (r.BurnHrs != null) { agg.BurnHrs += r.BurnHrs; agg.hasB = true; }
|
| 442 |
+
}
|
| 443 |
+
return [...by.values()]
|
| 444 |
+
.sort((a,b)=>a.Date.localeCompare(b.Date))
|
| 445 |
+
.map(d=>({
|
| 446 |
+
Date:d.Date,
|
| 447 |
+
EarnedHrs: d.hasE ? d.EarnedHrs : null,
|
| 448 |
+
BurnHrs: d.hasB ? d.BurnHrs : null
|
| 449 |
+
}));
|
| 450 |
+
}
|
| 451 |
+
function avgLastNDates(rows, n, key){
|
| 452 |
+
const days = daysFor(rows);
|
| 453 |
+
const last = days.slice(-n).map(d=>d[key]).filter(v=>v!=null); // keep zeros, drop nulls
|
| 454 |
+
if (!last.length) return null;
|
| 455 |
+
return last.reduce((a,b)=>a+b,0) / last.length;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
function rollingAvg(series,days,{excludeToday=false}={}){
|
| 459 |
+
if(!series.length) return null; const last=series[series.length-1]; const end=dayjs(last.Date); const eff=excludeToday?end.subtract(1,'day'):end; const start=eff.subtract(days-1,'day');
|
| 460 |
+
const w=series.filter(p=>{const d=dayjs(p.Date); return d.isAfter(start.subtract(1,'day'))&&d.isBefore(eff.add(1,'day'))&&p.value!=null;});
|
| 461 |
+
if(!w.length) return null; return w.reduce((a,p)=>a+Number(p.value),0)/w.length;
|
| 462 |
+
}
|
| 463 |
+
const sevenDayAvgCPI = rows => rollingAvg(rows.map(r=>({Date:r.Date,value:r.CPI})),7);
|
| 464 |
+
|
| 465 |
+
function cumulativeByDate(rows){
|
| 466 |
+
const map={};
|
| 467 |
+
for(const r of rows){
|
| 468 |
+
map[r.Date]??={E:0,A:0};
|
| 469 |
+
map[r.Date].E += (r.EarnedHrs ?? 0);
|
| 470 |
+
map[r.Date].A += (r.ActualHrs ?? 0);
|
| 471 |
+
}
|
| 472 |
+
let e=0,a=0;
|
| 473 |
+
return Object.keys(map).sort().map(d=>{ e+=map[d].E; a+=map[d].A; return {Date:d,CumEarned:e,CumActual:a}; });
|
| 474 |
+
}
|
| 475 |
+
function cumulativeCPI(rows){
|
| 476 |
+
let e=0,a=0;
|
| 477 |
+
for(const r of rows){ e += (r.EarnedHrs ?? 0); a += (r.ActualHrs ?? 0); }
|
| 478 |
+
return a ? e/a : null;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/* ===== Chart helpers (crosshair + horizontal lines) ===== */
|
| 482 |
+
const crosshairPlugin={ id:'cmCross', afterDatasetsDraw(chart,args,opt){ const act=chart.tooltip?.getActiveElements?.(); if(!act||!act.length) return; const {ctx,chartArea,scales:{x}}=chart; const i=act[0].index; const px=x.getPixelForValue(i); ctx.save(); ctx.strokeStyle=opt?.color||'#38bdf8'; ctx.setLineDash([4,3]); ctx.lineWidth=1; ctx.beginPath(); ctx.moveTo(px,chartArea.top); ctx.lineTo(px,chartArea.bottom); ctx.stroke(); ctx.restore(); } };
|
| 483 |
+
const hlinePlugin={ id:'cmH', afterDatasetsDraw(chart,args,opt){ const lines=opt?.lines||[]; const {ctx,chartArea,scales:{y}}=chart; ctx.save(); lines.forEach(l=>{ const ypx=y.getPixelForValue(l.value); ctx.strokeStyle=l.color||'#94a3b8'; ctx.setLineDash([6,4]); ctx.lineWidth=1.25; ctx.beginPath(); ctx.moveTo(chartArea.left,ypx); ctx.lineTo(chartArea.right,ypx); ctx.stroke(); if(l.label){ ctx.fillStyle=l.color||'#94a3b8'; ctx.font='12px Arial'; ctx.fillText(l.label, chartArea.left+6, ypx-6);} }); ctx.restore(); } };
|
| 484 |
+
Chart.register(crosshairPlugin,hlinePlugin);
|
| 485 |
+
|
| 486 |
+
/* ===== League (with sparklines) ===== */
|
| 487 |
+
function lastNDailyCPI(rows, n=10){
|
| 488 |
+
const arr = rows.map(r=>r.CPI).filter(v=>v!=null && isFinite(v));
|
| 489 |
+
return arr.slice(-n);
|
| 490 |
+
}
|
| 491 |
+
function renderLeague(){
|
| 492 |
+
const tbody=document.querySelector('#leagueTable tbody'); tbody.innerHTML='';
|
| 493 |
+
const byC=groupByContractor(filteredRows().map(calcDaily));
|
| 494 |
+
const out=[];
|
| 495 |
+
for(const [name,rows] of byC){
|
| 496 |
+
const c7=sevenDayAvgCPI(rows);
|
| 497 |
+
const e5=avgLastNDates(rows,5,'EarnedHrs');
|
| 498 |
+
const b5=avgLastNDates(rows,5,'BurnHrs');
|
| 499 |
+
const cum=cumulativeCPI(rows);
|
| 500 |
+
const trend = lastNDailyCPI(rows, 10);
|
| 501 |
+
let flag=''; if(c7==null) flag='<span class="flag amber">No data</span>'; else if(c7<0.80) flag='<span class="flag red">🔴 Low</span>'; else if(c7<1.00) flag='<span class="flag amber">🟡 Watch</span>'; else flag='<span class="flag green">🟢 Good</span>';
|
| 502 |
+
out.push({
|
| 503 |
+
name,
|
| 504 |
+
c7: c7==null?'-':c7.toFixed(2),
|
| 505 |
+
e5: e5==null?'-':Math.round(e5).toLocaleString(),
|
| 506 |
+
b5: b5==null?'-':Math.round(b5).toLocaleString(),
|
| 507 |
+
cum: cum==null?'-':cum.toFixed(2),
|
| 508 |
+
trend,
|
| 509 |
+
flag
|
| 510 |
+
});
|
| 511 |
+
}
|
| 512 |
+
out.sort((a,b)=>(a.c7==='-'?999:+a.c7)-(b.c7==='-'?999:+b.c7));
|
| 513 |
+
for(const r of out){
|
| 514 |
+
const tr=document.createElement('tr');
|
| 515 |
+
const sparkId = 'spk_'+Math.random().toString(36).slice(2);
|
| 516 |
+
tr.innerHTML=
|
| 517 |
+
`<td>${r.name}</td>
|
| 518 |
+
<td>${r.c7}</td>
|
| 519 |
+
<td>${r.e5}</td>
|
| 520 |
+
<td>${r.b5}</td>
|
| 521 |
+
<td>${r.cum}</td>
|
| 522 |
+
<td><canvas class="spark" id="${sparkId}" width="120" height="26" data-points="${encodeURIComponent(JSON.stringify(r.trend))}"></canvas></td>
|
| 523 |
+
<td>${r.flag}</td>`;
|
| 524 |
+
tbody.appendChild(tr);
|
| 525 |
+
}
|
| 526 |
+
renderSparklines();
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function renderSparklines(){
|
| 530 |
+
document.querySelectorAll('canvas.spark').forEach(cv=>{
|
| 531 |
+
const pts = JSON.parse(decodeURIComponent(cv.getAttribute('data-points')||'[]'));
|
| 532 |
+
const ctx = cv.getContext('2d'); const w=cv.width, h=cv.height; ctx.clearRect(0,0,w,h);
|
| 533 |
+
if(!pts.length){ ctx.fillStyle='#64748b'; ctx.font='12px Arial'; ctx.fillText('—', w/2-3, h/2+4); return; }
|
| 534 |
+
let min = Math.min(...pts, 1.0), max = Math.max(...pts, 1.0);
|
| 535 |
+
if(min===max){ min-=0.1; max+=0.1; }
|
| 536 |
+
const padY=3, padX=2;
|
| 537 |
+
const x = i => padX + (w-2*padX) * (i/(pts.length-1||1));
|
| 538 |
+
const y = v => padY + (h-2*padY) * (1 - (v-min)/(max-min));
|
| 539 |
+
ctx.strokeStyle='rgba(239,68,68,.8)'; ctx.setLineDash([4,3]); ctx.beginPath();
|
| 540 |
+
const yT = y(1.0); ctx.moveTo(padX, yT); ctx.lineTo(w-padX, yT); ctx.stroke(); ctx.setLineDash([]);
|
| 541 |
+
ctx.strokeStyle='#38bdf8'; ctx.lineWidth=1.5; ctx.beginPath();
|
| 542 |
+
pts.forEach((v,i)=>{ const xi=x(i), yi=y(v); i?ctx.lineTo(xi,yi):ctx.moveTo(xi,yi); });
|
| 543 |
+
ctx.stroke();
|
| 544 |
+
const lastY = y(pts[pts.length-1]), lastX = x(pts.length-1);
|
| 545 |
+
ctx.fillStyle='#22c55e'; ctx.beginPath(); ctx.arc(lastX,lastY,2.2,0,Math.PI*2); ctx.fill();
|
| 546 |
+
});
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/* ===== Cumulative ===== */
|
| 550 |
+
function renderCumChart(){
|
| 551 |
+
const ctx=el('cumChart'); const series=cumulativeByDate(filteredRows());
|
| 552 |
+
const labels=series.map(p=>p.Date), e=series.map(p=>p.CumEarned), a=series.map(p=>p.CumActual);
|
| 553 |
+
if(state.charts.cum) state.charts.cum.destroy();
|
| 554 |
+
state.charts.cum=new Chart(ctx,{type:'line',data:{labels,datasets:[{label:'Cum Earned',data:e,tension:.25,borderWidth:2},{label:'Cum Actual',data:a,tension:.25,borderWidth:2}]},
|
| 555 |
+
options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},
|
| 556 |
+
plugins:{legend:{labels:{color:'#cbd5e1'}},tooltip:{mode:'index',intersect:false}},
|
| 557 |
+
scales:{x:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.15)'}},y:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.15)'}}}}});
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/* ===== Rolling CPI ===== */
|
| 561 |
+
function avgDailyCPI(){
|
| 562 |
+
const byC=groupByContractor(filteredRows().map(calcDaily)); const byDate={};
|
| 563 |
+
for(const arr of byC.values()){ for(const r of arr){ if(r.CPI==null) continue; (byDate[r.Date]??=[]).push(r.CPI); } }
|
| 564 |
+
return Object.keys(byDate).sort().map(d=>({Date:d,value:byDate[d].reduce((a,x)=>a+x,0)/byDate[d].length}));
|
| 565 |
+
}
|
| 566 |
+
function avgLast(series,n){ const s=series.slice(-n); if(!s.length) return null; return s.reduce((a,p)=>a+p.value,0)/s.length; }
|
| 567 |
+
function chipClass(v){ if(v==null) return 'chip'; if(v<0.80) return 'chip low'; if(v<1.00) return 'chip watch'; return 'chip good'; }
|
| 568 |
+
|
| 569 |
+
function renderRollingChart(){
|
| 570 |
+
const ctx=el('rollingChart'); const series=avgDailyCPI(); const dates=series.map(s=>s.Date); const vals=series.map(s=>+s.value.toFixed(3));
|
| 571 |
+
let startIdx=0; if(state.lookbackDays!=='all'){ const n=+state.lookbackDays; startIdx=Math.max(0,vals.length-n); }
|
| 572 |
+
const visDates=dates.slice(startIdx), visVals=vals.slice(startIdx);
|
| 573 |
+
|
| 574 |
+
const nonNull=visVals.filter(v=>v!=null); const visAvg=nonNull.length? nonNull.reduce((a,b)=>a+b,0)/nonNull.length : null;
|
| 575 |
+
const vMin = Math.min(...nonNull, 1.00, ...(visAvg?[visAvg]:[]));
|
| 576 |
+
const vMax = Math.max(...nonNull, 1.00, ...(visAvg?[visAvg]:[]));
|
| 577 |
+
const pad = Math.max(0.05, (vMax - vMin) * 0.15);
|
| 578 |
+
|
| 579 |
+
if(state.charts.rolling) state.charts.rolling.destroy();
|
| 580 |
+
state.charts.rolling=new Chart(ctx,{
|
| 581 |
+
type:'line',
|
| 582 |
+
data:{labels:visDates,datasets:[{label:'Rolling CPI (daily avg of selected)',data:visVals,tension:.25,borderWidth:2}]},
|
| 583 |
+
options:{
|
| 584 |
+
responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},
|
| 585 |
+
plugins:{
|
| 586 |
+
legend:{labels:{color:'#cbd5e1'}},
|
| 587 |
+
tooltip:{mode:'index',intersect:false},
|
| 588 |
+
cmCross:{ color:'#38bdf8' },
|
| 589 |
+
cmH:{ lines:[
|
| 590 |
+
{ value:1.0, color:'#ef4444', label:'Target 1.00' },
|
| 591 |
+
...(visAvg!=null ? [{ value:visAvg, color:'#22c55e', label:`Avg ${visAvg.toFixed(2)}` }] : [])
|
| 592 |
+
]}
|
| 593 |
+
},
|
| 594 |
+
scales:{
|
| 595 |
+
x:{ticks:{color:'#94a3b8',autoSkip:true,maxRotation:0},grid:{color:'rgba(148,163,184,.12)'}},
|
| 596 |
+
y:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.12)'},
|
| 597 |
+
suggestedMin: vMin - pad, suggestedMax: vMax + pad }
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
});
|
| 601 |
+
|
| 602 |
+
const perf=[3,5,7,10].map(n=>{ const v=avgLast(series,n); return `<span class="${chipClass(v)}">${n}-Day Avg CPI: <b>${v==null?'-':v.toFixed(2)}</b></span>`; }).join('');
|
| 603 |
+
el('perfChips').innerHTML=perf;
|
| 604 |
+
|
| 605 |
+
const slider=el('lookback'); const max=Math.max(3,dates.length); slider.max=max;
|
| 606 |
+
if(state.lookbackDays==='all'){ slider.value=Math.min(30,max); el('lookbackLabel').textContent='All data'; setPresetActive('all'); }
|
| 607 |
+
else { slider.value=state.lookbackDays; el('lookbackLabel').textContent=`${state.lookbackDays} days`; setPresetActive(state.lookbackDays); }
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
function setPresetActive(v){ document.querySelectorAll('.preset').forEach(b=>b.classList.toggle('active', b.dataset.preset==String(v))); }
|
| 611 |
+
el('lookback').addEventListener('input',e=>{ state.lookbackDays=+e.target.value; el('lookbackLabel').textContent=`${state.lookbackDays} days`; setPresetActive(state.lookbackDays); renderRollingChart(); });
|
| 612 |
+
document.querySelectorAll('.preset').forEach(b=>b.addEventListener('click',()=>{ state.lookbackDays=(b.dataset.preset==='all'?'all':+b.dataset.preset); renderRollingChart(); }));
|
| 613 |
+
|
| 614 |
+
/* ===== Projection (Monte Carlo) ===== */
|
| 615 |
+
function forwardDates(startISO, days, skipSundays=true){
|
| 616 |
+
const out=[]; let d=dayjs(startISO);
|
| 617 |
+
while(out.length<days){
|
| 618 |
+
d=d.add(1,'day');
|
| 619 |
+
if(skipSundays && d.day()===0) continue;
|
| 620 |
+
out.push(d.format('YYYY-MM-DD'));
|
| 621 |
+
}
|
| 622 |
+
return out;
|
| 623 |
+
}
|
| 624 |
+
function simulatePaths(startVal, mu, sigma, steps, sims){
|
| 625 |
+
const paths=new Array(sims);
|
| 626 |
+
for(let s=0;s<sims;s++){
|
| 627 |
+
const p=new Array(steps);
|
| 628 |
+
let v=startVal;
|
| 629 |
+
for(let i=0;i<steps;i++){
|
| 630 |
+
const u1=Math.random()||1e-9, u2=Math.random()||1e-9;
|
| 631 |
+
const z=Math.sqrt(-2*Math.log(u1))*Math.cos(2*Math.PI*u2);
|
| 632 |
+
v = v + mu + sigma*z; // additive on CPI
|
| 633 |
+
v = Math.max(0.2, Math.min(2.5, v));
|
| 634 |
+
p[i]=+v.toFixed(3);
|
| 635 |
+
}
|
| 636 |
+
paths[s]=p;
|
| 637 |
+
}
|
| 638 |
+
return paths;
|
| 639 |
+
}
|
| 640 |
+
function quantile(arr,q){
|
| 641 |
+
if(!arr.length) return null;
|
| 642 |
+
const sorted=[...arr].sort((a,b)=>a-b);
|
| 643 |
+
const idx=(sorted.length-1)*q;
|
| 644 |
+
const lo=Math.floor(idx), hi=Math.ceil(idx);
|
| 645 |
+
if(lo===hi) return sorted[lo];
|
| 646 |
+
return sorted[lo] + (sorted[hi]-sorted[lo])*(idx-lo);
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
function renderProjection(){
|
| 650 |
+
const target = parseFloat(el('projTarget').value)||1.0;
|
| 651 |
+
const horizon = Math.max(15, Math.min(120, parseInt(el('projHorizon').value)||90));
|
| 652 |
+
const lookback = Math.max(10, Math.min(60, parseInt(el('projLookback').value)||30));
|
| 653 |
+
|
| 654 |
+
const series = avgDailyCPI(); // [{Date, value}]
|
| 655 |
+
if(!series.length){
|
| 656 |
+
el('projStats').innerHTML = 'No CPI history available for projection.';
|
| 657 |
+
if(state.charts.proj){ state.charts.proj.destroy(); state.charts.proj=null; }
|
| 658 |
+
return;
|
| 659 |
+
}
|
| 660 |
+
const last = series[series.length-1];
|
| 661 |
+
const hist = series.slice(-lookback);
|
| 662 |
+
const deltas = [];
|
| 663 |
+
for(let i=1;i<hist.length;i++){ deltas.push( (hist[i].value - hist[i-1].value) ); }
|
| 664 |
+
const mu = deltas.length ? deltas.reduce((a,b)=>a+b,0)/deltas.length : 0;
|
| 665 |
+
const sd = deltas.length ? Math.sqrt(deltas.reduce((a,b)=>a+b*b,0)/deltas.length - mu*mu) : 0.05;
|
| 666 |
+
|
| 667 |
+
const steps = horizon, sims = 400;
|
| 668 |
+
const paths = simulatePaths(hist[hist.length-1].value, mu, sd, steps, sims);
|
| 669 |
+
|
| 670 |
+
const p10=[], p25=[], p50=[], p75=[], p90=[];
|
| 671 |
+
for(let i=0;i<steps;i++){
|
| 672 |
+
const col = paths.map(p=>p[i]);
|
| 673 |
+
p10.push(quantile(col,0.10));
|
| 674 |
+
p25.push(quantile(col,0.25));
|
| 675 |
+
p50.push(quantile(col,0.50));
|
| 676 |
+
p75.push(quantile(col,0.75));
|
| 677 |
+
p90.push(quantile(col,0.90));
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
const pastN = 30;
|
| 681 |
+
const past = series.slice(-pastN);
|
| 682 |
+
const forward = forwardDates(last.Date, steps, state.filters.excludeSundays);
|
| 683 |
+
const labels = past.map(p=>p.Date).concat(forward);
|
| 684 |
+
|
| 685 |
+
const ds = [];
|
| 686 |
+
ds.push({label:'Past CPI', data: past.map(p=>+p.value.toFixed(3)).concat(new Array(steps).fill(null)),
|
| 687 |
+
borderColor:'#22c55e', pointRadius:0, borderWidth:2, tension:.25});
|
| 688 |
+
const bandColor1='rgba(56,189,248,0.18)', bandColor2='rgba(56,189,248,0.32)';
|
| 689 |
+
const padNulls = arr => new Array(past.length).fill(null).concat(arr);
|
| 690 |
+
ds.push({label:'90th', data: padNulls(p90), pointRadius:0, borderWidth:0});
|
| 691 |
+
ds.push({label:'10-90% band', data: padNulls(p10), pointRadius:0, borderWidth:0,
|
| 692 |
+
fill:{ target:'-1', above:bandColor1, below:bandColor1 }});
|
| 693 |
+
ds.push({label:'75th', data: padNulls(p75), pointRadius:0, borderWidth:0});
|
| 694 |
+
ds.push({label:'25-75% band', data: padNulls(p25), pointRadius:0, borderWidth:0,
|
| 695 |
+
fill:{ target:'-1', above:bandColor2, below:bandColor2 }});
|
| 696 |
+
ds.push({label:'Median', data: padNulls(p50), borderColor:'#38bdf8', pointRadius:0, borderWidth:2, tension:.25});
|
| 697 |
+
|
| 698 |
+
if(state.charts.proj) state.charts.proj.destroy();
|
| 699 |
+
const ctx = el('projChart');
|
| 700 |
+
state.charts.proj = new Chart(ctx,{
|
| 701 |
+
type:'line',
|
| 702 |
+
data:{ labels, datasets: ds },
|
| 703 |
+
options:{
|
| 704 |
+
responsive:true, maintainAspectRatio:false, interaction:{ mode:'index', intersect:false },
|
| 705 |
+
plugins:{
|
| 706 |
+
legend:{ labels:{ color:'#cbd5e1' } },
|
| 707 |
+
tooltip:{ mode:'index', intersect:false },
|
| 708 |
+
cmCross:{ color:'#38bdf8' },
|
| 709 |
+
cmH:{ lines:[ { value:1.0, color:'#ef4444', label:'Target 1.00 (ref)' },
|
| 710 |
+
{ value:target, color:'#facc15', label:`Target ${target.toFixed(2)}` } ] }
|
| 711 |
+
},
|
| 712 |
+
scales:{
|
| 713 |
+
x:{ ticks:{ color:'#94a3b8', autoSkip:true, maxRotation:0 }, grid:{ color:'rgba(148,163,184,.12)' } },
|
| 714 |
+
y:{ ticks:{ color:'#94a3b8' }, grid:{ color:'rgba(148,163,184,.12)' } }
|
| 715 |
+
}
|
| 716 |
+
}
|
| 717 |
+
});
|
| 718 |
+
|
| 719 |
+
const lastCol = paths.map(p=>p[p.length-1]);
|
| 720 |
+
const prob = lastCol.filter(v=>v>=target).length / lastCol.length;
|
| 721 |
+
const q10 = quantile(lastCol,0.10), q50 = quantile(lastCol,0.50), q90 = quantile(lastCol,0.90);
|
| 722 |
+
|
| 723 |
+
el('projStats').innerHTML =
|
| 724 |
+
`Horizon: <b>${horizon} days</b> · Lookback: <b>${lookback}</b> · Drift: <b>${mu.toFixed(3)}</b> · Vol: <b>${sd.toFixed(3)}</b><br>
|
| 725 |
+
P(CPI ≥ ${target.toFixed(2)}) ≈ <b>${(prob*100).toFixed(0)}%</b> · Last-day CPI ~ P10 <b>${q10.toFixed(2)}</b> | Median <b>${q50.toFixed(2)}</b> | P90 <b>${q90.toFixed(2)}</b>`;
|
| 726 |
+
}
|
| 727 |
+
['projTarget','projHorizon','projLookback'].forEach(id=>{
|
| 728 |
+
el(id).addEventListener('input', renderProjection);
|
| 729 |
+
el(id).addEventListener('change', renderProjection);
|
| 730 |
+
});
|
| 731 |
+
|
| 732 |
+
/* ===== Heatmap ===== */
|
| 733 |
+
function cpiClass(v){
|
| 734 |
+
if(v==null || !isFinite(v)) return 'hm-empty';
|
| 735 |
+
if(v < 0.80) return 'hm-red';
|
| 736 |
+
if(v < 1.00) return 'hm-amber';
|
| 737 |
+
return 'hm-green';
|
| 738 |
+
}
|
| 739 |
+
function renderHeatmap(){
|
| 740 |
+
const table = document.getElementById('heatTable');
|
| 741 |
+
if(!table) return;
|
| 742 |
+
const rows = filteredRows().map(calcDaily);
|
| 743 |
+
if(rows.length===0){
|
| 744 |
+
table.innerHTML = '<thead><tr><th>Contractor</th></tr></thead><tbody><tr><td class="sub" style="padding:14px">No data.</td></tr></tbody>';
|
| 745 |
+
return;
|
| 746 |
+
}
|
| 747 |
+
const contractors = [...new Set(rows.map(r=>r.Contractor))].sort();
|
| 748 |
+
const dates = [...new Set(rows.map(r=>r.Date))].sort();
|
| 749 |
+
|
| 750 |
+
const key = (c,d)=>`${c}__${d}`;
|
| 751 |
+
const map = new Map();
|
| 752 |
+
for(const r of rows){ map.set(key(r.Contractor,r.Date), r.CPI); }
|
| 753 |
+
|
| 754 |
+
let thead = '<thead><tr><th>Contractor</th>';
|
| 755 |
+
for(const d of dates){ thead += `<th>${d}</th>`; }
|
| 756 |
+
thead += '</tr></thead>';
|
| 757 |
+
|
| 758 |
+
let tbody = '<tbody>';
|
| 759 |
+
for(const c of contractors){
|
| 760 |
+
tbody += `<tr><th>${c}</th>`;
|
| 761 |
+
for(const d of dates){
|
| 762 |
+
const v = map.get(key(c,d));
|
| 763 |
+
const cls = cpiClass(v);
|
| 764 |
+
const txt = (v==null || !isFinite(v)) ? '—' : v.toFixed(2);
|
| 765 |
+
const title = (v==null || !isFinite(v)) ? `${c} • ${d}: no data` : `${c} • ${d}: CPI ${v.toFixed(3)}`;
|
| 766 |
+
tbody += `<td class="hm-cell ${cls}" title="${title}">${txt}</td>`;
|
| 767 |
+
}
|
| 768 |
+
tbody += '</tr>';
|
| 769 |
+
}
|
| 770 |
+
tbody += '</tbody>';
|
| 771 |
+
|
| 772 |
+
table.innerHTML = thead + tbody;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/* ===== Metrics CSV for export ===== */
|
| 776 |
+
function buildMetricsCSV(){
|
| 777 |
+
const byC=groupByContractor(filteredRows().map(calcDaily));
|
| 778 |
+
const rows = [['Contractor','7DayAvgCPI','5DayAvgEarned','5DayAvgBurn','CumCPI']];
|
| 779 |
+
for(const [name,list] of byC){
|
| 780 |
+
const c7=sevenDayAvgCPI(list);
|
| 781 |
+
const e5=avgLastNDates(list,5,'EarnedHrs');
|
| 782 |
+
const b5=avgLastNDates(list,5,'BurnHrs');
|
| 783 |
+
const cum=cumulativeCPI(list);
|
| 784 |
+
rows.push([
|
| 785 |
+
name,
|
| 786 |
+
c7==null?'':c7.toFixed(3),
|
| 787 |
+
e5==null?'':Math.round(e5),
|
| 788 |
+
b5==null?'':Math.round(b5),
|
| 789 |
+
cum==null?'':cum.toFixed(3)
|
| 790 |
+
]);
|
| 791 |
+
}
|
| 792 |
+
return rows.map(r=>r.map(v=>String(v).replace(/"/g,'""')).map(v=>`"${v}"`).join(',')).join('\r\n');
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
/* ===== Summary + Render ===== */
|
| 796 |
+
function renderSummary(){
|
| 797 |
+
const rows=filteredRows();
|
| 798 |
+
const dates=[...new Set(rows.map(r=>r.Date))].sort();
|
| 799 |
+
const cum=cumulativeCPI(rows);
|
| 800 |
+
el('summary').innerHTML = `
|
| 801 |
+
<div>Rows: <b>${rows.length}</b></div>
|
| 802 |
+
<div>Contractors: <b>${new Set(rows.map(r=>r.Contractor)).size}</b></div>
|
| 803 |
+
<div>Dates: <b>${dates.length}</b> <span class="sub">(${dates[0]||'-'} → ${dates[dates.length-1]||'-'})</span></div>
|
| 804 |
+
<div>Cumulative CPI: <b>${cum==null?'-':cum.toFixed(2)}</b></div>`;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
function renderAll(){
|
| 808 |
+
renderLeague();
|
| 809 |
+
renderRollingChart();
|
| 810 |
+
renderProjection();
|
| 811 |
+
renderCumChart();
|
| 812 |
+
renderSummary();
|
| 813 |
+
renderHeatmap();
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
buildContractorList(); renderAll();
|
| 817 |
+
</script>
|
| 818 |
+
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-qwensite.hf.space/logo.svg" alt="qwensite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-qwensite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >QwenSite</a> - 🧬 <a href="https://enzostvs-qwensite.hf.space?remix=Kaliman-1981/prdtrend1-0" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body>
|
| 819 |
</html>
|
prompts.txt
ADDED
|
File without changes
|