- Dashboard renders deck reports and scorecards as formatted pages (self-contained markdown renderer inlined in index.html — headings, styled tables, evidence blockquotes; no CDN, air-gap friendly) - At-a-glance strip on every deck report: composite with delta vs the prior deck, quant/qual/penalty mix, KPI hit/miss chips, BDEF best/weakest category, red-flag count - Generated DECK_REPORT.md now leads with a concise "At a glance" summary table (scorecard.py); jobs.py passes the previous deck record so the delta appears in the file too; dashboard hides the duplicate section since the strip covers it - .gitignore: .venv/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
653 lines
34 KiB
HTML
653 lines
34 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>Boardroom Map</title>
|
||
<style>
|
||
:root { --bg:#10131a; --panel:#181d27; --edge:#2a313f; --ink:#e9edf5; --dim:#97a1b5;
|
||
--accent:#c9a24b; --ok:#5fd08a; --yg:#b9d05f; --warn:#ffcf66; --bad:#ff7a7a; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||
background:var(--bg); color:var(--ink); }
|
||
header { display:flex; align-items:center; gap:12px; padding:18px 22px; border-bottom:1px solid var(--edge); }
|
||
header .seal { width:26px;height:26px;border-radius:50%;border:2px solid var(--accent);
|
||
display:flex;align-items:center;justify-content:center;color:var(--accent);font-weight:700;font-size:13px; }
|
||
h1 { font-size:18px; margin:0; letter-spacing:2px; }
|
||
.wrap { max-width:1100px; margin:0 auto; padding:22px; display:grid; gap:18px; grid-template-columns:1fr 1fr; }
|
||
.card { background:var(--panel); border:1px solid var(--edge); border-radius:14px; padding:16px 18px; }
|
||
.card.full { grid-column:1 / -1; }
|
||
.card h2 { margin:0 0 10px; font-size:13px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); }
|
||
.pill { display:inline-block; padding:2px 10px; border-radius:999px; font-size:12px; font-weight:600; }
|
||
.pill.idle,.pill.done{background:#26303f;color:var(--dim)}
|
||
.pill.busy{background:#3b3414;color:var(--warn)}
|
||
.pill.error{background:#3b1414;color:var(--bad)}
|
||
.kv { display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--edge); }
|
||
.kv:last-child{border:0} .kv .k{color:var(--dim)}
|
||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||
.dot.on{background:var(--ok)} .dot.off{background:#55608f}
|
||
pre { background:#0c0f16; border:1px solid var(--edge); border-radius:10px; padding:12px; margin:0;
|
||
max-height:340px; overflow:auto; font:12px/1.5 ui-monospace,Menlo,monospace; color:#cdd6ff; white-space:pre-wrap; }
|
||
pre.tall{max-height:520px}
|
||
.row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center}
|
||
button{background:#222a3a;color:var(--ink);border:1px solid var(--edge);border-radius:9px;
|
||
padding:8px 12px;font-size:13px;cursor:pointer} button:hover{background:#2c3650}
|
||
button.primary{background:#3a2f12;border-color:#6b551f;color:#ffdf9a}
|
||
button.mini{padding:2px 8px;font-size:11px;border-radius:7px}
|
||
a.mini{display:inline-block;background:#222a3a;border:1px solid var(--edge);border-radius:7px;
|
||
padding:2px 8px;font-size:11px;color:var(--ink);text-decoration:none;cursor:pointer}
|
||
a.mini:hover{background:#2c3650}
|
||
select,input[type=text]{background:#0c0f16;color:var(--ink);border:1px solid var(--edge);
|
||
border-radius:9px;padding:7px 10px;font-size:13px}
|
||
.drop{border:1.5px dashed var(--edge);border-radius:12px;padding:18px;text-align:center;color:var(--dim);cursor:pointer;margin-top:10px}
|
||
.drop.hot{border-color:var(--accent);color:var(--ink)}
|
||
.badge{font-size:11px;color:var(--dim)}
|
||
.muted{color:var(--dim);font-size:12px}
|
||
a{color:#9ab8ff}
|
||
/* portfolio */
|
||
table.port{width:100%;border-collapse:collapse}
|
||
table.port th{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);
|
||
text-align:left;padding:4px 8px;border-bottom:1px solid var(--edge)}
|
||
table.port td{padding:7px 8px;border-bottom:1px dashed var(--edge);vertical-align:middle}
|
||
table.port tr.co{cursor:pointer} table.port tr.co:hover td{background:#1e2532}
|
||
.score{display:inline-block;min-width:46px;text-align:center;padding:2px 8px;border-radius:8px;
|
||
font-weight:700;font-size:13px}
|
||
.score.green{background:#15351f;color:var(--ok)} .score.yg{background:#2c3315;color:var(--yg)}
|
||
.score.amber{background:#3b3414;color:var(--warn)} .score.red{background:#3b1414;color:var(--bad)}
|
||
.score.none{background:#20263200;color:var(--dim);font-weight:400}
|
||
.delta{font-size:12px;font-weight:600;margin-left:4px}
|
||
.delta.up{color:var(--ok)} .delta.dn{color:var(--bad)} .delta.flat{color:var(--dim)}
|
||
.tag{display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:600;
|
||
letter-spacing:.5px;text-transform:uppercase}
|
||
.tag.unreg{background:#3b3414;color:var(--warn)}
|
||
.tag.profit{background:#15351f;color:var(--ok)}
|
||
.chip{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;margin:2px 4px 2px 0;
|
||
background:#222a3a;border:1px solid var(--edge)}
|
||
.chip.done{background:#15351f;color:var(--ok);border-color:#1f4a2c}
|
||
.chip.error{background:#3b1414;color:var(--bad);border-color:#552}
|
||
.chip.busy{background:#3b3414;color:var(--warn);border-color:#554416}
|
||
.chip.warn{background:#3b1414;color:var(--bad);border-color:#552222}
|
||
.chip.period{background:#1c2430;color:#9ab8ff;border-color:#2a3a55}
|
||
/* detail */
|
||
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:12px}
|
||
@media (max-width:900px){ .detail-grid{grid-template-columns:1fr} .wrap{grid-template-columns:1fr} }
|
||
h3.sub{margin:14px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--dim)}
|
||
table.mini{width:100%;border-collapse:collapse;font-size:12px}
|
||
table.mini th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);
|
||
text-align:left;padding:3px 6px;border-bottom:1px solid var(--edge)}
|
||
table.mini td{padding:4px 6px;border-bottom:1px dashed var(--edge)}
|
||
.flag{border-left:3px solid var(--bad);background:#221318;border-radius:0 8px 8px 0;
|
||
padding:6px 10px;margin:6px 0;font-size:12px}
|
||
.flag .code{font:11px ui-monospace,Menlo,monospace;color:var(--bad)}
|
||
.inbox-co{margin-top:8px}
|
||
.inbox-co .co-name{font-weight:600;font-size:12px;color:var(--accent);letter-spacing:.5px}
|
||
details.jsonv summary{cursor:pointer;color:var(--dim);font-size:12px;margin:8px 0 4px}
|
||
/* rendered markdown reports */
|
||
.mdview{background:#0c0f16;border:1px solid var(--edge);border-radius:10px;
|
||
padding:14px 18px;max-height:560px;overflow:auto;font-size:13px;line-height:1.55}
|
||
.mdview h1{font-size:15px;margin:2px 0 10px;letter-spacing:.5px}
|
||
.mdview h2{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);
|
||
margin:18px 0 8px;border-bottom:1px solid var(--edge);padding-bottom:4px}
|
||
.mdview h2:first-child,.mdview h1+h2{margin-top:6px}
|
||
.mdview h3{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);margin:14px 0 6px}
|
||
.mdview table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0}
|
||
.mdview th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);
|
||
text-align:left;padding:3px 8px;border-bottom:1px solid var(--edge)}
|
||
.mdview td{padding:4px 8px;border-bottom:1px dashed var(--edge);vertical-align:top}
|
||
.mdview blockquote{margin:6px 0;padding:4px 12px;border-left:3px solid var(--accent);
|
||
color:var(--dim);font-style:italic;background:#141926;border-radius:0 8px 8px 0}
|
||
.mdview code{background:#1c2430;border-radius:4px;padding:0 5px;
|
||
font:11px ui-monospace,Menlo,monospace;color:#cdd6ff}
|
||
.mdview p{margin:6px 0} .mdview ul{margin:6px 0;padding-left:22px} .mdview li{margin:2px 0}
|
||
.mdview hr{border:0;border-top:1px solid var(--edge);margin:12px 0}
|
||
/* at-a-glance strip above a deck report */
|
||
.glance{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 12px;align-items:stretch}
|
||
.glance .g{background:#0c0f16;border:1px solid var(--edge);border-radius:10px;
|
||
padding:8px 14px;min-width:110px}
|
||
.glance .g .t{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--dim);margin-bottom:4px}
|
||
.glance .g .v{font-size:14px;font-weight:600}
|
||
.glance .g .v.small{font-size:12px;font-weight:500}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="seal">§</div>
|
||
<h1>BOARDROOM MAP</h1>
|
||
<span id="phase" class="pill idle">idle</span>
|
||
<span class="muted" id="netmode"></span>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<div class="card full">
|
||
<h2>Portfolio</h2>
|
||
<div id="portfolio"><div class="muted">Loading…</div></div>
|
||
</div>
|
||
|
||
<div class="card full" id="companyCard" style="display:none">
|
||
<h2 style="display:flex;align-items:center;gap:10px">Company —
|
||
<span id="coName" style="color:var(--ink);letter-spacing:0;text-transform:none;font-size:15px"></span>
|
||
<span id="coBadges"></span>
|
||
<button class="mini" style="margin-left:auto" onclick="closeCompany()">close</button>
|
||
</h2>
|
||
<div id="coTrend"></div>
|
||
<div class="detail-grid">
|
||
<div>
|
||
<h3 class="sub">BDEF categories (latest, tick = previous)</h3>
|
||
<div id="coCats"><div class="muted">—</div></div>
|
||
<h3 class="sub">Open red flags (latest deck)</h3>
|
||
<div id="coFlags"><div class="muted">—</div></div>
|
||
</div>
|
||
<div>
|
||
<h3 class="sub">KPI hit rate</h3>
|
||
<div id="coKpis"><div class="muted">—</div></div>
|
||
</div>
|
||
</div>
|
||
<h3 class="sub">Deck history</h3>
|
||
<div id="coDecks"><div class="muted">—</div></div>
|
||
<div class="row">
|
||
<button onclick="toggleScorecard()" id="scBtn">View SCORECARD.md</button>
|
||
</div>
|
||
<div id="coViewer" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Drop decks</h2>
|
||
<div class="row" style="margin-top:0">
|
||
<label class="muted" for="coSelect">Company</label>
|
||
<select id="coSelect" onchange="onCoSelect()"></select>
|
||
<span id="newCoWrap" style="display:none">
|
||
<input id="newCoName" type="text" placeholder="new company name" oninput="slugPreview()"/>
|
||
<span class="badge" id="slugPrev"></span>
|
||
</span>
|
||
</div>
|
||
<div id="drop" class="drop">Drop board decks here, or click to choose<br>
|
||
<span class="badge">PDF · PPTX · DOCX · TXT · MD — period parsed from filename (2026-Q2, FY2026…)</span></div>
|
||
<input id="file" type="file" multiple style="display:none"/>
|
||
<div id="inbox" style="margin-top:10px"></div>
|
||
<div class="row">
|
||
<button class="primary" onclick="act('/api/run','POST')">Grade decks</button>
|
||
<button onclick="act('/api/inbox/clear','POST')">Clear Inbox</button>
|
||
<button onclick="refresh(); loadCompanies()">Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Setup</h2>
|
||
<div id="ready"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Grading panel</h2>
|
||
<div id="panel"><div class="muted">No graders configured.</div></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Serving / Job</h2>
|
||
<div id="job"><div class="muted">—</div></div>
|
||
<div class="row">
|
||
<button onclick="act('/api/grader/build-image','POST')">Build grader image</button>
|
||
<button onclick="act('/api/stop','POST')">Stop serving</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card full">
|
||
<h2>Activity log</h2>
|
||
<pre id="log">loading…</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ------------------------------------------------------------------ helpers
|
||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g,
|
||
c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
|
||
async function getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error(await r.text()); return r.json(); }
|
||
async function act(u,m){ try{ const r=await fetch(u,{method:m}); const t=await r.text();
|
||
alert(r.ok? 'OK: '+t.slice(0,400) : 'Error: '+t.slice(0,400)); refresh(); }catch(e){ alert(e); } }
|
||
function dot(on,label){ return `<div><span class="dot ${on?'on':'off'}"></span>${label}</div>`; }
|
||
function bandCls(c){ return c>=75?'green':c>=60?'yg':c>=45?'amber':'red'; }
|
||
function scoreBadge(c){ if(c==null||isNaN(+c)) return '<span class="score none">—</span>';
|
||
return `<span class="score ${bandCls(+c)}">${(+c).toFixed(1)}</span>`; }
|
||
function deltaArrow(d){ if(d==null||isNaN(+d)) return '';
|
||
if(Math.abs(+d)<0.05) return '<span class="delta flat">→ 0.0</span>';
|
||
return +d>0? `<span class="delta up">▲ ${(+d).toFixed(1)}</span>`
|
||
: `<span class="delta dn">▼ ${Math.abs(+d).toFixed(1)}</span>`; }
|
||
|
||
// Minimal markdown renderer for our own reports (headings, tables, quotes,
|
||
// lists, bold/`code`) — self-contained so the air-gapped box needs no CDN.
|
||
function mdInline(s){
|
||
return esc(s)
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
|
||
}
|
||
function mdToHtml(md){
|
||
const lines=String(md||'').split(/\r?\n/), out=[]; let i=0;
|
||
const isRow=l=>/^\s*\|.*\|\s*$/.test(l);
|
||
const isSep=l=>/^\s*\|[\s:|-]+\|\s*$/.test(l);
|
||
const cells=r=>r.trim().replace(/^\||\|$/g,'').split('|').map(c=>mdInline(c.trim()));
|
||
while(i<lines.length){
|
||
const l=lines[i]; let m;
|
||
if(/^\s*$/.test(l)){ i++; continue; }
|
||
if(m=l.match(/^(#{1,4})\s+(.*)/)){
|
||
out.push(`<h${m[1].length}>${mdInline(m[2])}</h${m[1].length}>`); i++; continue; }
|
||
if(/^\s*-{3,}\s*$/.test(l)){ out.push('<hr>'); i++; continue; }
|
||
if(isRow(l) && isSep(lines[i+1]||'')){
|
||
out.push('<table><thead><tr>'+cells(l).map(c=>`<th>${c}</th>`).join('')+'</tr></thead><tbody>');
|
||
i+=2;
|
||
while(i<lines.length && isRow(lines[i]))
|
||
out.push('<tr>'+cells(lines[i++]).map(c=>`<td>${c}</td>`).join('')+'</tr>');
|
||
out.push('</tbody></table>'); continue; }
|
||
if(/^\s*>\s?/.test(l)){
|
||
const q=[];
|
||
while(i<lines.length && /^\s*>\s?/.test(lines[i]))
|
||
q.push(mdInline(lines[i++].replace(/^\s*>\s?/,'')));
|
||
out.push('<blockquote>'+q.join('<br>')+'</blockquote>'); continue; }
|
||
if(/^\s*[-*]\s+/.test(l)){
|
||
const li=[];
|
||
while(i<lines.length && /^\s*[-*]\s+/.test(lines[i]))
|
||
li.push('<li>'+mdInline(lines[i++].replace(/^\s*[-*]\s+/,''))+'</li>');
|
||
out.push('<ul>'+li.join('')+'</ul>'); continue; }
|
||
const p=[];
|
||
while(i<lines.length && !/^\s*$/.test(lines[i]) && !/^#{1,4}\s|^\s*[->]|^\s*\*\s|^\s*\|/.test(lines[i]))
|
||
p.push(mdInline(lines[i++]));
|
||
if(p.length) out.push('<p>'+p.join(' ')+'</p>'); else i++;
|
||
}
|
||
return `<div class="mdview">${out.join('\n')}</div>`;
|
||
}
|
||
|
||
// At-a-glance strip for one deck record (prev = the preceding graded deck).
|
||
function glanceStrip(rec,prev){
|
||
if(!rec) return '';
|
||
const q=rec.quant||{}, ql=rec.qual||{}, pen=rec.penalties||{};
|
||
const f1=x=>(x==null||isNaN(+x))?'—':(+x).toFixed(1);
|
||
let delta='';
|
||
if(prev && prev.composite!=null && rec.composite!=null)
|
||
delta=deltaArrow(rec.composite-prev.composite)+
|
||
`<span class="badge"> vs ${esc(prev.period||'prev')}</span>`;
|
||
const kres=(rec.kpi_results||[]).filter(k=>typeof k.credit==='number');
|
||
const hits=kres.filter(k=>k.credit>=0.999).length;
|
||
const kchips=kres.map(k=>
|
||
`<span class="chip ${k.credit>=0.999?'done':'warn'}">${k.credit>=0.999?'✓':'✗'} `+
|
||
`${esc(k.name||k.canonical_name||'?')}</span>`).join('');
|
||
const cats=ql.categories||{}; let best=null,worst=null;
|
||
Object.keys(BDEF).forEach(id=>{
|
||
const a=(cats[id]||{}).adjusted;
|
||
if(a==null||isNaN(+a)) return;
|
||
if(!best||+a>best.a) best={id,a:+a};
|
||
if(!worst||+a<worst.a) worst={id,a:+a};
|
||
});
|
||
const flags=pen.flags||[];
|
||
return `<div class="glance">`+
|
||
`<div class="g"><div class="t">Composite</div><div class="v">${scoreBadge(rec.composite)} ${delta}</div></div>`+
|
||
`<div class="g"><div class="t">Score mix</div><div class="v small">quant ${f1(q.score)} · `+
|
||
`qual ${f1(ql.score)} · flags −${f1(pen.total)}</div></div>`+
|
||
(kres.length?`<div class="g"><div class="t">KPIs vs target — ${hits}/${kres.length} hit</div>`+
|
||
`<div>${kchips}</div></div>`:'')+
|
||
(best?`<div class="g"><div class="t">BDEF best / weakest</div><div class="v small">`+
|
||
`${best.id} · ${esc(BDEF[best.id])} <strong>${best.a.toFixed(1)}</strong><br>`+
|
||
`${worst.id} · ${esc(BDEF[worst.id])} <strong>${worst.a.toFixed(1)}</strong></div></div>`:'')+
|
||
`<div class="g"><div class="t">Red flags</div><div class="v">${flags.length||'none'}</div></div>`+
|
||
`</div>`;
|
||
}
|
||
|
||
// Inline SVG sparkline: min-max normalized polyline (~120x28).
|
||
function sparkline(hist,w,h){
|
||
w=w||120; h=h||28;
|
||
const vals=(hist||[]).map(p=>+p.composite).filter(v=>!isNaN(v));
|
||
if(!vals.length) return '<span class="muted">—</span>';
|
||
if(vals.length===1) return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`+
|
||
`<circle cx="${w/2}" cy="${h/2}" r="2.5" fill="var(--accent)"/></svg>`;
|
||
const mn=Math.min(...vals), mx=Math.max(...vals), span=(mx-mn)||1, pad=3;
|
||
const pts=vals.map((v,i)=>
|
||
`${(pad+i*(w-2*pad)/(vals.length-1)).toFixed(1)},${(h-pad-(v-mn)*(h-2*pad)/span).toFixed(1)}`).join(' ');
|
||
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`+
|
||
`<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>`;
|
||
}
|
||
|
||
// Large composite trend with period labels + value dots.
|
||
function bigTrend(hist){
|
||
const pts=(hist||[]).filter(p=>p.composite!=null && !isNaN(+p.composite));
|
||
if(!pts.length) return '<div class="muted">No graded decks yet.</div>';
|
||
const w=680,h=150,padL=14,padR=14,padT=18,padB=26;
|
||
const vals=pts.map(p=>+p.composite);
|
||
const mn=Math.min(...vals), mx=Math.max(...vals), span=(mx-mn)||1;
|
||
const x=i=> pts.length===1? w/2 : padL+i*(w-padL-padR)/(pts.length-1);
|
||
const y=v=> h-padB-(v-mn)*(h-padT-padB)/span;
|
||
let svg=`<svg width="100%" viewBox="0 0 ${w} ${h}" style="max-width:${w}px">`;
|
||
if(pts.length>1)
|
||
svg+=`<polyline points="${pts.map((p,i)=>`${x(i).toFixed(1)},${y(+p.composite).toFixed(1)}`).join(' ')}" `+
|
||
`fill="none" stroke="var(--accent)" stroke-width="2"/>`;
|
||
const step=Math.max(1,Math.ceil(pts.length/10));
|
||
pts.forEach((p,i)=>{
|
||
const px=x(i), py=y(+p.composite);
|
||
svg+=`<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="3.5" fill="var(--accent)"/>`;
|
||
svg+=`<text x="${px.toFixed(1)}" y="${(py-8).toFixed(1)}" text-anchor="middle" `+
|
||
`font-size="10" fill="var(--ink)">${(+p.composite).toFixed(1)}</text>`;
|
||
if(i%step===0 || i===pts.length-1)
|
||
svg+=`<text x="${px.toFixed(1)}" y="${h-8}" text-anchor="middle" font-size="10" `+
|
||
`fill="var(--dim)">${esc(p.period||'?')}</text>`;
|
||
});
|
||
return svg+'</svg>';
|
||
}
|
||
|
||
// Horizontal BDEF A–H bars (0–5), previous value as a tick marker.
|
||
const BDEF={A:'Incentive alignment',B:'Inversion discipline',C:'Circle of competence',
|
||
D:'Capital allocation',E:'Moat durability',F:'Psych / cultural health',
|
||
G:'Simplicity & velocity',H:'Board value-add'};
|
||
function bdefBars(cats){
|
||
const ids=Object.keys(BDEF).filter(id=>cats[id]);
|
||
if(!ids.length) return '<div class="muted">—</div>';
|
||
const rowH=26,w=460,lab=170,barW=w-lab-46;
|
||
let svg=`<svg width="100%" viewBox="0 0 ${w} ${ids.length*rowH}" style="max-width:${w}px">`;
|
||
ids.forEach((id,r)=>{
|
||
const cy=r*rowH+rowH/2, c=cats[id]||{};
|
||
const cur=c.latest_adjusted, prev=c.previous_adjusted;
|
||
svg+=`<text x="0" y="${cy+4}" font-size="11" fill="var(--dim)">${id} · ${esc(BDEF[id])}</text>`;
|
||
svg+=`<rect x="${lab}" y="${cy-6}" width="${barW}" height="12" rx="6" fill="#0c0f16" stroke="var(--edge)"/>`;
|
||
if(cur!=null && !isNaN(+cur)){
|
||
const bw=Math.max(2,Math.min(1,+cur/5)*barW);
|
||
svg+=`<rect x="${lab}" y="${cy-6}" width="${bw.toFixed(1)}" height="12" rx="6" fill="var(--accent)"/>`;
|
||
svg+=`<text x="${lab+barW+8}" y="${cy+4}" font-size="11" fill="var(--ink)">${(+cur).toFixed(1)}</text>`;
|
||
}
|
||
if(prev!=null && !isNaN(+prev)){
|
||
const tx=lab+Math.min(1,+prev/5)*barW;
|
||
svg+=`<line x1="${tx.toFixed(1)}" y1="${cy-9}" x2="${tx.toFixed(1)}" y2="${cy+9}" `+
|
||
`stroke="var(--ink)" stroke-width="1.5" opacity="0.7"/>`;
|
||
}
|
||
});
|
||
return svg+'</svg>';
|
||
}
|
||
|
||
// Client-side mirror of decks.py period parsing (for inbox display).
|
||
const PERIOD_RES=[
|
||
[/(?:^|\D)((?:19|20)\d\d)[-_ ]?[Qq]([1-4])(?!\d)/, m=>m[1]+'-Q'+m[2]],
|
||
[/(?:^|[^A-Za-z0-9])[Qq]([1-4])[-_ ]((?:19|20)\d\d)(?!\d)/, m=>m[2]+'-Q'+m[1]],
|
||
[/(?:^|\D)((?:19|20)\d\d)[-_ ]?[Hh]([12])(?!\d)/, m=>m[1]+'-H'+m[2]],
|
||
[/(?:^|[^A-Za-z0-9])[Ff][Yy][-_ ]?((?:19|20)\d\d)(?!\d)/, m=>'FY'+m[1]],
|
||
[/(?:^|\D)((?:19|20)\d\d)[-_](0[1-9]|1[0-2])(?!\d)/, m=>m[1]+'-'+m[2]],
|
||
];
|
||
function parsePeriod(name){
|
||
for(const [re,f] of PERIOD_RES){ const m=String(name||'').match(re); if(m) return f(m); }
|
||
return null;
|
||
}
|
||
function slugify(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-')
|
||
.replace(/^-+|-+$/g,'') || 'company'; }
|
||
|
||
// ------------------------------------------------------------------ portfolio
|
||
let companiesCache=[], currentSlug=null;
|
||
|
||
async function loadCompanies(){
|
||
try{
|
||
const d=await getJSON('/api/companies');
|
||
companiesCache=d.companies||[];
|
||
renderPortfolio(); renderCoSelect();
|
||
if(currentSlug) openCompany(currentSlug,true);
|
||
}catch(e){
|
||
document.getElementById('portfolio').innerHTML='<div class="muted">companies error: '+esc(e.message||e)+'</div>';
|
||
}
|
||
}
|
||
|
||
function renderPortfolio(){
|
||
const el=document.getElementById('portfolio');
|
||
if(!companiesCache.length){
|
||
el.innerHTML='<div class="muted">No companies yet — drop a deck below to create one.</div>'; return;
|
||
}
|
||
el.innerHTML='<table class="port"><thead><tr>'+
|
||
'<th>Company</th><th>Latest</th><th>Δ</th><th>Trend</th><th>Decks</th></tr></thead><tbody>'+
|
||
companiesCache.map(c=>{
|
||
const l=c.latest||{};
|
||
return `<tr class="co" onclick="openCompany('${esc(c.slug)}')">`+
|
||
`<td>${esc(c.name)} ${c.auto_created?'<span class="tag unreg">unregistered</span>':''}</td>`+
|
||
`<td>${scoreBadge(l.composite)} <span class="badge">${esc(l.period||'')}</span></td>`+
|
||
`<td>${deltaArrow(l.delta)}</td>`+
|
||
`<td>${sparkline(c.history)}</td>`+
|
||
`<td class="muted">${c.deck_count||0}</td></tr>`;
|
||
}).join('')+'</tbody></table>';
|
||
}
|
||
|
||
// ------------------------------------------------------------------ company detail
|
||
async function openCompany(slug,silent){
|
||
try{
|
||
const d=await getJSON('/api/companies/'+encodeURIComponent(slug));
|
||
const changed = currentSlug!==slug;
|
||
currentSlug=slug;
|
||
renderDetail(d,changed);
|
||
document.getElementById('companyCard').style.display='';
|
||
if(!silent) document.getElementById('companyCard').scrollIntoView({behavior:'smooth'});
|
||
}catch(e){ if(!silent) alert('load company failed: '+(e.message||e)); }
|
||
}
|
||
function closeCompany(){
|
||
currentSlug=null; scorecardOpen=false;
|
||
document.getElementById('coViewer').innerHTML='';
|
||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
||
document.getElementById('companyCard').style.display='none';
|
||
}
|
||
|
||
let currentRecs=[];
|
||
function renderDetail(d,changed){
|
||
const co=d.company||{}, recs=d.records||[];
|
||
currentRecs=recs;
|
||
document.getElementById('coName').textContent=co.name||co.slug||'?';
|
||
document.getElementById('coBadges').innerHTML=co.auto_created?'<span class="tag unreg">unregistered</span>':'';
|
||
document.getElementById('coTrend').innerHTML=
|
||
bigTrend(recs.map(r=>({period:r.period,composite:r.composite})));
|
||
document.getElementById('coCats').innerHTML=bdefBars(d.categories_latest||{});
|
||
|
||
// KPI hit-rate table — profitability KPIs pinned to top.
|
||
const kpis=Object.entries(d.kpi_hit_rate||{})
|
||
.map(([cn,s])=>Object.assign({canonical:cn},s))
|
||
.sort((a,b)=>(b.profitability?1:0)-(a.profitability?1:0)||a.canonical.localeCompare(b.canonical));
|
||
document.getElementById('coKpis').innerHTML=!kpis.length? '<div class="muted">—</div>' :
|
||
'<table class="mini"><thead><tr><th>KPI</th><th></th><th>Last credit</th><th>Hits</th><th>Streak</th></tr></thead><tbody>'+
|
||
kpis.map(k=>`<tr><td title="${esc(k.canonical)}">${esc(k.name||k.canonical)}</td>`+
|
||
`<td>${k.profitability?'<span class="tag profit">profit</span>':''}</td>`+
|
||
`<td>${k.last_credit==null?'—':(k.last_credit*100).toFixed(0)+'%'}</td>`+
|
||
`<td class="muted">${k.hits||0}/${k.attempts||0}</td>`+
|
||
`<td class="muted">${k.streak||0}</td></tr>`).join('')+'</tbody></table>';
|
||
|
||
// Open red flags (latest deck).
|
||
const flags=d.open_flags||[];
|
||
document.getElementById('coFlags').innerHTML=!flags.length? '<div class="muted">none</div>' :
|
||
flags.map(f=>`<div class="flag"><span class="code">${esc(f.code)}</span>`+
|
||
` <span class="badge">severity ${esc(f.severity)}${f.points!=null?' · -'+esc(f.points)+' pts':''}</span>`+
|
||
`<br>${esc(f.description)}</div>`).join('');
|
||
|
||
// Deck history (newest first).
|
||
document.getElementById('coDecks').innerHTML=!recs.length? '<div class="muted">no graded decks yet</div>' :
|
||
'<table class="mini"><thead><tr><th>Period</th><th>Composite</th><th>Graded</th><th></th></tr></thead><tbody>'+
|
||
recs.slice().reverse().map(r=>
|
||
`<tr><td>${esc(r.period||'?')}</td><td>${scoreBadge(r.composite)}</td>`+
|
||
`<td class="muted">${esc(String(r.graded_at||'').slice(0,16).replace('T',' '))}</td>`+
|
||
`<td><button class="mini" onclick="viewDeckReport('${esc(r.deck_id)}')">report</button> `+
|
||
`<button class="mini" onclick="viewDeckJson('${esc(r.deck_id)}')">json</button> `+
|
||
`<a class="mini" href="${deckBase(r.deck_id)}/report" `+
|
||
`download="${esc(co.slug||currentSlug)}-${esc(r.deck_id)}-report.md" title="Download report">⬇</a></td></tr>`).join('')+
|
||
'</tbody></table>';
|
||
|
||
// Only reset the viewer when switching companies — periodic re-renders must
|
||
// not close whatever report the user has open.
|
||
if(changed){
|
||
scorecardOpen=false;
|
||
document.getElementById('coViewer').innerHTML='';
|
||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
||
}
|
||
}
|
||
function deckBase(deckId){
|
||
return '/api/companies/'+encodeURIComponent(currentSlug)+'/decks/'+encodeURIComponent(deckId);
|
||
}
|
||
|
||
async function viewDeckReport(deckId){
|
||
if(!currentSlug) return;
|
||
const base=deckBase(deckId);
|
||
try{
|
||
const r=await fetch(base+'/report');
|
||
const t=await r.text();
|
||
const idx=currentRecs.findIndex(x=>x.deck_id===deckId);
|
||
const rec=idx>=0?currentRecs[idx]:null, prev=idx>0?currentRecs[idx-1]:null;
|
||
// The glance strip covers the report's own "At a glance" table — drop the
|
||
// duplicate section in the dashboard view (downloads keep it).
|
||
let md=t;
|
||
if(rec) md=md.replace(/\n## At a glance[\s\S]*?(?=\n(?:Graded |## ))/,'\n');
|
||
const body=r.ok? glanceStrip(rec,prev)+mdToHtml(md)
|
||
: `<pre class="tall">${esc('error: '+t)}</pre>`;
|
||
setViewer('Deck report — '+deckId, body,
|
||
base+'/report', `${currentSlug}-${deckId}-report.md`);
|
||
}catch(e){ alert(e); }
|
||
}
|
||
async function viewDeckJson(deckId){
|
||
if(!currentSlug) return;
|
||
try{
|
||
const d=await getJSON(deckBase(deckId));
|
||
setViewer('Deck record — '+deckId,
|
||
`<details class="jsonv" open><summary>collapse / expand raw JSON</summary>`+
|
||
`<pre class="tall">${esc(JSON.stringify(d,null,2))}</pre></details>`,
|
||
deckBase(deckId), `${currentSlug}-${deckId}.json`);
|
||
}catch(e){ alert('load record failed: '+(e.message||e)); }
|
||
}
|
||
let scorecardOpen=false;
|
||
async function toggleScorecard(){
|
||
if(!currentSlug) return;
|
||
const v=document.getElementById('coViewer'), btn=document.getElementById('scBtn');
|
||
if(scorecardOpen){ v.innerHTML=''; scorecardOpen=false; btn.textContent='View SCORECARD.md'; return; }
|
||
try{
|
||
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard';
|
||
const r=await fetch(url);
|
||
const t=await r.text();
|
||
setViewer('SCORECARD.md', r.ok? mdToHtml(t) : `<pre class="tall">${esc('error: '+t)}</pre>`,
|
||
url, `${currentSlug}-SCORECARD.md`);
|
||
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
|
||
}catch(e){ alert(e); }
|
||
}
|
||
function setViewer(title,html,dlHref,dlName){
|
||
scorecardOpen=false;
|
||
document.getElementById('scBtn').textContent='View SCORECARD.md';
|
||
const dl=dlHref?`<a class="mini" style="margin-left:auto" href="${dlHref}" `+
|
||
`download="${esc(dlName||'report.txt')}" title="Download">⬇ download</a>`:'';
|
||
document.getElementById('coViewer').innerHTML=
|
||
`<h3 class="sub" style="display:flex;align-items:center;gap:8px">${esc(title)}${dl}`+
|
||
`<button class="mini" ${dlHref?'':'style="margin-left:auto"'} `+
|
||
`onclick="document.getElementById('coViewer').innerHTML=''">close</button></h3>`+html;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ drop card
|
||
function renderCoSelect(){
|
||
const sel=document.getElementById('coSelect');
|
||
const cur=sel.value;
|
||
sel.innerHTML=companiesCache.map(c=>
|
||
`<option value="${esc(c.slug)}">${esc(c.name)}</option>`).join('')+
|
||
'<option value="__new__">new company…</option>';
|
||
if(cur && [...sel.options].some(o=>o.value===cur)) sel.value=cur;
|
||
else if(!companiesCache.length) sel.value='__new__';
|
||
onCoSelect();
|
||
}
|
||
function onCoSelect(){
|
||
const isNew=document.getElementById('coSelect').value==='__new__';
|
||
document.getElementById('newCoWrap').style.display=isNew?'':'none';
|
||
slugPreview();
|
||
}
|
||
function slugPreview(){
|
||
const n=document.getElementById('newCoName').value.trim();
|
||
document.getElementById('slugPrev').textContent=n?('→ /data/inbox/'+slugify(n)+'/'):'';
|
||
}
|
||
function uploadCompany(){
|
||
const sel=document.getElementById('coSelect');
|
||
if(sel.value!=='__new__') return sel.value;
|
||
const n=document.getElementById('newCoName').value.trim();
|
||
return n? slugify(n) : null;
|
||
}
|
||
|
||
const drop=document.getElementById('drop'), file=document.getElementById('file');
|
||
drop.onclick=()=>file.click();
|
||
file.onchange=()=>{ upload(file.files); file.value=''; };
|
||
['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('hot');}));
|
||
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('hot');}));
|
||
drop.addEventListener('drop',ev=>{ if(ev.dataTransfer.files.length) upload(ev.dataTransfer.files); });
|
||
|
||
async function upload(files){
|
||
const co=uploadCompany();
|
||
if(!co){ alert('Pick a company (or type a new company name) before uploading — root-level files are not graded.'); return; }
|
||
const names=[...files].map(f=>f.name+(parsePeriod(f.name)?' ['+parsePeriod(f.name)+']':' [no period in name]'));
|
||
const fd=new FormData(); for(const f of files) fd.append('files',f);
|
||
try{
|
||
const r=await fetch('/api/upload?company='+encodeURIComponent(co),{method:'POST',body:fd});
|
||
if(!r.ok) alert('Upload failed: '+(await r.text()).slice(0,300));
|
||
else console.log('uploaded to '+co+': '+names.join(', '));
|
||
refresh(); loadCompanies();
|
||
}catch(e){ alert(e); }
|
||
}
|
||
|
||
function renderInbox(ib){
|
||
const el=document.getElementById('inbox');
|
||
const cos=(ib&&ib.companies)||{}, skipped=(ib&&ib.skipped)||[];
|
||
const slugs=Object.keys(cos).sort();
|
||
let html='';
|
||
for(const slug of slugs){
|
||
html+=`<div class="inbox-co"><span class="co-name">${esc(slug)}</span>`+
|
||
cos[slug].map(f=>{
|
||
const per=f.period||parsePeriod(f.name);
|
||
return `<div class="kv"><span>${esc(f.name)} `+
|
||
(per?`<span class="chip period">${esc(per)}</span>`:'<span class="badge">period?</span>')+
|
||
(f.supported===false?' <span class="badge">unsupported</span>':'')+
|
||
`</span><span class="muted">${f.bytes?((f.bytes/1024).toFixed(0)+' KB'):''}</span></div>`;
|
||
}).join('')+'</div>';
|
||
}
|
||
if(skipped.length)
|
||
html+=`<div style="margin-top:8px"><span class="chip warn">skipped (no company): ${esc(skipped.join(', '))}</span></div>`;
|
||
el.innerHTML=html||'<div class="muted">Inbox empty.</div>';
|
||
}
|
||
|
||
// ------------------------------------------------------------------ polling
|
||
let lastPhase=null;
|
||
async function refresh(){
|
||
try{
|
||
const s = await getJSON('/api/status');
|
||
const rt = s.runtime||{};
|
||
const ph = document.getElementById('phase');
|
||
const phase = rt.phase||'idle';
|
||
ph.textContent = phase;
|
||
ph.className = 'pill '+(phase==='idle'||phase==='done'?phase==='done'?'done':'idle':phase==='error'?'error':'busy');
|
||
if(lastPhase && lastPhase!==phase && (phase==='done'||phase==='idle')) loadCompanies();
|
||
lastPhase=phase;
|
||
document.getElementById('netmode').textContent =
|
||
(s.networkMode==='airgapped'?'air-gapped':'local-services')
|
||
+ (s.adjudicator?' · adjudicator on':'') + (s.autoRunOnDrop?' · auto-run':'');
|
||
|
||
renderInbox(s.inbox);
|
||
|
||
document.getElementById('ready').innerHTML =
|
||
dot(s.configured.sparks,'Sparks configured') +
|
||
dot(s.configured.models>0,`${s.configured.models} model(s)`) +
|
||
dot(s.configured.graders>0,`${s.configured.graders} grader(s)`) +
|
||
`<div class="kv"><span class="k">network mode</span><span>${esc(s.networkMode)}</span></div>` +
|
||
`<div class="kv"><span class="k">wipe docs after</span><span>${s.wipeRemoteDocs?'yes':'no'}</span></div>` +
|
||
(s.models||[]).map(m=>`<div class="kv"><span>${esc(m.alias)}</span><span class="muted">${esc(m.hfModel)} · ${esc(m.spark)}</span></div>`).join('');
|
||
|
||
document.getElementById('panel').innerHTML = (s.panel||[]).length
|
||
? (s.panel||[]).map(g=>`<div class="kv"><span>${esc(g.name)} ${g.known?'':'<span class="badge">unknown model</span>'}</span><span class="muted">${esc(g.model)}${g.persona?' · persona ✓':''}</span></div>`).join('')
|
||
: '<div class="muted">No graders configured.</div>';
|
||
|
||
const deckChips=(rt.decks||[]).map(d=>{
|
||
const st=d.status||'pending';
|
||
const cls=st==='done'?'done':(st==='error'||st==='failed')?'error':(st==='pending'?'':'busy');
|
||
const tail=d.composite!=null?(' · '+(+d.composite).toFixed(1)):(d.error?' · error':'');
|
||
return `<span class="chip ${cls}">${esc(d.company)}${d.period?' '+esc(d.period):''}${tail}</span>`;
|
||
}).join('');
|
||
const curCo = rt.company||rt.current_company, curPer = rt.period||rt.current_period;
|
||
document.getElementById('job').innerHTML =
|
||
`<div class="kv"><span class="k">job</span><span>${esc(rt.job_id||'—')}</span></div>` +
|
||
(rt.decks_total?`<div class="kv"><span class="k">deck</span><span>${esc(rt.deck_index)}/${esc(rt.decks_total)}${curCo?' · '+esc(curCo)+(curPer?' '+esc(curPer):''):''}</span></div>`:'') +
|
||
(rt.waves_total?`<div class="kv"><span class="k">wave</span><span>${esc(rt.wave_index)}/${esc(rt.waves_total)}</span></div>`:'') +
|
||
(rt.message?`<div class="muted" style="margin-top:6px">${esc(rt.message)}</div>`:'') +
|
||
(deckChips?`<div style="margin-top:8px">${deckChips}</div>`:'') +
|
||
(rt.panel||[]).map(p=>`<div class="kv"><span>${esc(p.name)}</span><span class="muted">${esc(p.status||'')}</span></div>`).join('');
|
||
|
||
const ev = await getJSON('/api/events');
|
||
document.getElementById('log').textContent = (ev.events||[]).slice(-200).join('\n') || '(no activity yet)';
|
||
}catch(e){ document.getElementById('log').textContent = 'status error: '+e; }
|
||
}
|
||
refresh(); loadCompanies();
|
||
setInterval(refresh, 6000);
|
||
setInterval(loadCompanies, 60000);
|
||
</script>
|
||
</body>
|
||
</html>
|