dataforth/testdatadb: multi-AI UI redesign — clickable single-file prototype

Vanilla single-file (no build/CDN) command-center redesign of the testdatadb search
UI: omni-search with serial/model/text routing + auto-select fast path, dense
monospace results table with PASS/FAIL pills + web-published indicator, persistent
split-pane datasheet inspector (iframe to /api/datasheet), left filter rail
(result/date/model/station/log), server pagination, CSV export, URL state, keyboard
nav (/ ↑↓ ↵ Esc), clinical light theme. Hits the existing API; deployed to AD2 as
public/index.redesign.html (preview at :3000/index.redesign.html). Synthesized from
Grok + Gemini concepts (both converged on this command-center design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 06:36:20 -07:00
parent bfe375044d
commit 55407e8601

View File

@@ -0,0 +1,301 @@
<!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:#f8fafc; --surface:#ffffff; --border:#e2e8f0; --border-strong:#cbd5e1;
--ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8; --accent:#1e40af; --accent-soft:#eff6ff;
--pass-bg:#dcfce7; --pass-ink:#166534; --fail-bg:#fee2e2; --fail-ink:#991b1b;
--hover:#f1f5f9; --sel:#e0e7ff; --mono:ui-monospace,"SFMono-Regular",Consolas,"Liberation Mono",monospace;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{font-family:var(--sans);font-size:14px;color:var(--ink);background:var(--bg);
display:grid;grid-template-rows:auto 1fr;overflow:hidden}
/* ---------- header ---------- */
header{display:flex;align-items:center;gap:16px;padding:0 16px;height:52px;
background:var(--surface);border-bottom:1px solid var(--border)}
.brand{font-weight:700;letter-spacing:-.01em;white-space:nowrap}
.brand span{color:var(--accent)}
.omni{flex:1;position:relative}
.omni input{width:100%;height:36px;padding:0 12px 0 32px;font-size:14px;font-family:var(--sans);
border:1px solid var(--border-strong);border-radius:6px;background:var(--bg);color:var(--ink)}
.omni input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft);background:#fff}
.omni .ic{position:absolute;left:10px;top:9px;color:var(--ink-3);font-size:15px}
.omni .route{position:absolute;right:10px;top:9px;font-size:11px;color:var(--ink-3);font-family:var(--mono)}
.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:inherit;font-size:12px;color:var(--ink-2);background:none;border:1px solid var(--border);
border-radius:6px;height:30px;padding:0 10px;cursor:pointer}
.hbtn:hover{background:var(--hover)}
/* ---------- layout ---------- */
main{display:grid;grid-template-columns:232px 1fr 420px;min-height:0;height:100%}
.pane{min-height:0;overflow:auto}
/* ---------- filter rail ---------- */
.rail{border-right:1px solid var(--border);background:var(--surface);padding:14px}
.rail h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);margin:18px 0 8px;font-weight:600}
.rail h3:first-child{margin-top:0}
.seg{display:flex;border:1px solid var(--border-strong);border-radius:6px;overflow:hidden}
.seg button{flex:1;font:inherit;font-size:12px;height:30px;border:0;background:#fff;color:var(--ink-2);cursor:pointer}
.seg button+button{border-left:1px solid var(--border)}
.seg button.on{background:var(--accent);color:#fff}
.rail label{display:block;font-size:11px;color:var(--ink-3);margin:8px 0 3px}
.rail input,.rail select{width:100%;height:30px;font:inherit;font-size:13px;padding:0 8px;
border:1px solid var(--border-strong);border-radius:6px;background:#fff;color:var(--ink)}
.rail input:focus,.rail select:focus{outline:none;border-color:var(--accent)}
.reset{margin-top:18px;width:100%;height:30px;font:inherit;font-size:12px;background:none;
border:1px solid var(--border);border-radius:6px;color:var(--ink-2);cursor:pointer}
.reset:hover{background:var(--hover)}
/* ---------- results ---------- */
.results{display:grid;grid-template-rows: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:600;color:var(--ink)}
.rtoolbar .sp{flex:1}
.rtoolbar a,.rtoolbar select{font:inherit;font-size:12px;color:var(--accent);text-decoration:none}
.rtoolbar select{color:var(--ink-2);border:1px solid var(--border);border-radius:5px;height:26px}
.twrap{overflow:auto;min-height:0}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{position:sticky;top:0;background:#f8fafc;border-bottom:1px solid var(--border-strong);
text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);
font-weight:600;padding:7px 10px;white-space:nowrap;z-index:1}
tbody td{padding:5px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
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}
.pill{display:inline-block;font-size:11px;font-weight:600;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)}
.pager{display:flex;align-items:center;gap:10px;padding:8px 12px;border-top:1px solid var(--border);font-size:12px;color:var(--ink-2)}
.pager button{font:inherit;font-size:12px;height:28px;padding:0 10px;border:1px solid var(--border-strong);
border-radius:6px;background:#fff;cursor:pointer;color:var(--ink)}
.pager button:disabled{opacity:.4;cursor:default}
/* ---------- inspector ---------- */
.insp{border-left:1px solid var(--border);background:var(--surface);display:grid;grid-template-rows:auto auto 1fr;min-height:0}
.insp .meta{padding:12px 14px;border-bottom:1px solid var(--border)}
.insp .meta .sn{font-family:var(--mono);font-size:16px;font-weight:700}
.insp .meta dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:10px 0 0;font-size:12.5px}
.insp .meta dt{color:var(--ink-3)}
.insp .meta dd{margin:0;font-family:var(--mono)}
.insp .acts{display:flex;gap:6px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--border)}
.insp .acts a,.insp .acts button{font:inherit;font-size:12px;height:28px;padding:0 10px;border:1px solid var(--border-strong);
border-radius:6px;background:#fff;color:var(--ink);text-decoration:none;display:inline-flex;align-items:center;cursor:pointer}
.insp .acts .pri{background:var(--accent);border-color:var(--accent);color:#fff}
.insp iframe{width:100%;height:100%;border:0;background:#fff}
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--ink-3);gap:8px;font-size:13px;text-align:center;padding:20px}
.skel{height:24px;margin:6px 10px;border-radius:4px;background:linear-gradient(90deg,#f1f5f9,#e8edf3,#f1f5f9);background-size:200% 100%;animation:sh 1.1s infinite}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
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}
</style>
</head>
<body>
<header>
<div class="brand">Dataforth <span>·</span> TestDataDB</div>
<div class="omni">
<span class="ic">🔍</span>
<input id="omni" autocomplete="off" spellcheck="false"
placeholder="Search serial, model, or text… (press / to focus)">
<span class="route" id="route"></span>
</div>
<div class="badge"><span class="dot"></span><span id="ingest">loading…</span></div>
<button class="hbtn" id="statsBtn" title="Database statistics">Stats</button>
</header>
<main>
<!-- filter rail -->
<aside class="rail pane">
<h3>Result</h3>
<div class="seg" id="segResult">
<button data-v="" class="on">All</button>
<button data-v="PASS">PASS</button>
<button data-v="FAIL">FAIL</button>
</div>
<h3>Test date</h3>
<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 filters</button>
</aside>
<!-- results -->
<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="#">Export CSV ↓</a>
</div>
<div class="twrap" id="twrap">
<table>
<thead><tr>
<th>Serial</th><th>Model</th><th>Test Date</th><th>Stn</th><th>Log</th><th>Result</th><th>Web</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</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 style="color:var(--ink-3)"><kbd>/</kbd> search · <kbd></kbd><kbd></kbd> rows · <kbd></kbd> open · <kbd>Esc</kbd> clear</span>
</div>
</section>
<!-- inspector -->
<aside class="insp">
<div class="meta" id="meta"><div class="empty" style="height:auto;padding:8px 0">No record selected</div></div>
<div class="acts" id="acts" style="display:none"></div>
<div id="viewer"><div class="empty">Search a serial number, then select a row.<br>The calibration certificate renders here.</div></div>
</aside>
</main>
<script>
const API = ''; // same-origin; relative /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:[] };
let timer=null;
// ---- omni-search routing: serial-ish -> serial, model-ish -> model, else full-text ----
function routeOmni(v){
v=v.trim(); state.q=state.serial=state.model='';
if(!v){ $('route').textContent=''; return; }
if(/\s/.test(v)){ state.q=v; $('route').textContent='text'; }
else if(/^(scm|dsc|[5780]b|pwr|vas)/i.test(v)){ state.model=v; $('route').textContent='model'; }
else { state.serial=v; $('route').textContent='serial'; }
}
function params(extra){
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(extra!=='export'){ 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 fmtDate(d){ return d? String(d).slice(0,10) : ''; }
async function search(){
$('rows').innerHTML = Array.from({length:8}).map(()=>'<tr><td colspan="7"><div class="skel"></div></td></tr>').join('');
try{
const r = await fetch(API+'/api/search?'+params().toString());
const data = await r.json();
state.rows = data.records||[]; state.total = data.total||0;
renderRows();
}catch(e){ $('rows').innerHTML='<tr><td colspan="7" style="color:var(--fail-ink);padding:14px">Search failed: '+e.message+'</td></tr>'; }
$('exportCsv').href = API+'/api/export?'+params('export').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){ $('rows').innerHTML='<tr><td colspan="7" style="color:var(--ink-3);padding:18px">No records match. Try clearing a filter.</td></tr>'; return; }
$('rows').innerHTML = state.rows.map(r=>{
const web = r.api_uploaded_at ? '●' : '○';
return `<tr data-id="${r.id}">
<td class="mono" style="font-weight:600">${r.serial_number||''}</td>
<td class="mono">${r.model_number||''}</td>
<td class="mono">${fmtDate(r.test_date)}</td>
<td class="mono">${(r.test_station||'').replace(/^TS-/,'')}</td>
<td class="mono" style="color:var(--ink-3)">${(r.log_type||'').replace(/LOG$/,'')}</td>
<td><span class="pill ${r.overall_result}">${r.overall_result||''}</span></td>
<td style="color:${r.api_uploaded_at?'var(--pass-ink)':'var(--ink-3)'};text-align:center" title="${r.api_uploaded_at?'published to website':'not published'}">${web}</td>
</tr>`;}).join('');
[...$('rows').children].forEach(tr=>tr.onclick=()=>select(tr.dataset.id));
// auto-select first (the serial fast-path) if a serial query
if(state.serial && state.rows[0]) select(state.rows[0].id);
else if(state.selected){ const tr=[...$('rows').children].find(t=>t.dataset.id==state.selected); if(tr) tr.classList.add('sel'); }
}
function select(id){
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">${r.serial_number||''}</div>
<dl><dt>Model</dt><dd>${r.model_number||''}</dd>
<dt>Date</dt><dd>${fmtDate(r.test_date)}</dd>
<dt>Station</dt><dd>${r.test_station||''}</dd>
<dt>Result</dt><dd><span class="pill ${r.overall_result}">${r.overall_result||''}</span></dd>
<dt>Log</dt><dd>${r.log_type||''}</dd>
${r.work_order?`<dt>WO</dt><dd>${r.work_order}</dd>`:''}
<dt>Web</dt><dd>${r.api_uploaded_at?'published '+fmtDate(r.api_uploaded_at):'not published'}</dd></dl>`;
$('acts').style.display='flex';
const ds = API+'/api/datasheet/'+id;
$('acts').innerHTML = `<a class="pri" href="${ds}?format=html" target="_blank">Open ↗</a>
<button onclick="document.getElementById('viewer').querySelector('iframe').contentWindow.print()">Print</button>
<a href="${ds}?format=txt" download="${r.serial_number}.txt">TXT</a>
<a href="${ds}?format=html" download="${r.serial_number}.html">HTML</a>`;
$('viewer').innerHTML = `<iframe src="${ds}?format=html" title="datasheet"></iframe>`;
syncUrl();
}
// ---- filters wiring ----
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;search();},280); }
$('omni').addEventListener('input', e=>{ routeOmni(e.target.value); debouncedSearch(); });
$('segResult').addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b)return;
[...e.currentTarget.children].forEach(x=>x.classList.toggle('on',x===b)); state.result=b.dataset.v; state.page=0; search(); });
$('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=()=>{ ['result','station','logtype','from','to'].forEach(k=>state[k]='');
$('segResult').children[0].click(); $('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=''; };
// ---- keyboard ----
document.addEventListener('keydown', e=>{
if(e.key==='/' && document.activeElement!==$('omni')){ e.preventDefault(); $('omni').focus(); $('omni').select(); return; }
if(e.key==='Escape'){ 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);
select(state.rows[i].id); const tr=[...$('rows').children][i]; if(tr) tr.scrollIntoView({block:'nearest'}); }
if(e.key==='Enter' && state.selected){ window.open(API+'/api/datasheet/'+state.selected+'?format=html','_blank'); }
});
// ---- bootstrap ----
async function boot(){
// restore from URL
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);
state.selected=u.get('selected');
// filters + stats
try{ const f=await (await fetch(API+'/api/filters')).json();
$('modelList').innerHTML=(f.models||[]).map(m=>`<option value="${m.model_number}">${m.model_number} (${m.count})</option>`).join('');
$('fStation').innerHTML='<option value="">any station</option>'+(f.stations||[]).map(s=>`<option>${s}</option>`).join('');
$('fLog').innerHTML='<option value="">any log</option>'+(f.log_types||[]).map(l=>`<option>${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'; }
$('statsBtn').onclick=async()=>{ const s=await (await fetch(API+'/api/stats')).json();
alert('Total: '+s.total_records.toLocaleString()+'\nResult: '+(s.by_result||[]).map(r=>r.overall_result+' '+r.count.toLocaleString()).join(' · ')
+'\nDates: '+fmtDate(s.date_range.oldest)+' → '+fmtDate(s.date_range.newest)
+'\nLog types: '+(s.by_log_type||[]).map(r=>r.log_type+' '+r.count.toLocaleString()).join(' · ')); };
$('omni').focus();
search();
}
boot();
</script>
</body>
</html>