Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>Curve Master Productivity Monitor</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root{ | |
| --bg:#0f172a; --panel:#101827; --panel2:#0b1222; --text:#e5e7eb; --muted:#94a3b8; | |
| --teal:#06b6d4; --sky:#38bdf8; --blue:#2563eb; --green:#22c55e; --amber:#facc15; --red:#ef4444; --border:#20304d; | |
| --radius:14px; --gap:16px; | |
| } | |
| *{box-sizing:border-box} | |
| body{margin:0;font-family:Arial,Helvetica,sans-serif;background:var(--bg);color:var(--text)} | |
| /* ===== Brand header ===== */ | |
| header{ | |
| display:flex;align-items:center;justify-content:space-between;gap:12px; | |
| background:#0c1526; padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.08) | |
| } | |
| .brand{display:flex;align-items:center;gap:12px} | |
| .brand-icon{ | |
| width:36px;height:36px;border-radius:10px;background:rgba(255,255,255,.06); | |
| display:grid;place-items:center;border:1px solid rgba(255,255,255,.18); | |
| box-shadow:inset 0 0 12px rgba(255,255,255,.06); | |
| } | |
| .brand-icon svg{width:22px;height:22px} | |
| .brand-text{line-height:1} | |
| .brand-title{ | |
| font-family:Georgia,"Times New Roman",serif; | |
| font-size:28px; letter-spacing:.2px; color:#dbeafe; | |
| text-shadow:0 1px 0 rgba(0,0,0,.5); | |
| } | |
| .brand-sub{margin-top:2px; font-size:12px; color:#b9c8ff; opacity:.95} | |
| .hdr-actions{display:flex;gap:8px} | |
| .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} | |
| .btn:hover{filter:brightness(1.06)} | |
| .btn-danger{background:#dc2626;border-color:#b91c1c} | |
| main{max-width:1200px;margin:20px auto;padding:0 var(--gap)} | |
| .grid{display:grid;grid-template-columns:repeat(12,1fr);gap:var(--gap)} | |
| .card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px} | |
| .span-4{grid-column:span 4}.span-8{grid-column:span 8}.span-12{grid-column:span 12} | |
| h2{margin:0 0 12px 0;font-size:18px;color:#e6f9ff} | |
| .sub{font-size:12px;color:var(--muted);margin-bottom:10px} | |
| .uploader{border:2px dashed var(--sky);padding:16px;text-align:center;border-radius:var(--radius);background:var(--panel2)} | |
| input[type="file"]{display:block;margin:8px auto;color:var(--muted)} | |
| label{font-size:12px;color:var(--muted)} | |
| .controls-row{display:flex;gap:12px;flex-wrap:wrap;align-items:center} | |
| input[type="text"], input[type="number"]{ | |
| background:#0b162c;color:var(--text);border:1px solid var(--border); | |
| padding:8px 10px;border-radius:10px;outline:none;height:36px | |
| } | |
| .num{width:110px} | |
| .tog{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--muted);height:36px} | |
| .pill{padding:6px 12px;border-radius:999px;background:#0b162c;border:1px solid var(--border);color:#cbd5e1;font-size:12px;cursor:pointer} | |
| .pill input[type="checkbox"]{margin-right:6px; transform:translateY(1px)} | |
| /* Contractor picker */ | |
| #contractorBox{width:100%;height:260px;border:1px solid var(--border);border-radius:10px;background:#0b162c;overflow:auto;padding:6px} | |
| .c-item{display:flex;align-items:center;gap:8px;padding:6px 6px;border-radius:8px} | |
| .c-item:hover{background:#0c1426} | |
| /* Table */ | |
| table{width:100%;border-collapse:collapse;font-size:13px} | |
| th,td{padding:10px 12px;border-bottom:1px solid var(--border);text-align:left} | |
| th{color:var(--sky);background:#0c1426;position:sticky;top:0} | |
| tr:hover{background:#0c1426} | |
| .flag{font-weight:700}.flag.red{color:var(--red)}.flag.amber{color:var(--amber)}.flag.green{color:var(--green)} | |
| /* Charts */ | |
| .chart{height:380px} canvas{width:100%;height:100%} | |
| /* Slider & Presets */ | |
| .slider-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:10px 0} | |
| .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} | |
| .chip.good{color:var(--green)}.chip.watch{color:var(--amber)}.chip.low{color:var(--red)} | |
| .preset{cursor:pointer;border:1px solid var(--border);background:#0b162c;padding:6px 10px;border-radius:999px;color:#cbd5e1;font-size:12px} | |
| .preset.active{background:linear-gradient(90deg,var(--teal),var(--blue));color:#fff;border:none} | |
| .foot{font-size:12px;color:var(--muted)} | |
| @media (max-width:900px){.span-4,.span-8,.span-12{grid-column:span 12}} | |
| /* Heatmap */ | |
| .heat-wrap{overflow:auto;border:1px solid var(--border);border-radius:12px} | |
| .heat-table{border-collapse:separate;border-spacing:6px;width:max-content;min-width:100%;background:#0b1325} | |
| .heat-table thead th{ | |
| position:sticky; top:0; z-index:2; | |
| background:#0c1426; color:#cde9ff; font-weight:700; padding:10px 8px; border-radius:8px; | |
| } | |
| .heat-table tbody th{ | |
| position:sticky; left:0; z-index:1; | |
| background:#0c1426; color:#e5eefc; font-weight:600; padding:10px 8px; border-radius:8px; white-space:nowrap; | |
| } | |
| .hm-cell{ | |
| min-width:74px; text-align:center; font-weight:700; color:white; padding:10px 6px; border-radius:10px; | |
| box-shadow:inset 0 0 0 1px rgba(0,0,0,.25); | |
| } | |
| .hm-red{background:rgba(239,68,68,.9)} | |
| .hm-amber{background:rgba(250,204,21,.92); color:#1b1b1b} | |
| .hm-green{background:rgba(34,197,94,.9)} | |
| .hm-empty{background:#0c1426; color:#64748b; font-weight:600} | |
| .heat-legend{display:flex;gap:12px;align-items:center;margin-top:8px;color:var(--muted);font-size:12px} | |
| .leg-chip{width:20px;height:14px;border-radius:4px;display:inline-block} | |
| /* Sparklines */ | |
| .spark{display:block;width:120px;height:26px} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="brand"> | |
| <div class="brand-icon" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M4 17 L10 11 L14 15 L21 8" stroke="#dbeafe" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/> | |
| <circle cx="21" cy="8" r="1.6" fill="#dbeafe"/> | |
| </svg> | |
| </div> | |
| <div class="brand-text"> | |
| <div class="brand-title">Curve Master Productivity Monitor</div> | |
| <div class="brand-sub">Advanced Productivity Monitor</div> | |
| </div> | |
| </div> | |
| <div class="hdr-actions"> | |
| <button id="exportPDF" class="btn">Export PDF + PNG + CSV</button> | |
| <button id="clearData" class="btn btn-danger">Reset</button> | |
| </div> | |
| </header> | |
| <main> | |
| <!-- Upload + Summary (left), Filters (right) --> | |
| <section class="grid"> | |
| <div class="card span-4" style="display:flex;flex-direction:column;gap:12px"> | |
| <h2>Upload Daily CSV</h2> | |
| <div class="uploader"> | |
| <input id="fileInput" type="file" accept=".csv" /> | |
| <div class="sub">Headers (aliases OK): Date, Contractor, EarnedHrs, ActualHrs</div> | |
| </div> | |
| <div> | |
| <h2 style="margin-bottom:6px">Import Summary</h2> | |
| <div id="summary" class="sub">No data yet.</div> | |
| <div id="importLog" class="sub"></div> | |
| </div> | |
| </div> | |
| <div class="card span-8"> | |
| <h2>Filters</h2> | |
| <div style="display:grid;grid-template-columns:1fr;gap:10px"> | |
| <div> | |
| <label>Contractor</label> | |
| <input id="contractorSearch" type="text" placeholder="Search contractors…" /> | |
| <div id="contractorBox"></div> | |
| <div class="controls-row" style="margin-top:6px"> | |
| <button id="selectAll" class="pill">Select All</button> | |
| <button id="clearPick" class="pill">Clear</button> | |
| <div class="tog"><input type="checkbox" id="excludeSundays" checked><label for="excludeSundays">Exclude Sundays</label></div> | |
| <div class="tog"><input type="checkbox" id="excludeToday" checked><label for="excludeToday">Exclude Today from rolling</label></div> | |
| <button id="clearFilters" class="pill">Reset Filters</button> | |
| </div> | |
| <!-- Flags filter (only trims league table) --> | |
| <div class="controls-row" style="margin-top:6px"> | |
| <span class="sub" style="min-width:60px">Flags:</span> | |
| <label class="pill"><input class="flagChk" type="checkbox" value="low"> Low</label> | |
| <label class="pill"><input class="flagChk" type="checkbox" value="watch"> Watch</label> | |
| <label class="pill"><input class="flagChk" type="checkbox" value="good"> Good</label> | |
| <label class="pill"><input class="flagChk" type="checkbox" value="nodata"> No data</label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- League --> | |
| <section class="grid" style="margin-top:16px"> | |
| <div class="card span-12" id="leagueCard"> | |
| <h2>Contractor Performance</h2> | |
| <div class="sub">Sorted by worst 7-Day Avg CPI. Filters apply.</div> | |
| <div id="leagueScroll" style="max-height:360px;overflow:auto"> | |
| <table id="leagueTable"> | |
| <thead> | |
| <tr> | |
| <th>Contractor</th> | |
| <th>7-Day Avg CPI</th> | |
| <th>5-Day Avg Earned</th> | |
| <th>5-Day Avg Burn</th> | |
| <th>Cum CPI</th> | |
| <th>Trend (7–10d)</th> | |
| <th>Flags</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Rolling CPI --> | |
| <section class="grid" style="margin-top:16px"> | |
| <div class="card span-12" id="rollingCard"> | |
| <h2>Rolling CPI (Avg of Selected Contractors)</h2> | |
| <div class="sub"><b>Target = 1.00</b>; green line shows the visible average.</div> | |
| <div class="slider-row"> | |
| <span>Presets:</span> | |
| <button class="preset" data-preset="3">3d</button> | |
| <button class="preset" data-preset="5">5d</button> | |
| <button class="preset" data-preset="7">7d</button> | |
| <button class="preset" data-preset="10">10d</button> | |
| <button class="preset" data-preset="all">All</button> | |
| <span style="margin-left:16px">Look-back:</span> | |
| <input id="lookback" type="range" min="3" max="30" value="7"/> | |
| <span id="lookbackLabel" class="sub">7 days</span> | |
| </div> | |
| <div class="chart"><canvas id="rollingChart"></canvas></div> | |
| <div id="perfChips" style="margin-top:8px"></div> | |
| </div> | |
| </section> | |
| <!-- Projection --> | |
| <section class="grid" style="margin-top:16px"> | |
| <div class="card span-12" id="projCard"> | |
| <h2>CPI Projection (Monte Carlo Fan)</h2> | |
| <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> | |
| <div class="controls-row" style="margin-bottom:6px"> | |
| <label>Target CPI</label> | |
| <input id="projTarget" class="num" type="number" step="0.01" value="1.00"/> | |
| <label>Horizon (days)</label> | |
| <input id="projHorizon" class="num" type="number" min="15" max="120" step="5" value="90"/> | |
| <label>Model lookback (days)</label> | |
| <input id="projLookback" class="num" type="number" min="10" max="60" step="5" value="30"/> | |
| <span class="sub">Uses most recent CPI dates; Sundays honored by your filter.</span> | |
| </div> | |
| <div class="chart"><canvas id="projChart"></canvas></div> | |
| <div id="projStats" class="sub" style="margin-top:6px"></div> | |
| </div> | |
| </section> | |
| <!-- Cumulative --> | |
| <section class="grid" style="margin-top:16px"> | |
| <div class="card span-12" id="cumCard"> | |
| <h2>Cumulative Earned vs Actual</h2> | |
| <div class="chart"><canvas id="cumChart"></canvas></div> | |
| <div class="foot">Cumulative across filtered contractors & dates.</div> | |
| </div> | |
| </section> | |
| <!-- Daily CPI Heatmap --> | |
| <section class="grid" style="margin-top:16px"> | |
| <div class="card span-12" id="heatCard"> | |
| <h2>Daily CPI Heatmap</h2> | |
| <div class="heat-wrap" id="heatWrap"> | |
| <table class="heat-table" id="heatTable"></table> | |
| </div> | |
| <div class="heat-legend"> | |
| <span class="leg-chip" style="background:rgba(239,68,68,.9)"></span> < 0.80 | |
| <span class="leg-chip" style="background:rgba(250,204,21,.92)"></span> 0.80–0.99 | |
| <span class="leg-chip" style="background:rgba(34,197,94,.9)"></span> ≥ 1.00 | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Libs --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.10/dayjs.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <script> | |
| /* ===== STATE ===== */ | |
| const state = { | |
| rows: [], | |
| filters: { contractors:[], excludeSundays:true, excludeTodayInRolling:true, flags: [] }, | |
| charts: { cum:null, rolling:null, proj:null }, | |
| lookbackDays: 'all' // GLOBAL WINDOW: 'all' or N (3/5/7/10/slider) | |
| }; | |
| const HEADER_ALIASES = { | |
| Date:['date','workdate','reportdate'], | |
| Contractor:['contractor','company','vendor'], | |
| EarnedHrs:['earnedhrs','earned','evhrs','scheduleearn'], | |
| ActualHrs:['actualhrs','actual','paidhours','paidhrs','timesheethrs','timesheet'] | |
| }; | |
| /* ===== HELPERS ===== */ | |
| const el = id => document.getElementById(id); | |
| const toISO = v => { const d = dayjs(v); return d.isValid()? d.format('YYYY-MM-DD'):null; }; | |
| const isSunday = iso => dayjs(iso).day()===0; | |
| const normalizeHeader=(h)=>{ if(!h) return null; const k=h.toString().trim().toLowerCase(); | |
| for(const canon in HEADER_ALIASES){ const set=[canon.toLowerCase(),...HEADER_ALIASES[canon]]; if(set.includes(k)) return canon; } return null; }; | |
| // BLANKS -> null; zeros kept as 0 | |
| const num = x => { | |
| if (x === undefined || x === null || String(x).trim() === '') return null; | |
| const n = Number(String(x).replace(/,/g,'').trim()); | |
| return Number.isFinite(n) ? n : null; | |
| }; | |
| /* ===== IMPORT ===== */ | |
| el('fileInput').addEventListener('change', (e)=>{ | |
| const f=e.target.files?.[0]; if(!f) return; | |
| Papa.parse(f,{header:true,skipEmptyLines:true,complete:(res)=>{ | |
| const headerMap={}; for(const h of res.meta.fields){ const canon=normalizeHeader(h); if(canon) headerMap[canon]=h; } | |
| for(const must of ['Date','Contractor','EarnedHrs','ActualHrs']) if(!headerMap[must]){ el('importLog').innerHTML='<span style="color:#ef4444">Missing required headers.</span>'; return; } | |
| const rows=[]; | |
| for(const r of res.data){ | |
| const d=toISO(r[headerMap.Date]); const c=(r[headerMap.Contractor]||'').trim(); | |
| const e=num(r[headerMap.EarnedHrs]); const a=num(r[headerMap.ActualHrs]); | |
| if(!d || !c) continue; rows.push({Date:d, Contractor:c, EarnedHrs:e, ActualHrs:a}); | |
| } | |
| state.rows.push(...rows); | |
| el('importLog').textContent = `Imported ${rows.length} rows.`; | |
| buildContractorList(); renderAll(); | |
| }}); | |
| }); | |
| /* ===== RESET / TOGGLES ===== */ | |
| document.getElementById('clearData').addEventListener('click', ()=>{ | |
| state.rows=[]; state.filters={ contractors:[], excludeSundays:true, excludeTodayInRolling:true, flags:[] }; | |
| state.lookbackDays = 'all'; | |
| el('contractorSearch').value=''; | |
| el('excludeSundays').checked = true; | |
| el('excludeToday').checked = true; | |
| document.querySelectorAll('.flagChk').forEach(cb=>cb.checked=false); | |
| el('lookback').value = Math.max(3, 7); | |
| el('lookbackLabel').textContent = 'All data'; | |
| setPresetActive('all'); | |
| el('importLog').textContent='Cleared all data.'; | |
| buildContractorList(); destroyCharts(); renderAll(); | |
| }); | |
| document.getElementById('clearFilters').addEventListener('click', ()=>{ | |
| state.filters.contractors = []; | |
| state.filters.excludeSundays = true; | |
| state.filters.excludeTodayInRolling = true; | |
| state.filters.flags = []; | |
| state.lookbackDays = 'all'; | |
| el('excludeSundays').checked = true; | |
| el('excludeToday').checked = true; | |
| document.querySelectorAll('.flagChk').forEach(cb=>cb.checked=false); | |
| setPresetActive('all'); el('lookbackLabel').textContent='All data'; | |
| buildContractorList(); renderAll(); | |
| }); | |
| el('excludeSundays').addEventListener('change', e=>{ state.filters.excludeSundays = e.target.checked; renderAll(); }); | |
| el('excludeToday').addEventListener('change', e=>{ state.filters.excludeTodayInRolling = e.target.checked; renderAll(); }); | |
| function readFlagSelections(){ | |
| state.filters.flags = [...document.querySelectorAll('.flagChk:checked')].map(x=>x.value); | |
| renderLeague(); // league only | |
| } | |
| document.querySelectorAll('.flagChk').forEach(cb=>cb.addEventListener('change', readFlagSelections)); | |
| /* ===== GLOBAL DATE WINDOW ===== */ | |
| // Base rows ignoring the global window (used to size slider/presets correctly) | |
| function baseRowsNoWindow(){ | |
| let rows=[...state.rows]; | |
| if(state.filters.contractors.length){ const set=new Set(state.filters.contractors); rows=rows.filter(r=>set.has(r.Contractor)); } | |
| if(state.filters.excludeSundays) rows=rows.filter(r=>!isSunday(r.Date)); | |
| return rows.sort((a,b)=>a.Date.localeCompare(b.Date)||a.Contractor.localeCompare(b.Contractor)); | |
| } | |
| function availableDates(){ return [...new Set(baseRowsNoWindow().map(r=>r.Date))].sort(); } | |
| function filteredRows(){ | |
| // apply contractor/Sundays first | |
| let rows = baseRowsNoWindow(); | |
| // apply global window if set | |
| if(state.lookbackDays !== 'all'){ | |
| const dates = [...new Set(rows.map(r=>r.Date))].sort(); | |
| const n = Math.max(1, +state.lookbackDays); | |
| const lastDates = dates.slice(-n); | |
| const dateSet = new Set(lastDates); | |
| rows = rows.filter(r => dateSet.has(r.Date)); | |
| } | |
| return rows; | |
| } | |
| // CPI calc: Actual null/0 => CPI null; Earned null => 0 | |
| const calcDaily = r => { | |
| const E = r.EarnedHrs; | |
| const A = r.ActualHrs; | |
| const CPI = (A == null || A === 0) ? null : ((E ?? 0) / A); | |
| return { ...r, CPI, BurnHrs: A }; | |
| }; | |
| function groupByContractor(rows){ | |
| const m=new Map(); | |
| for(const r of rows){ if(!m.has(r.Contractor)) m.set(r.Contractor,[]); m.get(r.Contractor).push(r); } | |
| for(const v of m.values()) v.sort((a,b)=>a.Date.localeCompare(b.Date)); | |
| return m; | |
| } | |
| // Daily aggregates per date (zeros kept; missing => null) | |
| function daysFor(rows){ | |
| const by = new Map(); | |
| for (const r of rows){ | |
| const d = r.Date; | |
| if (!by.has(d)) by.set(d, { Date:d, EarnedHrs:0, BurnHrs:0, hasE:false, hasB:false }); | |
| const agg = by.get(d); | |
| if (r.EarnedHrs != null) { agg.EarnedHrs += r.EarnedHrs; agg.hasE = true; } | |
| if (r.BurnHrs != null) { agg.BurnHrs += r.BurnHrs; agg.hasB = true; } | |
| } | |
| return [...by.values()] | |
| .sort((a,b)=>a.Date.localeCompare(b.Date)) | |
| .map(d=>({ | |
| Date:d.Date, | |
| EarnedHrs: d.hasE ? d.EarnedHrs : null, | |
| BurnHrs: d.hasB ? d.BurnHrs : null | |
| })); | |
| } | |
| function avgLastNDates(rows, n, key){ | |
| const days = daysFor(rows); | |
| const last = days.slice(-n).map(d=>d[key]).filter(v=>v!=null); // keep zeros, drop nulls | |
| if (!last.length) return null; | |
| return last.reduce((a,b)=>a+b,0) / last.length; | |
| } | |
| function rollingAvg(series,days,{excludeToday=false}={}){ | |
| 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'); | |
| 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;}); | |
| if(!w.length) return null; return w.reduce((a,p)=>a+Number(p.value),0)/w.length; | |
| } | |
| const sevenDayAvgCPI = rows => rollingAvg(rows.map(r=>({Date:r.Date,value:r.CPI})),7); | |
| function cumulativeByDate(rows){ | |
| const map={}; | |
| for(const r of rows){ | |
| map[r.Date]??={E:0,A:0}; | |
| map[r.Date].E += (r.EarnedHrs ?? 0); | |
| map[r.Date].A += (r.ActualHrs ?? 0); | |
| } | |
| let e=0,a=0; | |
| return Object.keys(map).sort().map(d=>{ e+=map[d].E; a+=map[d].A; return {Date:d,CumEarned:e,CumActual:a}; }); | |
| } | |
| function cumulativeCPI(rows){ | |
| let e=0,a=0; | |
| for(const r of rows){ e += (r.EarnedHrs ?? 0); a += (r.ActualHrs ?? 0); } | |
| return a ? e/a : null; | |
| } | |
| /* ===== Plugins ===== */ | |
| 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(); } }; | |
| 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(); } }; | |
| Chart.register(crosshairPlugin,hlinePlugin); | |
| /* ===== League (with sparklines & flags) ===== */ | |
| function lastNDailyCPI(rows, n=10){ | |
| const arr = rows.map(r=>r.CPI).filter(v=>v!=null && isFinite(v)); | |
| return arr.slice(-n); | |
| } | |
| function renderLeague(){ | |
| const tbody=document.querySelector('#leagueTable tbody'); tbody.innerHTML=''; | |
| const byC=groupByContractor(filteredRows().map(calcDaily)); | |
| const out=[]; | |
| for(const [name,rows] of byC){ | |
| const c7=sevenDayAvgCPI(rows); | |
| const e5=avgLastNDates(rows,5,'EarnedHrs'); | |
| const b5=avgLastNDates(rows,5,'BurnHrs'); | |
| const cum=cumulativeCPI(rows); | |
| const trend = lastNDailyCPI(rows, 10); | |
| let slug, labelHtml; | |
| if (c7 == null){ | |
| slug = 'nodata'; labelHtml = '<span class="flag amber">No data</span>'; | |
| } else if (c7 < 0.80){ | |
| slug = 'low'; labelHtml = '<span class="flag red">🔴 Low</span>'; | |
| } else if (c7 < 1.00){ | |
| slug = 'watch'; labelHtml = '<span class="flag amber">🟡 Watch</span>'; | |
| } else { | |
| slug = 'good'; labelHtml = '<span class="flag green">🟢 Good</span>'; | |
| } | |
| if (state.filters.flags.length && !state.filters.flags.includes(slug)) continue; | |
| out.push({ | |
| name, | |
| c7: c7==null?'-':c7.toFixed(2), | |
| e5: e5==null?'-':Math.round(e5).toLocaleString(), | |
| b5: b5==null?'-':Math.round(b5).toLocaleString(), | |
| cum: cum==null?'-':cum.toFixed(2), | |
| trend, | |
| flag: labelHtml | |
| }); | |
| } | |
| out.sort((a,b)=>(a.c7==='-'?999:+a.c7)-(b.c7==='-'?999:+b.c7)); | |
| for(const r of out){ | |
| const tr=document.createElement('tr'); | |
| const sparkId = 'spk_'+Math.random().toString(36).slice(2); | |
| tr.innerHTML= | |
| `<td>${r.name}</td> | |
| <td>${r.c7}</td> | |
| <td>${r.e5}</td> | |
| <td>${r.b5}</td> | |
| <td>${r.cum}</td> | |
| <td><canvas class="spark" id="${sparkId}" width="120" height="26" data-points="${encodeURIComponent(JSON.stringify(r.trend))}"></canvas></td> | |
| <td>${r.flag}</td>`; | |
| tbody.appendChild(tr); | |
| } | |
| renderSparklines(); | |
| } | |
| function renderSparklines(){ | |
| document.querySelectorAll('canvas.spark').forEach(cv=>{ | |
| const pts = JSON.parse(decodeURIComponent(cv.getAttribute('data-points')||'[]')); | |
| const ctx = cv.getContext('2d'); const w=cv.width, h=cv.height; ctx.clearRect(0,0,w,h); | |
| if(!pts.length){ ctx.fillStyle='#64748b'; ctx.font='12px Arial'; ctx.fillText('—', w/2-3, h/2+4); return; } | |
| let min = Math.min(...pts, 1.0), max = Math.max(...pts, 1.0); | |
| if(min===max){ min-=0.1; max+=0.1; } | |
| const padY=3, padX=2; | |
| const x = i => padX + (w-2*padX) * (i/(pts.length-1||1)); | |
| const y = v => padY + (h-2*padY) * (1 - (v-min)/(max-min)); | |
| ctx.strokeStyle='rgba(239,68,68,.8)'; ctx.setLineDash([4,3]); ctx.beginPath(); | |
| const yT = y(1.0); ctx.moveTo(padX, yT); ctx.lineTo(w-padX, yT); ctx.stroke(); ctx.setLineDash([]); | |
| ctx.strokeStyle='#38bdf8'; ctx.lineWidth=1.5; ctx.beginPath(); | |
| pts.forEach((v,i)=>{ const xi=x(i), yi=y(v); i?ctx.lineTo(xi,yi):ctx.moveTo(xi,yi); }); | |
| ctx.stroke(); | |
| const lastY = y(pts[pts.length-1]), lastX = x(pts.length-1); | |
| ctx.fillStyle='#22c55e'; ctx.beginPath(); ctx.arc(lastX,lastY,2.2,0,Math.PI*2); ctx.fill(); | |
| }); | |
| } | |
| /* ===== Cumulative ===== */ | |
| function renderCumChart(){ | |
| const ctx=el('cumChart'); const series=cumulativeByDate(filteredRows()); | |
| const labels=series.map(p=>p.Date), e=series.map(p=>p.CumEarned), a=series.map(p=>p.CumActual); | |
| if(state.charts.cum) state.charts.cum.destroy(); | |
| 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}]}, | |
| options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false}, | |
| plugins:{legend:{labels:{color:'#cbd5e1'}},tooltip:{mode:'index',intersect:false}}, | |
| scales:{x:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.15)'}},y:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.15)'}}}}}); | |
| } | |
| /* ===== Rolling CPI (global window honored) ===== */ | |
| function avgDailyCPI(){ | |
| const byC=groupByContractor(filteredRows().map(calcDaily)); const byDate={}; | |
| for(const arr of byC.values()){ for(const r of arr){ if(r.CPI==null) continue; (byDate[r.Date]??=[]).push(r.CPI); } } | |
| return Object.keys(byDate).sort().map(d=>({Date:d,value:byDate[d].reduce((a,x)=>a+x,0)/byDate[d].length})); | |
| } | |
| 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; } | |
| 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'; } | |
| function renderRollingChart(){ | |
| const ctx=el('rollingChart'); let series=avgDailyCPI(); | |
| // Honor "Exclude Today" toggle for rolling | |
| if (state.filters.excludeTodayInRolling && series.length){ | |
| const todayISO = dayjs().format('YYYY-MM-DD'); | |
| if (series[series.length-1].Date === todayISO) series = series.slice(0,-1); | |
| } | |
| const dates=series.map(s=>s.Date); const vals=series.map(s=>+s.value.toFixed(3)); | |
| const nonNull=vals.filter(v=>v!=null); const visAvg=nonNull.length? nonNull.reduce((a,b)=>a+b,0)/nonNull.length : null; | |
| const vMin = Math.min(...nonNull, 1.00, ...(visAvg?[visAvg]:[])); | |
| const vMax = Math.max(...nonNull, 1.00, ...(visAvg?[visAvg]:[])); | |
| const pad = Math.max(0.05, (vMax - vMin) * 0.15); | |
| if(state.charts.rolling) state.charts.rolling.destroy(); | |
| state.charts.rolling=new Chart(ctx,{ | |
| type:'line', | |
| data:{labels:dates,datasets:[{label:'Rolling CPI (daily avg of selected)',data:vals,tension:.25,borderWidth:2}]}, | |
| options:{ | |
| responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false}, | |
| plugins:{ | |
| legend:{labels:{color:'#cbd5e1'}}, | |
| tooltip:{mode:'index',intersect:false}, | |
| cmCross:{ color:'#38bdf8' }, | |
| cmH:{ lines:[ | |
| { value:1.0, color:'#ef4444', label:'Target 1.00' }, | |
| ...(visAvg!=null ? [{ value:visAvg, color:'#22c55e', label:`Avg ${visAvg.toFixed(2)}` }] : []) | |
| ]} | |
| }, | |
| scales:{ | |
| x:{ticks:{color:'#94a3b8',autoSkip:true,maxRotation:0},grid:{color:'rgba(148,163,184,.12)'}}, | |
| y:{ticks:{color:'#94a3b8'},grid:{color:'rgba(148,163,184,.12)'}, | |
| suggestedMin: vMin - pad, suggestedMax: vMax + pad } | |
| } | |
| } | |
| }); | |
| 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(''); | |
| el('perfChips').innerHTML=perf; | |
| // Slider sizing based on available dates ignoring current window | |
| const max = Math.max(3, availableDates().length); | |
| el('lookback').max = max; | |
| if(state.lookbackDays==='all'){ | |
| el('lookback').value=Math.min(30,max); | |
| el('lookbackLabel').textContent='All data'; | |
| setPresetActive('all'); | |
| }else{ | |
| el('lookback').value=state.lookbackDays; | |
| el('lookbackLabel').textContent=`${state.lookbackDays} days`; | |
| setPresetActive(state.lookbackDays); | |
| } | |
| } | |
| /* ===== Projection (uses global window for the past portion display) ===== */ | |
| function forwardDates(startISO, days, skipSundays=true){ | |
| const out=[]; let d=dayjs(startISO); | |
| while(out.length<days){ | |
| d=d.add(1,'day'); | |
| if(skipSundays && d.day()===0) continue; | |
| out.push(d.format('YYYY-MM-DD')); | |
| } | |
| return out; | |
| } | |
| function simulatePaths(startVal, mu, sigma, steps, sims){ | |
| const paths=new Array(sims); | |
| for(let s=0;s<sims;s++){ | |
| const p=new Array(steps); | |
| let v=startVal; | |
| for(let i=0;i<steps;i++){ | |
| const u1=Math.random()||1e-9, u2=Math.random()||1e-9; | |
| const z=Math.sqrt(-2*Math.log(u1))*Math.cos(2*Math.PI*u2); | |
| v = v + mu + sigma*z; | |
| v = Math.max(0.2, Math.min(2.5, v)); | |
| p[i]=+v.toFixed(3); | |
| } | |
| paths[s]=p; | |
| } | |
| return paths; | |
| } | |
| function quantile(arr,q){ | |
| if(!arr.length) return null; | |
| const sorted=[...arr].sort((a,b)=>a-b); | |
| const idx=(sorted.length-1)*q; | |
| const lo=Math.floor(idx), hi=Math.ceil(idx); | |
| if(lo===hi) return sorted[lo]; | |
| return sorted[lo] + (sorted[hi]-sorted[lo])*(idx-lo); | |
| } | |
| function renderProjection(){ | |
| const target = parseFloat(el('projTarget').value)||1.0; | |
| const horizon = Math.max(15, Math.min(120, parseInt(el('projHorizon').value)||90)); | |
| const lookback = Math.max(10, Math.min(60, parseInt(el('projLookback').value)||30)); | |
| let series = avgDailyCPI(); | |
| if(!series.length){ | |
| el('projStats').innerHTML = 'No CPI history available for projection.'; | |
| if(state.charts.proj){ state.charts.proj.destroy(); state.charts.proj=null; } | |
| return; | |
| } | |
| const last = series[series.length-1]; | |
| const hist = series.slice(-lookback); | |
| const deltas = []; | |
| for(let i=1;i<hist.length;i++){ deltas.push( (hist[i].value - hist[i-1].value) ); } | |
| const mu = deltas.length ? deltas.reduce((a,b)=>a+b,0)/deltas.length : 0; | |
| const sd = deltas.length ? Math.sqrt(deltas.reduce((a,b)=>a+b*b,0)/deltas.length - mu*mu) : 0.05; | |
| const steps = horizon, sims = 400; | |
| const paths = simulatePaths(hist[hist.length-1].value, mu, sd, steps, sims); | |
| const p10=[], p25=[], p50=[], p75=[], p90=[]; | |
| for(let i=0;i<steps;i++){ | |
| const col = paths.map(p=>p[i]); | |
| p10.push(quantile(col,0.10)); | |
| p25.push(quantile(col,0.25)); | |
| p50.push(quantile(col,0.50)); | |
| p75.push(quantile(col,0.75)); | |
| p90.push(quantile(col,0.90)); | |
| } | |
| // Past section uses global window if smaller than 30 days | |
| const allPast = series; | |
| const maxPast = (state.lookbackDays==='all') ? 30 : Math.min(30, +state.lookbackDays); | |
| const past = allPast.slice(-maxPast); | |
| const forward = forwardDates(last.Date, steps, state.filters.excludeSundays); | |
| const labels = past.map(p=>p.Date).concat(forward); | |
| const ds = []; | |
| ds.push({label:'Past CPI', data: past.map(p=>+p.value.toFixed(3)).concat(new Array(steps).fill(null)), | |
| borderColor:'#22c55e', pointRadius:0, borderWidth:2, tension:.25}); | |
| const bandColor1='rgba(56,189,248,0.18)', bandColor2='rgba(56,189,248,0.32)'; | |
| const padNulls = arr => new Array(past.length).fill(null).concat(arr); | |
| ds.push({label:'90th', data: padNulls(p90), pointRadius:0, borderWidth:0}); | |
| ds.push({label:'10-90% band', data: padNulls(p10), pointRadius:0, borderWidth:0, | |
| fill:{ target:'-1', above:bandColor1, below:bandColor1 }}); | |
| ds.push({label:'75th', data: padNulls(p75), pointRadius:0, borderWidth:0}); | |
| ds.push({label:'25-75% band', data: padNulls(p25), pointRadius:0, borderWidth:0, | |
| fill:{ target:'-1', above:bandColor2, below:bandColor2 }}); | |
| ds.push({label:'Median', data: padNulls(p50), borderColor:'#38bdf8', pointRadius:0, borderWidth:2, tension:.25}); | |
| if(state.charts.proj) state.charts.proj.destroy(); | |
| const ctx = el('projChart'); | |
| state.charts.proj = new Chart(ctx,{ | |
| type:'line', | |
| data:{ labels, datasets: ds }, | |
| options:{ | |
| responsive:true, maintainAspectRatio:false, interaction:{ mode:'index', intersect:false }, | |
| plugins:{ | |
| legend:{ labels:{ color:'#cbd5e1' } }, | |
| tooltip:{ mode:'index', intersect:false }, | |
| cmCross:{ color:'#38bdf8' }, | |
| cmH:{ lines:[ { value:1.0, color:'#ef4444', label:'Target 1.00 (ref)' }, | |
| { value:target, color:'#facc15', label:`Target ${target.toFixed(2)}` } ] } | |
| }, | |
| scales:{ | |
| x:{ ticks:{ color:'#94a3b8', autoSkip:true, maxRotation:0 }, grid:{ color:'rgba(148,163,184,.12)' } }, | |
| y:{ ticks:{ color:'#94a3b8' }, grid:{ color:'rgba(148,163,184,.12)' } } | |
| } | |
| } | |
| }); | |
| const lastCol = paths.map(p=>p[p.length-1]); | |
| const prob = lastCol.filter(v=>v>=target).length / lastCol.length; | |
| const q10 = quantile(lastCol,0.10), q50 = quantile(lastCol,0.50), q90 = quantile(lastCol,0.90); | |
| el('projStats').innerHTML = | |
| `Horizon: <b>${horizon} days</b> · Lookback: <b>${lookback}</b> · Drift: <b>${mu.toFixed(3)}</b> · Vol: <b>${sd.toFixed(3)}</b><br> | |
| 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>`; | |
| } | |
| /* ===== Heatmap ===== */ | |
| function cpiClass(v){ | |
| if(v==null || !isFinite(v)) return 'hm-empty'; | |
| if(v < 0.80) return 'hm-red'; | |
| if(v < 1.00) return 'hm-amber'; | |
| return 'hm-green'; | |
| } | |
| function renderHeatmap(){ | |
| const table = document.getElementById('heatTable'); | |
| if(!table) return; | |
| const rows = filteredRows().map(calcDaily); | |
| if(rows.length===0){ | |
| table.innerHTML = '<thead><tr><th>Contractor</th></tr></thead><tbody><tr><td class="sub" style="padding:14px">No data.</td></tr></tbody>'; | |
| return; | |
| } | |
| const contractors = [...new Set(rows.map(r=>r.Contractor))].sort(); | |
| const dates = [...new Set(rows.map(r=>r.Date))].sort(); | |
| const key = (c,d)=>`${c}__${d}`; | |
| const map = new Map(); | |
| for(const r of rows){ map.set(key(r.Contractor,r.Date), r.CPI); } | |
| let thead = '<thead><tr><th>Contractor</th>'; | |
| for(const d of dates){ thead += `<th>${d}</th>`; } | |
| thead += '</tr></thead>'; | |
| let tbody = '<tbody>'; | |
| for(const c of contractors){ | |
| tbody += `<tr><th>${c}</th>`; | |
| for(const d of dates){ | |
| const v = map.get(key(c,d)); | |
| const cls = cpiClass(v); | |
| const txt = (v==null || !isFinite(v)) ? '—' : v.toFixed(2); | |
| const title = (v==null || !isFinite(v)) ? `${c} • ${d}: no data` : `${c} • ${d}: CPI ${v.toFixed(3)}`; | |
| tbody += `<td class="hm-cell ${cls}" title="${title}">${txt}</td>`; | |
| } | |
| tbody += '</tr>'; | |
| } | |
| tbody += '</tbody>'; | |
| table.innerHTML = thead + tbody; | |
| } | |
| /* ===== Export CSV ===== */ | |
| function buildMetricsCSV(){ | |
| const byC=groupByContractor(filteredRows().map(calcDaily)); | |
| const rows = [['Contractor','7DayAvgCPI','5DayAvgEarned','5DayAvgBurn','CumCPI']]; | |
| for(const [name,list] of byC){ | |
| const c7=sevenDayAvgCPI(list); | |
| const e5=avgLastNDates(list,5,'EarnedHrs'); | |
| const b5=avgLastNDates(list,5,'BurnHrs'); | |
| const cum=cumulativeCPI(list); | |
| rows.push([ | |
| name, | |
| c7==null?'':c7.toFixed(3), | |
| e5==null?'':Math.round(e5), | |
| b5==null?'':Math.round(b5), | |
| cum==null?'':cum.toFixed(3) | |
| ]); | |
| } | |
| return rows.map(r=>r.map(v=>String(v).replace(/"/g,'""')).map(v=>`"${v}"`).join(',')).join('\r\n'); | |
| } | |
| /* ===== Summary + Render ===== */ | |
| function renderSummary(){ | |
| const rows=filteredRows(); | |
| const dates=[...new Set(rows.map(r=>r.Date))].sort(); | |
| const cum=cumulativeCPI(rows); | |
| el('summary').innerHTML = ` | |
| <div>Rows: <b>${rows.length}</b></div> | |
| <div>Contractors: <b>${new Set(rows.map(r=>r.Contractor)).size}</b></div> | |
| <div>Dates: <b>${dates.length}</b> <span class="sub">(${dates[0]||'-'} → ${dates[dates.length-1]||'-'})</span></div> | |
| <div>Cumulative CPI: <b>${cum==null?'-':cum.toFixed(2)}</b></div>`; | |
| } | |
| function destroyCharts(){ | |
| if(state.charts.cum){state.charts.cum.destroy();state.charts.cum=null} | |
| if(state.charts.rolling){state.charts.rolling.destroy();state.charts.rolling=null} | |
| if(state.charts.proj){state.charts.proj.destroy();state.charts.proj=null} | |
| } | |
| function renderAll(){ | |
| renderLeague(); | |
| renderRollingChart(); | |
| renderProjection(); | |
| renderCumChart(); | |
| renderSummary(); | |
| renderHeatmap(); | |
| } | |
| /* ===== Presets / Slider (GLOBAL) ===== */ | |
| function setPresetActive(v){ document.querySelectorAll('.preset').forEach(b=>b.classList.toggle('active', b.dataset.preset==String(v))); } | |
| el('lookback').addEventListener('input',e=>{ | |
| state.lookbackDays=+e.target.value; | |
| el('lookbackLabel').textContent=`${state.lookbackDays} days`; | |
| setPresetActive(state.lookbackDays); | |
| renderAll(); | |
| }); | |
| document.querySelectorAll('.preset').forEach(b=>b.addEventListener('click',()=>{ | |
| state.lookbackDays=(b.dataset.preset==='all'?'all':+b.dataset.preset); | |
| renderAll(); | |
| })); | |
| /* ===== Initial ===== */ | |
| function buildContractorList(){ | |
| const box=el('contractorBox'); const q=el('contractorSearch').value?.toLowerCase()||''; | |
| const names=[...new Set(state.rows.map(r=>r.Contractor))].sort().filter(n=>n.toLowerCase().includes(q)); | |
| const sel=new Set(state.filters.contractors); | |
| 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>'; | |
| box.querySelectorAll('input[type="checkbox"]').forEach(cb=>{ | |
| cb.addEventListener('change', ()=>{ | |
| const set=new Set(state.filters.contractors); | |
| if(cb.checked) set.add(cb.value); else set.delete(cb.value); | |
| state.filters.contractors=[...set]; renderAll(); | |
| }); | |
| }); | |
| } | |
| el('contractorSearch').addEventListener('input', buildContractorList); | |
| el('selectAll').addEventListener('click',()=>{ const names=[...new Set(state.rows.map(r=>r.Contractor))]; state.filters.contractors=names; buildContractorList(); renderAll(); }); | |
| el('clearPick').addEventListener('click',()=>{ state.filters.contractors=[]; buildContractorList(); renderAll(); }); | |
| buildContractorList(); renderAll(); | |
| </script> | |
| <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> | |
| </html> | |