prdtrend1-0 / index.html
Kaliman-1981's picture
Add 2 files
1a3a053 verified
<!DOCTYPE html>
<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%!important;height:100%!important}
/* 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> &lt; 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,'&quot;')}" ${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>