Files
boardroom-map/orchestrator/templates/index.html
T
Jonathan KirkwoodandClaude Fable 5 1d1074b625 Fix 9 seam-review findings
- Pinned target's profitability flag now overrides the extractor's bucket guess
- Extractor/target periods canonicalized so ledger forecast chaining matches
- autoRunOnDrop no longer error-loops on ungradeable inbox content; failed
  batches count as seen
- Ship 2 default graders (pipeline requires >=2 valid reports per deck)
- UI styles 'failed' deck chips as errors; .markdown discoverable
- Unknown adjudicator model disables adjudication loudly instead of silently
- Reserved rids extractor/adjudicator; teardown also clears bm-grader-* containers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:26:41 -05:00

516 lines
27 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}
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}
</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=>({'&':'&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>`; }
// 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));
currentSlug=slug;
renderDetail(d);
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; document.getElementById('companyCard').style.display='none'; }
function renderDetail(d){
const co=d.company||{}, recs=d.records||[];
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></td></tr>`).join('')+
'</tbody></table>';
document.getElementById('coViewer').innerHTML='';
document.getElementById('scBtn').textContent='View SCORECARD.md';
}
async function viewDeckReport(deckId){
if(!currentSlug) return;
const base='/api/companies/'+encodeURIComponent(currentSlug)+'/decks/'+encodeURIComponent(deckId);
try{
const r=await fetch(base+'/report');
const t=await r.text();
setViewer('Deck report — '+deckId, `<pre class="tall">${esc(r.ok?t:('error: '+t))}</pre>`);
}catch(e){ alert(e); }
}
async function viewDeckJson(deckId){
if(!currentSlug) return;
try{
const d=await getJSON('/api/companies/'+encodeURIComponent(currentSlug)+
'/decks/'+encodeURIComponent(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>`);
}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 r=await fetch('/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard');
const t=await r.text();
setViewer('SCORECARD.md', `<pre class="tall">${esc(r.ok?t:('error: '+t))}</pre>`);
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
}catch(e){ alert(e); }
}
function setViewer(title,html){
scorecardOpen=false;
document.getElementById('scBtn').textContent='View SCORECARD.md';
document.getElementById('coViewer').innerHTML=
`<h3 class="sub" style="display:flex;align-items:center">${esc(title)}`+
`<button class="mini" 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>