Files
boardroom-map/orchestrator/templates/index.html
T
Jonathan KirkwoodandClaude Fable 5 9b9c7e58c1 v0.1.9: per-company progress reviews + deck-quality guidance
progress.py reads the whole graded ledger and answers two questions the
per-deck scorecard can't: is the company actually progressing (composite/
BDEF-category/KPI trajectories, recurring vs resolved flags), and is the
material good enough to judge them by — a deterministic gap engine spots
what the decks are NOT showing (no profitability visibility, untargeted
KPIs, no forward guidance, broken forecast chain, silently dropped KPIs,
thin-evidence BDEF categories, no board asks) and renders each gap as a
concrete, paste-ready request for the next deck.

Served live from the ledger (no GPU) at /api/companies/{slug}/progress(.md),
written to /data/ledger/<slug>/PROGRESS.md + /data/reports/latest-progress.md
after each graded deck, and viewable/downloadable from the dashboard company
card ("View progress review"). 18 new tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 20:50:41 -05:00

691 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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}
button.danger{background:#2c1416;border-color:#552222;color:var(--bad)}
button.danger:hover{background:#3b1414}
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 danger" style="margin-left:auto" onclick="deleteCompany()">delete company…</button>
<button class="mini" 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>
<button onclick="toggleProgress()" id="prBtn">View progress review</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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 AH bars (05), 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; resetViewerToggles();
document.getElementById('coViewer').innerHTML='';
document.getElementById('companyCard').style.display='none';
}
function resetViewerToggles(){
scorecardOpen=false; progressOpen=false;
document.getElementById('scBtn').textContent='View SCORECARD.md';
document.getElementById('prBtn').textContent='View progress review';
}
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){
resetViewerToggles();
document.getElementById('coViewer').innerHTML='';
}
}
async function deleteCompany(){
if(!currentSlug) return;
const name=(companiesCache.find(c=>c.slug===currentSlug)||{}).name||currentSlug;
if(!confirm(`Delete "${name}"?\n\nThis wipes its graded history: all deck records, `+
`the scorecard, and report copies. A company registered in Configure Companies `+
`reappears as an empty row (aliases and pinned targets kept), ready to re-grade.`)) return;
const restore=confirm('Move its already-graded deck files back into the inbox '+
'so they can be re-graded?\n\nOK = yes, put decks back · Cancel = no, just delete');
try{
const r=await fetch('/api/companies/'+encodeURIComponent(currentSlug)+
(restore?'?restore_decks=true':''),{method:'DELETE'});
const t=await r.text();
if(!r.ok){ alert('Delete failed: '+t.slice(0,300)); return; }
const d=JSON.parse(t);
alert(`Deleted ${name}.`+(d.restored_decks?` ${d.restored_decks} deck file(s) `+
`restored to the inbox — hit "Grade decks" to re-run.`:''));
closeCompany(); refresh(); loadCompanies();
}catch(e){ alert('Delete failed: '+(e.message||e)); }
}
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, progressOpen=false;
async function toggleScorecard(){
if(!currentSlug) return;
const v=document.getElementById('coViewer'), btn=document.getElementById('scBtn');
if(scorecardOpen){ v.innerHTML=''; resetViewerToggles(); 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); }
}
async function toggleProgress(){
if(!currentSlug) return;
const v=document.getElementById('coViewer'), btn=document.getElementById('prBtn');
if(progressOpen){ v.innerHTML=''; resetViewerToggles(); return; }
try{
const url='/api/companies/'+encodeURIComponent(currentSlug)+'/progress.md';
const r=await fetch(url);
const t=await r.text();
setViewer('Progress review', r.ok? mdToHtml(t) : `<pre class="tall">${esc('error: '+t)}</pre>`,
url, `${currentSlug}-PROGRESS.md`);
progressOpen=true; btn.textContent='Hide progress review';
}catch(e){ alert(e); }
}
function setViewer(title,html,dlHref,dlName){
resetViewerToggles();
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>