Files
claudetools/projects/dataforth-dos/testdatadb-fix/public/index.redesign.html
Mike Swanson 55407e8601 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>
2026-06-18 06:36:20 -07:00

302 lines
18 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:#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>