Files
claudetools/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html
Mike Swanson 1c9f2d101d dataforth/testdatadb UI: clear, persistent push feedback (toasts)
The push handlers set the button to 'skipped' then immediately ran search(), which
re-rendered the inspector and wiped the text — so a skipped publish flashed and
vanished (looked like nothing happened). Replace with persistent toasts that state the
outcome explicitly: Published / already up-to-date / Push failed / and for a skip,
'<model> isn't renderable yet, so nothing was sent.' Only refresh the row on an actual
publish so the message isn't clobbered. Same for the multi-select Re-push summary.

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

542 lines
38 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)}
/* ---------- toast ---------- */
.toast{position:fixed;right:18px;bottom:18px;max-width:400px;z-index:60;display:flex;flex-direction:column;gap:8px}
.toast .t{background:#fff;border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:8px;box-shadow:0 8px 28px rgba(15,23,42,.18);padding:11px 13px 12px;font-size:12.5px;color:var(--ink-2);cursor:pointer;animation:tin .16s}
.toast .t.ok{border-left-color:var(--pass-ink)} .toast .t.warn{border-left-color:#d97706} .toast .t.err{border-left-color:var(--fail-ink)}
.toast .t b{display:block;margin-bottom:2px;color:var(--ink);font-size:13px}
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
/* ---------- 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>
<div class="toast" id="toast"></div>
<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;
}
function showToast(title,msg,type){ const c=$('toast'); if(!c)return; const el=document.createElement('div');
el.className='t '+(type||''); el.innerHTML='<b>'+esc(title)+'</b>'+(msg?esc(msg):''); el.onclick=()=>el.remove();
c.appendChild(el); setTimeout(()=>{el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),320);}, type==='warn'||type==='err'?9000:5000); }
async function pushWeb(id,btn){
const r=state.rows.find(x=>x.id==id); const sn=r?r.serial_number:id; const mdl=r?r.model_number:'';
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]}); const pub=(d.created||0)+(d.updated||0);
if(pub>0){ showToast('Published — '+sn,'Sent to the public website.','ok'); setTimeout(search,400); }
else if(d.errors){ showToast('Push failed — '+sn, d.errors+' error(s) from the website API.','err'); }
else if(d.skipped){ showToast('Not published — '+sn, mdl+' isnt renderable yet, so nothing was sent. This model still needs the render fix before it can publish.','warn'); }
else if(d.unchanged){ showToast(sn+' already up to date','Already on the website — nothing changed.','ok'); }
else { showToast('No change — '+sn, 'Nothing was published.','warn'); }
}catch(e){ showToast('Push failed — '+sn, e.message,'err'); }
btn.disabled=false; btn.textContent=t;
}
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}); const pub=(d.created||0)+(d.updated||0);
showToast('Push complete', pub+' published · '+(d.unchanged||0)+' unchanged · '+(d.skipped||0)+' skipped (not renderable)'+(d.errors?(' · '+d.errors+' error'):'')+'.', d.errors?'err':(pub||d.unchanged?'ok':'warn'));
if(pub) search();
}catch(e){ showToast('Push failed', e.message,'err'); }
b.disabled=false; b.textContent=t;
}
$('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>