Files
claudetools/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html
Mike Swanson 0d22704f65 dataforth/testdatadb UI: collapse inspector until a record is selected
Results pane now fills the full width on load (the empty ~500px inspector no longer
reserves space). Inspector column expands when a record opens; Esc closes it back to
full-width results. Same behavior at the 1180px breakpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:48:02 -07:00

527 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>Dataforth · TestDataDB</title>
<style>
:root{
--bg:#f6f8fa; --surface:#ffffff; --border:#e3e8ee; --border-strong:#cbd5e1;
--ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8; --accent:#1e40af; --accent-soft:#eef4ff;
--pass-bg:#dcfce7; --pass-ink:#15803d; --fail-bg:#fee2e2; --fail-ink:#b91c1c;
--hover:#f1f5f9; --sel:#e0e7ff; --desk:#e7ecf1;
--mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--r:6px; --insp:500px;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--sans);font-size:13.5px;color:var(--ink);background:var(--bg);
display:grid;grid-template-rows:auto 1fr;overflow:hidden;-webkit-font-smoothing:antialiased}
button,input,select{font-family:inherit}
:focus-visible{outline:none;box-shadow:0 0 0 2px var(--surface),0 0 0 4px var(--accent)}
/* ---------- header ---------- */
header{display:flex;align-items:center;gap:14px;padding:0 14px;height:50px;
background:var(--surface);border-bottom:1px solid var(--border);position:relative;z-index:30}
.brand{font-weight:700;letter-spacing:-.01em;white-space:nowrap;font-size:14.5px}
.brand b{color:var(--accent);font-weight:700}
.menubtn{display:none;border:1px solid var(--border);background:none;border-radius:var(--r);height:32px;width:34px;cursor:pointer;color:var(--ink-2)}
.omni{flex:1;position:relative;max-width:780px}
.omni .field{display:flex;align-items:center;height:38px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg);padding:0 8px 0 30px;position:relative}
.omni .field:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft);background:#fff}
.omni .ic{position:absolute;left:9px;color:var(--ink-3);font-size:15px;line-height:1}
.omni input{flex:1;height:100%;border:0;background:none;font-size:14px;color:var(--ink);outline:none}
.mode{font-size:10.5px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--accent);
background:var(--accent-soft);border:1px solid #dbeafe;border-radius:5px;padding:2px 7px;cursor:pointer;white-space:nowrap}
.mode:hover{background:#dbeafe}
.history{position:absolute;top:44px;left:0;right:0;background:#fff;border:1px solid var(--border);border-radius:8px;
box-shadow:0 8px 24px rgba(15,23,42,.12);padding:5px;display:none;z-index:40}
.history.show{display:block}
.history .hl{font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3);padding:5px 8px}
.history button{display:flex;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:none;
border-radius:5px;padding:6px 8px;font-size:13px;cursor:pointer;color:var(--ink);font-family:var(--mono)}
.history button:hover{background:var(--hover)}
.badge{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2);white-space:nowrap}
.dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px #22c55e22}
.hbtn{font-size:12.5px;color:var(--ink-2);background:none;border:1px solid var(--border);border-radius:var(--r);height:32px;padding:0 11px;cursor:pointer}
.hbtn:hover{background:var(--hover)}
/* ---------- stats popover ---------- */
.pop{position:absolute;top:46px;right:12px;width:340px;background:#fff;border:1px solid var(--border);
border-radius:10px;box-shadow:0 12px 34px rgba(15,23,42,.16);padding:16px;display:none;z-index:50}
.pop.show{display:block}
.pop h4{margin:0 0 2px;font-size:13px}
.pop .big{font-size:26px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.pop .sub{font-size:11.5px;color:var(--ink-3)}
.pop .bar{display:flex;height:10px;border-radius:5px;overflow:hidden;margin:12px 0 6px;background:#eef2f6}
.pop .bar i{display:block}
.pop .legend{display:flex;gap:14px;font-size:11.5px;color:var(--ink-2)}
.pop .legend span::before{content:"";display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px;vertical-align:middle}
.pop .lt{display:flex;justify-content:space-between;font-size:12px;padding:3px 0;border-top:1px solid var(--border);font-family:var(--mono)}
.pop .lt:first-of-type{border-top:0;margin-top:8px}
/* ---------- layout ---------- */
main{display:grid;grid-template-columns:236px 1fr 0;min-height:0;height:100%}
body.insp-open main{grid-template-columns:236px 1fr var(--insp)}
body:not(.insp-open) .insp{display:none}
.pane{min-height:0;overflow:auto}
/* ---------- filter rail ---------- */
.rail{border-right:1px solid var(--border);background:var(--surface);padding:14px;overflow-y:auto}
.rail h3{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:18px 0 8px;font-weight:600}
.rail h3:first-child{margin-top:0}
.presets{display:flex;flex-direction:column;gap:5px}
.preset{display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12.5px;height:31px;padding:0 9px;
border:1px solid var(--border);border-radius:var(--r);background:#fff;color:var(--ink);cursor:pointer;transition:background .08s}
.preset:hover:not(:disabled){background:var(--accent-soft);border-color:#bfdbfe}
.preset .pi{width:14px;text-align:center;color:var(--accent);font-size:12px}
.preset:disabled{opacity:.45;cursor:not-allowed}
.famrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}
.fam{font-family:var(--mono);font-size:11.5px;height:26px;padding:0 9px;border:1px solid var(--border);border-radius:5px;background:#fff;color:var(--ink-2);cursor:pointer}
.fam:hover{background:var(--accent-soft);border-color:#bfdbfe;color:var(--accent)}
.rail label{display:block;font-size:11px;color:var(--ink-3);margin:8px 0 3px}
.rail input,.rail select{width:100%;height:31px;font-size:13px;padding:0 8px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;color:var(--ink)}
.rail input:focus,.rail select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.chips{display:flex;gap:5px;margin-top:7px}
.chip{font-size:11px;height:24px;padding:0 8px;border:1px solid var(--border);border-radius:20px;background:#fff;color:var(--ink-2);cursor:pointer}
.chip:hover{background:var(--hover)}
.reset{margin-top:18px;width:100%;height:31px;font-size:12px;background:none;border:1px solid var(--border);border-radius:var(--r);color:var(--ink-2);cursor:pointer}
.reset:hover{background:var(--hover)}
/* ---------- results ---------- */
.results{display:grid;grid-template-rows:auto auto 1fr auto;min-height:0;background:var(--surface)}
.rtoolbar{display:flex;align-items:center;gap:12px;padding:8px 12px;border-bottom:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.rtoolbar .count{font-weight:700;color:var(--ink);font-variant-numeric:tabular-nums}
.rtoolbar .sp{flex:1}
.rtoolbar a{font-size:12px;color:var(--accent);text-decoration:none}
.rtoolbar a:hover{text-decoration:underline}
.rtoolbar select{font-size:12px;color:var(--ink-2);border:1px solid var(--border);border-radius:5px;height:27px;background:#fff}
.selbar{display:none;align-items:center;gap:10px;padding:6px 12px;background:var(--accent-soft);border-bottom:1px solid #dbeafe;font-size:12px;color:var(--accent)}
.selbar.show{display:flex}
.selbar button{font-size:12px;height:26px;padding:0 9px;border:1px solid #bfdbfe;border-radius:var(--r);background:#fff;color:var(--accent);cursor:pointer}
.selbar button:disabled{opacity:.5;cursor:not-allowed}
.twrap{overflow:auto;min-height:0}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{position:sticky;top:0;background:#f7f9fb;border-bottom:1px solid var(--border-strong);text-align:left;
font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);font-weight:600;padding:7px 10px;white-space:nowrap;z-index:1;user-select:none}
thead th.s{cursor:pointer}
thead th.s:hover{color:var(--ink-2)}
thead th .arr{color:var(--accent);font-size:9px;margin-left:3px}
thead th.ck{width:30px;padding-left:12px}
tbody td{padding:5px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
tbody td.ck{padding-left:12px}
tbody tr{cursor:pointer}
tbody tr:hover{background:var(--hover)}
tbody tr.sel{background:var(--sel)}
tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--accent)}
.mono{font-family:var(--mono);font-size:12.5px;font-variant-numeric:tabular-nums}
.pill{display:inline-block;font-size:10.5px;font-weight:700;padding:1px 8px;border-radius:4px;font-family:var(--mono)}
.pill.PASS{background:var(--pass-bg);color:var(--pass-ink)}
.pill.FAIL{background:var(--fail-bg);color:var(--fail-ink)}
.tag{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;font-family:var(--sans)}
.tag::before{content:"";width:7px;height:7px;border-radius:50%}
.tag.pub{background:var(--pass-bg);color:var(--pass-ink)} .tag.pub::before{background:var(--pass-ink)}
.tag.unpub{background:#fef3c7;color:#92400e} .tag.unpub::before{background:#d97706;background:none;box-shadow:inset 0 0 0 1.5px #d97706}
.web{font-size:13px}
.pager{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.pager button{font-size:12px;height:28px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
.pager button:disabled{opacity:.4;cursor:default}
.pager .hint{color:var(--ink-3)}
.state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:40px 20px;color:var(--ink-3);text-align:center}
.state .em{font-size:26px;opacity:.6}
.state.err{color:var(--fail-ink)}
.state button{margin-top:4px;font-size:12px;height:28px;padding:0 12px;border:1px solid var(--border-strong);border-radius:var(--r);background:#fff;cursor:pointer;color:var(--ink)}
.skel{height:13px;margin:8px 10px;border-radius:4px;background:linear-gradient(90deg,#eef2f6,#e2e8f0,#eef2f6);background-size:200% 100%;animation:sh 1.1s infinite}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* ---------- inspector ---------- */
.insp{position:relative;border-left:1px solid var(--border);background:var(--surface);display:grid;grid-template-rows:auto auto 1fr;min-height:0}
.resizer{position:absolute;left:-3px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:5}
.resizer:hover,.resizer.drag{background:linear-gradient(90deg,transparent,var(--accent) 45%,var(--accent) 55%,transparent)}
body.resizing{cursor:col-resize;user-select:none}
.meta{padding:12px 14px;border-bottom:1px solid var(--border)}
.meta .sn{font-family:var(--mono);font-size:16px;font-weight:700;letter-spacing:-.01em}
.meta dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:10px 0 0;font-size:12.5px}
.meta dt{color:var(--ink-3)}
.meta dd{margin:0;font-family:var(--mono)}
.acts{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border)}
.acts a,.acts button{font-size:12px;height:29px;padding:0 11px;border:1px solid var(--border-strong);border-radius:var(--r);
background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer}
.acts a:hover,.acts button:hover:not(:disabled){background:var(--hover)}
.acts .pri{background:var(--accent);border-color:var(--accent);color:#fff}
.acts .pri:hover{background:#1c3aa9}
.acts button:disabled{opacity:.5;cursor:not-allowed}
.viewer{background:var(--desk);overflow:auto;padding:14px;position:relative}
.viewer iframe{display:block;width:100%;min-height:100%;border:1px solid var(--border);border-radius:5px;background:#fff;
box-shadow:0 1px 5px rgba(15,23,42,.10)}
kbd{font-family:var(--mono);font-size:11px;background:#f1f5f9;border:1px solid var(--border-strong);border-bottom-width:2px;border-radius:4px;padding:0 5px;color:var(--ink-2)}
/* ---------- responsive ---------- */
@media (max-width:1180px){
main{grid-template-columns:1fr 0}
body.insp-open main{grid-template-columns:1fr var(--insp)}
.rail{position:absolute;top:50px;bottom:0;left:0;width:250px;z-index:20;box-shadow:6px 0 20px rgba(15,23,42,.12);transform:translateX(-104%);transition:transform .18s}
body.rail-open .rail{transform:none}
.menubtn{display:inline-flex;align-items:center;justify-content:center}
}
@media (max-width:820px){ main{grid-template-columns:1fr} .insp{position:absolute;top:50px;bottom:0;right:0;width:min(94vw,520px);z-index:18;box-shadow:-8px 0 24px rgba(15,23,42,.14)} .insp:not(.open){display:none} .resizer{display:none} }
</style>
</head>
<body>
<header>
<button class="menubtn" id="menu" title="Filters"></button>
<div class="brand">Dataforth <b>·</b> TestDataDB</div>
<div class="omni">
<div class="field">
<span class="ic">🔍</span>
<input id="omni" autocomplete="off" spellcheck="false" placeholder="Search serial, model, or text… ( / to focus, s: m: t: to force )">
<button class="mode" id="mode" title="Click to force the search mode">auto</button>
</div>
<div class="history" id="history"></div>
</div>
<div class="badge"><span class="dot"></span><span id="ingest">loading…</span></div>
<button class="hbtn" id="statsBtn">Stats ▾</button>
<div class="pop" id="statsPop"></div>
</header>
<main>
<aside class="rail pane" id="rail">
<h3>Quick searches</h3>
<div class="presets" id="presets"></div>
<div class="famrow" id="families"></div>
<h3>Test date</h3>
<div class="chips" id="dateChips"></div>
<label>From</label><input type="date" id="fFrom">
<label>To</label><input type="date" id="fTo">
<h3>Model</h3>
<input id="fModel" list="modelList" placeholder="any model" autocomplete="off">
<datalist id="modelList"></datalist>
<h3>Station</h3>
<select id="fStation"><option value="">any station</option></select>
<h3>Log type</h3>
<select id="fLog"><option value="">any log</option></select>
<button class="reset" id="reset">Reset all</button>
</aside>
<section class="results">
<div class="rtoolbar">
<span class="count" id="count"></span><span>results</span>
<span class="sp"></span>
<label>rows <select id="pageSize"><option>25</option><option selected>50</option><option>100</option></select></label>
<a id="exportCsv" href="#" title="Export current filtered results">Export CSV ↓</a>
</div>
<div class="selbar" id="selbar">
<span id="selcount">0 selected</span>
<button id="copySel">Copy serials</button>
<button id="repushSel" title="Publish / re-publish the selected serials to the public website">Re-push ▴</button>
<span class="sp" style="flex:1"></span>
<button id="selclear" style="border:0;background:none">clear</button>
</div>
<div class="twrap" id="twrap">
<table>
<thead><tr>
<th class="ck"><input type="checkbox" id="ckAll" title="select page"></th>
<th class="s" data-s="serial_number">Serial</th>
<th class="s" data-s="model_number">Model</th>
<th class="s" data-s="test_date">Test Date</th>
<th>Stn</th><th>Log</th>
<th class="s" data-s="overall_result">Result</th>
<th title="Published to public website">Web</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div id="state"></div>
</div>
<div class="pager">
<button id="prev"> Prev</button>
<span id="pginfo">Page 1</span>
<button id="next">Next </button>
<span class="sp" style="flex:1"></span>
<span class="hint"><kbd>/</kbd> search · <kbd></kbd><kbd></kbd> rows · <kbd></kbd> open · <kbd>Esc</kbd> back</span>
</div>
</section>
<aside class="insp" id="insp">
<div class="resizer" id="resizer" title="Drag to resize"></div>
<div class="meta" id="meta"></div>
<div class="acts" id="acts" style="display:none"></div>
<div class="viewer" id="viewer"></div>
</aside>
</main>
<script>
const API='';
const $=id=>document.getElementById(id);
const state={q:'',serial:'',model:'',result:'',station:'',logtype:'',from:'',to:'',size:50,page:0,total:0,
selected:null,rows:[],sort:'',dir:'desc',checks:new Set(),force:'',webStatus:''};
let timer=null, certTimer=null;
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const fmtDate=d=>d?String(d).slice(0,10):'';
const isoD=d=>d.toISOString().slice(0,10);
/* ---------- omni routing (with s:/m:/t: overrides + encoded serials) ---------- */
function routeOmni(raw){
state.q=state.serial=state.model=''; let v=(raw||'').trim(), mode=state.force;
const pm=v.match(/^([smt]):\s*(.*)$/i); if(pm){ mode={s:'serial',m:'model',t:'text'}[pm[1].toLowerCase()]; v=pm[2]; }
if(!v){ $('mode').textContent=state.force||'auto'; return; }
if(!mode){
if(/\s/.test(v)) mode='text';
else if(/^(scm|dsc|[57]b|8b|pwr|vas)/i.test(v)) mode='model';
else mode='serial'; // numeric AND encoded (A243-1) serials land here
}
if(mode==='model') state.model=v; else if(mode==='text') state.q=v; else state.serial=v;
$('mode').textContent=mode;
}
$('mode').onclick=()=>{ const seq=['auto','serial','model','text']; state.force=seq[(seq.indexOf(state.force||'auto')+1)%4]; if(state.force==='auto')state.force=''; routeOmni($('omni').value); state.page=0; search(); };
/* ---------- query / search ---------- */
function params(forExport){
const p=new URLSearchParams();
for(const k of ['q','serial','model','result','station','logtype','from','to']) if(state[k]) p.set(k,state[k]);
if(state.sort){ p.set('sort',state.sort); p.set('dir',state.dir); } // honored by /api/search (whitelisted)
if(state.webStatus) p.set('web_status',state.webStatus); // on=published, off=not yet published
if(!forExport){ p.set('limit',state.size); p.set('offset',state.page*state.size); }
return p;
}
function syncUrl(){ const p=params(); if(state.selected)p.set('selected',state.selected); history.replaceState(null,'','?'+p.toString()); }
function showState(html,cls){ $('state').className='state '+(cls||''); $('state').innerHTML=html; $('rows').innerHTML=''; }
async function search(){
$('state').innerHTML=''; $('rows').innerHTML=Array.from({length:9}).map(()=>'<tr><td colspan="8"><div class="skel"></div></td></tr>').join('');
try{
const r=await fetch(API+'/api/search?'+params().toString());
if(!r.ok) throw new Error('HTTP '+r.status);
const d=await r.json(); state.rows=d.records||[]; state.total=d.total||0; renderRows();
}catch(e){ showState('<div class="em">⚠</div>Search failed — '+esc(e.message)+'<button onclick="search()">Retry</button>','err'); $('count').textContent='—'; }
$('exportCsv').href=API+'/api/export?'+params(true).toString(); syncUrl();
}
function renderRows(){
$('count').textContent=state.total.toLocaleString();
const pages=Math.max(1,Math.ceil(state.total/state.size));
$('pginfo').textContent='Page '+(state.page+1)+' of '+pages.toLocaleString();
$('prev').disabled=state.page<=0; $('next').disabled=state.page>=pages-1;
if(!state.rows.length){ showState('<div class="em">∅</div>No records match.'+(activeFilters()?'<button onclick="document.getElementById(\'reset\').click()">Clear filters</button>':'')); return; }
$('state').innerHTML='';
$('rows').innerHTML=state.rows.map(r=>{
const ck=state.checks.has(String(r.id))?'checked':'';
return `<tr data-id="${r.id}">
<td class="ck"><input type="checkbox" data-ck="${r.id}" ${ck}></td>
<td class="mono" style="font-weight:600">${esc(r.serial_number)}</td>
<td class="mono">${esc(r.model_number)}</td>
<td class="mono">${fmtDate(r.test_date)}</td>
<td class="mono">${esc((r.test_station||'').replace(/^TS-/,''))}</td>
<td class="mono" style="color:var(--ink-3)">${esc((r.log_type||'').replace(/LOG$/,''))}</td>
<td><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></td>
<td class="web" style="text-align:center;color:${r.api_uploaded_at?'var(--pass-ink)':'var(--ink-3)'}" title="${r.api_uploaded_at?'published':'not published'}">${r.api_uploaded_at?'●':'○'}</td>
</tr>`;}).join('');
[...$('rows').children].forEach(tr=>{
tr.onclick=e=>{ if(e.target.dataset.ck!==undefined)return; select(tr.dataset.id); };
const cb=tr.querySelector('[data-ck]'); cb.onclick=e=>{e.stopPropagation(); const id=cb.dataset.ck; cb.checked?state.checks.add(id):state.checks.delete(id); updateSel();};
});
updateSortHeads();
if(state.serial&&state.rows[0]) select(state.rows[0].id,true);
else if(state.selected){ const tr=[...$('rows').children].find(t=>t.dataset.id==state.selected); if(tr)tr.classList.add('sel'); }
}
function activeFilters(){ return ['serial','model','q','result','station','logtype','from','to'].some(k=>state[k]); }
/* ---------- selection (instant) + cert (lazy) ---------- */
function select(id,auto){
state.selected=id;
[...$('rows').children].forEach(t=>t.classList.toggle('sel',t.dataset.id==id));
const r=state.rows.find(x=>x.id==id); if(!r) return;
$('meta').innerHTML=`<div class="sn">${esc(r.serial_number)}</div>
<dl><dt>Model</dt><dd>${esc(r.model_number)}</dd>
<dt>Date</dt><dd>${fmtDate(r.test_date)}</dd>
<dt>Station</dt><dd>${esc(r.test_station)}</dd>
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${esc(r.overall_result)}</span></dd>
<dt>Log</dt><dd>${esc(r.log_type)}</dd>
${r.work_order?`<dt>WO</dt><dd>${esc(r.work_order)}</dd>`:''}
<dt>Web</dt><dd>${r.api_uploaded_at?`<span class="tag pub">Published</span> <span style="color:var(--ink-3)">${fmtDate(r.api_uploaded_at)}</span>`:'<span class="tag unpub">Not published</span>'}</dd></dl>`;
const ds=API+'/api/datasheet/'+id;
$('acts').style.display='flex';
$('acts').innerHTML=`<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
<button onclick="printCert()">Print</button>
<a href="${ds}?format=txt" download="${esc(r.serial_number)}.txt">TXT</a>
<a href="${ds}?format=html" download="${esc(r.serial_number)}.html">HTML</a>
<button onclick="pushWeb('${id}',this)" title="Publish / re-publish this cert to the public website">Push to Web ▴</button>`;
$('insp').classList.add('open'); document.body.classList.add('insp-open');
// lazy-load the certificate so fast arrow/typing stays snappy
$('viewer').innerHTML='<div class="state"><div class="skel" style="width:60%"></div></div>';
clearTimeout(certTimer);
certTimer=setTimeout(()=>loadCert(ds),auto?240:60);
syncUrl();
}
function loadCert(ds){
$('viewer').innerHTML='<iframe id="dsframe" title="datasheet"></iframe>';
const f=$('dsframe'); f.onload=()=>{styleCert();fitCert();setTimeout(fitCert,120);setTimeout(fitCert,350);}; f.src=ds+'?format=html';
}
function styleCert(){ const f=$('dsframe'); try{ const doc=f.contentDocument; if(!doc)return;
if(!doc.getElementById('_inj')){ const s=doc.createElement('style'); s.id='_inj';
s.textContent='html,body{background:#fff!important;margin:0!important}body{padding:16px 20px!important;color:#0f172a}pre{margin:0;font-family:'+getComputedStyle(document.body).getPropertyValue('--mono')+';font-size:12.5px;line-height:1.32}';
(doc.head||doc.documentElement).appendChild(s); }
}catch(e){} }
function fitCert(){ const f=$('dsframe'); if(!f)return; try{ const doc=f.contentDocument; if(!doc||!doc.body)return;
const b=doc.body, root=doc.documentElement;
// measure natural content width (transform doesn't reflow, so text never rewraps)
b.style.transform='none'; b.style.width='max-content'; b.style.transformOrigin='0 0';
root.style.overflow='hidden';
const nat=Math.max(b.scrollWidth,root.scrollWidth), av=f.clientWidth-12; // widest line + right margin
if(!nat){ return; } // not laid out yet — the retry will catch it
const k=nat>av ? Math.max(.4, av/nat) : 1;
b.style.transform='scale('+k+')';
f.style.height=Math.ceil(b.scrollHeight*k+2)+'px'; // size the frame to the scaled cert, no v-scroll-in-frame
}catch(e){} }
function printCert(){ const f=$('dsframe'); if(f&&f.contentWindow){f.contentWindow.focus();f.contentWindow.print();} }
window.addEventListener('resize',()=>{fitCert();});
/* ---------- multi-select ---------- */
function updateSel(){ const n=state.checks.size; $('selbar').classList.toggle('show',n>0); $('selcount').textContent=n+' selected';
$('ckAll').checked=n>0&&state.rows.every(r=>state.checks.has(String(r.id))); }
$('ckAll').onclick=e=>{ state.rows.forEach(r=>{ e.target.checked?state.checks.add(String(r.id)):state.checks.delete(String(r.id)); });
[...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=e.target.checked); updateSel(); };
$('copySel').onclick=()=>{ const sns=state.rows.filter(r=>state.checks.has(String(r.id))).map(r=>r.serial_number).join('\n');
navigator.clipboard&&navigator.clipboard.writeText(sns); $('copySel').textContent='Copied ✓'; setTimeout(()=>$('copySel').textContent='Copy serials',1200); };
$('selclear').onclick=()=>{ state.checks.clear(); [...$('rows').querySelectorAll('[data-ck]')].forEach(c=>c.checked=false); $('ckAll').checked=false; updateSel(); };
/* ---------- publish to public website (POST /api/upload, idempotent) ---------- */
async function doUpload(payload){
const r=await fetch(API+'/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json().catch(()=>({})); if(!r.ok) throw new Error(d.error||('HTTP '+r.status)); return d;
}
async function pushWeb(id,btn){
const r=state.rows.find(x=>x.id==id); const sn=r?r.serial_number:id;
if(!confirm('Publish '+sn+' to the public Dataforth website now?')) return;
const t=btn.textContent; btn.disabled=true; btn.textContent='Publishing…';
try{ const d=await doUpload({ids:[+id]});
btn.textContent=d.errors?('✕ '+d.errors+' err'):(d.skipped&&!(d.created+d.updated+d.unchanged)?'skipped':'Published ✓');
setTimeout(search,500); // refresh WEB status from the DB
}catch(e){ btn.textContent='✕ '+e.message.slice(0,16); btn.disabled=false; }
}
async function pushSelected(){
const ids=[...state.checks].map(Number); if(!ids.length) return;
if(!confirm('Publish '+ids.length+' selected serial(s) to the public Dataforth website now?')) return;
const b=$('repushSel'),t=b.textContent; b.disabled=true; b.textContent='Publishing…';
try{ const d=await doUpload({ids});
b.textContent='✓ '+((d.created||0)+(d.updated||0))+' pushed'+(d.skipped?(' · '+d.skipped+' skip'):'');
setTimeout(()=>{b.textContent=t;b.disabled=false;search();},1400);
}catch(e){ b.textContent='✕ failed'; b.disabled=false; alert('Push failed: '+e.message); }
}
$('repushSel').onclick=pushSelected;
/* ---------- sort ---------- */
function updateSortHeads(){ [...document.querySelectorAll('thead th.s')].forEach(th=>{
const on=th.dataset.s===state.sort; th.querySelector('.arr')?.remove();
if(on){ const a=document.createElement('span'); a.className='arr'; a.textContent=state.dir==='asc'?'▲':'▼'; th.appendChild(a);} }); }
document.querySelectorAll('thead th.s').forEach(th=>th.onclick=()=>{
const f=th.dataset.s; if(state.sort===f) state.dir=state.dir==='asc'?'desc':'asc'; else {state.sort=f;state.dir=f==='test_date'?'desc':'asc';}
state.page=0; search(); });
/* ---------- filters / paging ---------- */
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;state.checks.clear();updateSel();search();},280); }
$('omni').addEventListener('input',e=>{routeOmni(e.target.value);debouncedSearch();});
$('omni').addEventListener('focus',()=>{ if(!$('omni').value) showHistory(true); });
$('omni').addEventListener('blur',()=>setTimeout(()=>showHistory(false),150));
$('fFrom').onchange=e=>{state.from=e.target.value;state.page=0;search();};
$('fTo').onchange=e=>{state.to=e.target.value;state.page=0;search();};
$('fModel').onchange=e=>{state.model=e.target.value;state.page=0;search();};
$('fStation').onchange=e=>{state.station=e.target.value;state.page=0;search();};
$('fLog').onchange=e=>{state.logtype=e.target.value;state.page=0;search();};
$('pageSize').onchange=e=>{state.size=+e.target.value;state.page=0;search();};
$('prev').onclick=()=>{if(state.page>0){state.page--;search();$('twrap').scrollTop=0;}};
$('next').onclick=()=>{state.page++;search();$('twrap').scrollTop=0;};
$('reset').onclick=()=>{ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
state.sort='';state.webStatus='';state.checks.clear();updateSel(); $('omni').value='';$('mode').textContent=state.force||'auto';
$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; state.page=0; search(); };
$('menu').onclick=()=>document.body.classList.toggle('rail-open');
/* ---------- presets ---------- */
function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]=''); state.webStatus='';state.sort='';
$('omni').value='';$('mode').textContent=state.force||'auto';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; }
function applyPreset(fn){ clearAll(); fn(); state.page=0; state.checks.clear(); updateSel(); document.body.classList.remove('rail-open'); search(); }
const PRESETS=[
{ic:'◷',label:'Recent',fn:()=>{}},
{ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}},
{ic:'•',label:'Today',fn:()=>{const t=isoD(new Date());state.from=t;state.to=t;}},
{ic:'7',label:'Last 7 days',fn:()=>{const d=new Date();d.setDate(d.getDate()-7);state.from=isoD(d);}},
{ic:'▴',label:'Latest uploads',fn:()=>{state.webStatus='on';state.sort='api_uploaded_at';state.dir='desc';}},
{ic:'○',label:'Not yet published',fn:()=>{state.webStatus='off';}},
];
const SOON=[
{label:'Retested units',why:'needs a retest flag in the pipeline (next)'},
];
function renderPresets(){
$('presets').innerHTML='';
PRESETS.forEach(p=>{const b=document.createElement('button');b.className='preset';b.innerHTML='<span class="pi">'+p.ic+'</span>'+p.label;b.onclick=()=>applyPreset(p.fn);$('presets').appendChild(b);});
SOON.forEach(s=>{const b=document.createElement('button');b.className='preset';b.disabled=true;b.title=s.why;b.innerHTML='<span class="pi">…</span>'+s.label;$('presets').appendChild(b);});
$('families').innerHTML='';
['DSCA','8B','5B','7B','SCM5B'].forEach(m=>{const b=document.createElement('button');b.className='fam';b.textContent=m;b.onclick=()=>applyPreset(()=>{state.model=m;$('fModel').value=m;});$('families').appendChild(b);});
$('dateChips').innerHTML='';
[['Today',0],['7d',7],['30d',30],['Year',-1]].forEach(([lbl,n])=>{const b=document.createElement('button');b.className='chip';b.textContent=lbl;
b.onclick=()=>{const d=new Date(); if(n===-1)state.from=d.getFullYear()+'-01-01'; else if(n===0){state.from=state.to=isoD(d);} else {d.setDate(d.getDate()-n);state.from=isoD(d);state.to='';}
$('fFrom').value=state.from;$('fTo').value=state.to||''; state.page=0;search();};
$('dateChips').appendChild(b);});
}
/* ---------- recent searches ---------- */
function recents(){ try{return JSON.parse(localStorage.getItem('tdb_recent')||'[]')}catch(e){return[]} }
function pushRecent(q){ if(!q)return; let r=recents().filter(x=>x!==q); r.unshift(q); r=r.slice(0,8); localStorage.setItem('tdb_recent',JSON.stringify(r)); }
function showHistory(on){ const r=recents(); if(!on||!r.length){$('history').classList.remove('show');return;}
$('history').innerHTML='<div class="hl">Recent</div>'+r.map(q=>`<button data-q="${esc(q)}">↩ ${esc(q)}</button>`).join('');
[...$('history').querySelectorAll('button')].forEach(b=>b.onmousedown=()=>{$('omni').value=b.dataset.q;routeOmni(b.dataset.q);state.page=0;search();showHistory(false);});
$('history').classList.add('show'); }
/* ---------- resizer ---------- */
(function(){const rz=$('resizer');let on=false;
rz.addEventListener('mousedown',e=>{on=true;rz.classList.add('drag');document.body.classList.add('resizing');e.preventDefault();});
window.addEventListener('mousemove',e=>{if(!on)return;let w=Math.max(360,Math.min(window.innerWidth-540,window.innerWidth-e.clientX));document.documentElement.style.setProperty('--insp',w+'px');});
window.addEventListener('mouseup',()=>{if(on){on=false;rz.classList.remove('drag');document.body.classList.remove('resizing');fitCert();}});
})();
/* ---------- stats popover ---------- */
let statsLoaded=false;
$('statsBtn').onclick=async()=>{ const p=$('statsPop'); if(p.classList.contains('show')){p.classList.remove('show');return;}
p.classList.add('show'); if(statsLoaded)return;
try{ const s=await (await fetch(API+'/api/stats')).json(); statsLoaded=true;
const res=s.by_result||[]; const pass=(res.find(r=>r.overall_result==='PASS')||{}).count||0; const fail=(res.find(r=>r.overall_result==='FAIL')||{}).count||0; const tot=pass+fail||1;
p.innerHTML=`<h4>Database</h4><div class="big">${(s.total_records||0).toLocaleString()}</div><div class="sub">records · ${fmtDate(s.date_range&&s.date_range.oldest)}${fmtDate(s.date_range&&s.date_range.newest)}</div>
<div class="bar"><i style="width:${pass/tot*100}%;background:var(--pass-ink)"></i><i style="width:${fail/tot*100}%;background:var(--fail-ink)"></i></div>
<div class="legend"><span style="--c:var(--pass-ink)">PASS ${pass.toLocaleString()}</span><span>FAIL ${fail.toLocaleString()}</span></div>
<div style="margin-top:6px">${(s.by_log_type||[]).slice(0,7).map(l=>`<div class="lt"><span>${esc(l.log_type)}</span><span>${l.count.toLocaleString()}</span></div>`).join('')}</div>`;
p.querySelectorAll('.legend span')[0].style.cssText='--c:var(--pass-ink)';
}catch(e){ p.innerHTML='<div class="sub">stats unavailable</div>'; } };
document.addEventListener('click',e=>{ if(!$('statsPop').contains(e.target)&&e.target!==$('statsBtn')) $('statsPop').classList.remove('show'); });
/* ---------- keyboard ---------- */
document.addEventListener('keydown',e=>{
if(e.key==='/'&&document.activeElement!==$('omni')){e.preventDefault();$('omni').focus();$('omni').select();return;}
if(e.key==='Escape'){ if($('statsPop').classList.contains('show')){$('statsPop').classList.remove('show');return;}
if($('insp').classList.contains('open')){$('insp').classList.remove('open');document.body.classList.remove('insp-open');state.selected=null;[...$('rows').children].forEach(t=>t.classList.remove('sel'));syncUrl();return;}
if(document.activeElement===$('omni')&&$('omni').value){$('omni').value='';routeOmni('');debouncedSearch();}else $('omni').focus(); return;}
if((e.key==='ArrowDown'||e.key==='ArrowUp')&&state.rows.length){
e.preventDefault(); let i=state.rows.findIndex(r=>r.id==state.selected);
i=e.key==='ArrowDown'?Math.min(state.rows.length-1,i+1):Math.max(0,i-1);
if(state.rows[i]){select(state.rows[i].id);const tr=[...$('rows').children][i];if(tr)tr.scrollIntoView({block:'nearest'});}}
if(e.key==='Enter'){ if(document.activeElement===$('omni')) pushRecent($('omni').value.trim());
if(state.selected) window.open(API+'/api/datasheet/'+state.selected+'?format=html','_blank'); }
});
/* ---------- bootstrap ---------- */
async function boot(){
const u=new URLSearchParams(location.search);
if(u.get('serial')){state.serial=u.get('serial');$('omni').value=state.serial;routeOmni(state.serial);}
else if(u.get('model')){state.model=u.get('model');$('omni').value=state.model;routeOmni(state.model);}
else if(u.get('q')){state.q=u.get('q');$('omni').value=state.q;routeOmni(state.q);}
for(const k of ['result','station','logtype','from','to']) if(u.get(k))state[k]=u.get(k);
if(u.get('sort')){state.sort=u.get('sort');state.dir=u.get('dir')||'desc';}
state.selected=u.get('selected');
renderPresets();
try{ const f=await (await fetch(API+'/api/filters')).json();
$('modelList').innerHTML=(f.models||[]).map(m=>`<option value="${esc(m.model_number)}">${esc(m.model_number)} (${m.count})</option>`).join('');
$('fStation').innerHTML='<option value="">any station</option>'+(f.stations||[]).map(s=>`<option>${esc(s)}</option>`).join('');
$('fLog').innerHTML='<option value="">any log</option>'+(f.log_types||[]).map(l=>`<option>${esc(l)}</option>`).join('');
}catch(e){}
try{ const s=await (await fetch(API+'/api/stats')).json();
$('ingest').textContent=(s.total_records||0).toLocaleString()+' records · newest '+fmtDate(s.date_range&&s.date_range.newest);
}catch(e){ $('ingest').textContent='stats unavailable'; }
if(state.from)$('fFrom').value=state.from; if(state.to)$('fTo').value=state.to;
if(state.station)$('fStation').value=state.station; if(state.logtype)$('fLog').value=state.logtype; if(state.model)$('fModel').value=state.model;
$('omni').focus(); search();
}
boot();
</script>
</body>
</html>