Compare commits

..

18 Commits

Author SHA1 Message Date
f798e8eafa dataforth(datasheet): Fix 2 — publish the 68 STAGE-3-clean DSCA models to Hoffman
Restarted testdatadb service (new template live), canary-pushed 1 cert (updated,
0 errors), then re-pushed all PASS certs for the 68 Final-Test-content-clean
models via uploadBySerialNumbers from a fresh node process.

Result over 30,423 PASS certs: updated=26022 unchanged=2738 created=0 errors=0
skipped=1663. The 26,022 updates replace the old defective DSCA renders on the
live site with the rebuilt, byte-content-validated ones. The 1,663 skips are the
count-guard correctly refusing any individual cert whose value count != its
template row count (ambiguous) — never publishing misaligned data.

Artifacts: push-clean68.js (driver), dsca-clean68-models.json (the published
set). NOT yet published: the 26 last-digit-diff models, the ~32 ambiguous/null
layout families, and the 231 untemplated models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 07:03:23 -07:00
c03bdc9a64 dataforth(datasheet): Fix 2 STAGE 3 — DSCA render validator + first full report
validate-dsca-stage3.js: read-only harness that, for every staged DSCA original
we have ground truth for (2806 across 126 models), looks up the DB record,
renders it through the live path, and content-compares. GATE = the FINAL TEST
RESULTS section (rule lines canonicalized, whitespace collapsed — so the deferred
column-spacing cosmetic doesn't register); accuracy-section diffs reported
separately as informational.

First run verdict (report attached):
- 68 models FINAL-TEST CONTENT-CLEAN (0 mismatch over compared certs).
- 2123/2316 certs match the Final-Test content exactly (91.7%).
- 26 models show measured-value last-digit diffs only — structure (names, specs,
  row alignment, statuses) is correct. Two root causes, neither structural:
    * rounding-mode: JS double toFixed vs QB single-precision half-up
      (e.g. raw 9.9995 code3 -> "9.999" here, "10.000" in golden). Fixable but
      float-precision-sensitive; risks regressing currently-clean values.
    * data vintage: staged .TXT is a different test run than the DB latest-wins
      record (Fix 3) — e.g. Supply Current 19.6 vs 20.3, 0.7 apart. Not a render
      bug; can't be reconciled against an older staged sheet.
- ~32 models render null (count-guard): DSCA33-*, DSCA45-*, DSCA49-* families
  whose raw_data carries load points the template omits -> need per-subtype slot
  mapping (the canonical-slot approach) before they can render.

Still NOT published: service not restarted, nothing re-pushed to Hoffman.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:52:54 -07:00
e7fa7cc680 dataforth(datasheet): Fix 2 STAGE 2 — wire DSCA per-model templates into render
Deployed file is C:\Shares\testdatadb\templates\datasheet-exact.js; this
reconciles the repo copy + adds dsca-templates.json (STAGE 1 output).

What changed in generateExactDatasheet (DSCA family only; 5B/8B/7B/DSCT/SCMVAS
paths byte-unchanged):
- Load dsca-templates.json once at module top (126 per-model layouts).
- DSCA Final-Test now renders names + specs from the staged template rows, not
  the single hardcoded DATA_LINES['DSCA'] + buildTSpecs DSCA branch.
- Value-bearing raw_data STATUS groups map positionally onto the spec-bearing
  template rows; empty-spec rows (240VAC Withstand / Hi-Pot) render blank+PASS.
  Removed the duplicate hardcoded 240VAC/Hi-Pot footer for DSCA (now rows).
- ACCURACY header uses the template accOut ("Output (V)"/"Output (mA)") with '-'
  rule separators instead of "Vout (V)" + '='.
- Header/columns match the staged originals (Measured Value*, 25/15/19/6 rule).

Two real bugs fixed (both are the handoff's "lines drop" / wrong-value defect):
- formatMeasuredExact reads the value from index 4 so negative signs survive
  ("PASS-4.24..." -> "-4", not "4"); also decimal-code N -> toFixed(N) exactly
  (DSCA differs from 5B/8B where code 2 means 1 decimal).
- parseRawData no longer consumes the first DSCA STATUS group as a bare
  step-response line when that line is absent (dropped 3 rows on e.g. DSCA39-01).

Safety: when value count != spec-row count the positional zip is ambiguous
(subtype measures load points the template omits, e.g. DSCA49 5mA pair), so the
cert is SKIPPED (null) and left for STAGE 3 per-subtype mapping rather than
emitting misaligned data.

Validation: DSCA38-05 (SN 180224-1) Final-Test block byte-identical to its
staged original. 92/126 templated models render cleanly; 7 ambiguous + 27
no-spec skip. Remaining ACCURACY-block spacing diffs are the deferred cosmetic
gap. NOT YET LIVE: testdatadb service not restarted, nothing re-pushed to
Hoffman (STAGE 3 gate).

Coverage gap to resolve before publish: only 126/357 DSCA models in the DB have
a staged template (56,074 certs, 70.1%); 231 models / 23,866 certs have none and
now render null — needs a STAGE 1 extension (more staged originals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:44:17 -07:00
fc9fff81f7 sync: auto-sync from AD2 at 2026-06-18 06:13:27
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-18 06:13:27
2026-06-18 06:16:14 -07:00
703002f648 sync: auto-sync from AD2 at 2026-06-17 16:21:47
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 16:21:47
2026-06-18 06:15:43 -07:00
556bc24b54 dataforth(datasheet): save email draft to John summarizing all findings
Plain-language summary for John (EE, non-developer) covering all 4 problems
(RTD label, DSCA table, same-day run, missing units), framed as ACG-owned fixes.
Cross-links the four supporting technical docs. Draft - not yet sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:43 -07:00
8e251da843 dataforth(datasheet): correct Cause 2 of missing units - cryptolocker incident, not import timing
Per Mike: import runs every 15 min, so routine timing isn't the cause. The 379
absent units are confined to 2025-10..2026-01 (stop after Jan 2026) on TS-4L/4R/1R
- fingerprint of a one-time overwrite during the incident/recovery (fresh DOS logs
overwrote accumulated appended server-side logs for ~2 weeks). One-time, not
recurring; backfill from the surviving staged .TXT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
b42b7f6d14 dataforth(datasheet): root-cause the 608 missing units (report for John)
608 staged datasheets absent from DB. Two causes: (1) 229 units with encoded/
non-standard serials the importer's leading-digit regex silently skips - data is
in the .DAT, recoverable; full blind spot is 840 serials / 9,510 records / 141
models dropped fleet-wide. (2) 379 units whose per-model .DAT was overwritten by a
later work order - recoverable only from the staged .TXT or a log backup. Adds
John-facing report, raw data, and the chase-missing-units.js tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
ff61d8d964 dataforth(datasheet): same-day retest faithfulness — exposure sweep + fix proposal
Whole-source sweep (981,716 records / 406,549 serials): 6,515 same-day multi-run
events; DB holds a NON-latest run for 311 (the strictly-greater-date conflict rule
freezes on an arbitrary same-day run). Corrects the verdict doc to flag same-day
retests as a latest-wins faithfulness violation (not benign). Adds the proposed
>= -with-data-differs conflict-rule fix (diagnose-only) and the sweep tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
e20f0366ff dataforth(datasheet): parsing-fidelity validation — all staged originals vs DB
Validated all 11,922 staged original .TXT datasheets against test_records.
0 genuine parse faults across 11,239 comparable records; mismatches all explained
(retests, reused serials, VAS format, legacy out-of-scope units). Adds the
validate-parsing.js tool, raw report, and verdict. Two follow-ups (NOT parse bugs):
608 staged units absent from DB (ingestion completeness), and same-day retests keep
the first run (ON CONFLICT strictly-greater-date).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
c3ed3b6177 dataforth(datasheet): PROPOSED Defect-A fix — render RTD input as Temp (C), not resistance
Repo copy only (review before deploying to C:\Shares\testdatadb). Folds RTD
(sensorNum 7) into the temperature path so the ACCURACY input column shows
'Temp. (C)' with signed temperature values, matching the original DOS-generated
datasheets and thermocouples (3-6). raw_data stimulus is already in deg C; no
conversion. getSensorNum and the i==13 ohm/ohm unit override are untouched.

Verified read-only against deployed env: 8B35 SN 179553-13 now shows Temp (C);
regression over 184 5B/8B renders -> 15 RTD changed (intended), 0 non-RTD changed.
Does NOT address Defect B (DSCA template). See DATASHEET-RTD-BUG-DIAGNOSIS-2026-06-17.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
e234fc24d5 memory: AD2 sync.sh pushes main not ad2 (fork push gotcha)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
bd0e28cc66 sync: auto-sync from AD2 at 2026-06-17 13:56:18
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 13:56:18
2026-06-18 06:15:42 -07:00
66fffcc197 memory: record AD2 Dataforth-fork structure + sync gotchas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:42 -07:00
492df6c9e0 dataforth: preserve fork operational context as clients/dataforth/CLAUDE.dataforth.md
Relocated verbatim from .claude/CLAUDE.md so the ad2 fork stops editing the
shared fleet harness doc. After rebase onto main, .claude/CLAUDE.md = the lean
fleet version; Dataforth ops context lives here. Keeps future ad2 syncs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:41 -07:00
7faef5a3b0 sync: auto-sync from AD2 at 2026-06-17 13:35:55
Author: Mike Swanson
Machine: AD2
Timestamp: 2026-06-17 13:35:55
2026-06-18 06:15:37 -07:00
sysadmin
9179a32f65 Add AD scripts and stage import instructions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:05 -07:00
sysadmin
f41f117447 Session log 2026-04-03: WO import, 7B support, PG migration started
- 33K work orders imported, 2.27M records linked
- 7B exact-match formatter added (31 params, 120VAC, Packing Check List)
- TXT formatting refined to match QB TAB positions exactly
- PostgreSQL 18 installed on AD2, database created
- SQL Server Express uninstalled
- Full Dataforth audit document generated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-18 06:15:05 -07:00
2 changed files with 0 additions and 542 deletions

View File

@@ -1,181 +0,0 @@
# DSCA Datasheet Fix 2 — STAGE 2 wire-in, STAGE 3 validator, publish of 68 clean models
## User
- **User:** Mike Swanson (mike)
- **Machine:** AD2
- **Role:** admin
## Session Summary
Picked up the AD2-local handoff `projects/dataforth-dos/DATASHEET-FIX2-5-HANDOFF-2026-06-18.md`
(ref Syncro #32441) and executed Fix 2 (DSCA Final-Test rebuild) STAGE 2 and STAGE 3, then
published the validated subset to the live Hoffman site. Work was on the DEPLOYED pipeline at
`C:\Shares\testdatadb` (Node + PostgreSQL 18); repo copies under
`projects/dataforth-dos/datasheet-pipeline/implementation/` are stale and were reconciled after.
STAGE 2 wired the STAGE-1 output `dsca-templates.json` (126 per-model layouts) into the deployed
`templates/datasheet-exact.js`. For `family === 'DSCA'` the Final-Test block now renders parameter
names + specs from the staged template rows (not the single hardcoded `DATA_LINES['DSCA']` +
`buildTSpecs` DSCA branch); value-bearing `raw_data` STATUS groups map positionally onto the
spec-bearing rows; empty-spec rows (240VAC Withstand / Hi-Pot) render blank+PASS (the duplicate
hardcoded footer for DSCA was removed). The ACCURACY block now uses the template `accOut`
(`Output (V)`/`Output (mA)`) with `-` rule separators instead of `Vout (V)` + `=`. Two real
defects were found and fixed in the process (both are the handoff's "lines drop / wrong values"
defect): negative signs were dropped because value parsing started at index 5 instead of 4, and
`parseRawData` always consumed the line after the 5 accuracy points as a step-response placeholder
— but many DSCA models omit that bare `0` line, so the first STATUS group (and its rows) was being
discarded. Both fixes were validated against the DSCA38-05 golden staged original: the rebuilt
Final-Test block is byte-for-byte identical (the only residual diffs are deferred cosmetic ACCURACY
column spacing).
STAGE 3 built a read-only validator (`tools/validate-dsca-stage3.js`) that, for every staged DSCA
original we have ground truth for (2,806 across 126 models), looks up the DB record, renders it
through the live path, and content-compares. The gate is the FINAL TEST RESULTS section with rule
lines canonicalized and whitespace collapsed (so the deferred column-spacing cosmetic does not
register); accuracy-section diffs are reported separately. Verdict: 68 models FINAL-TEST
CONTENT-CLEAN (2,123/2,316 compared certs match exactly, 91.7%), 26 models with measured-value
last-digit diffs only, and ~32 models rendering null via the count-guard. A safety guard was added:
when a model's value count != its spec-row count the positional zip is ambiguous (the subtype
measures load points the template omits, e.g. DSCA49's 5mA pair), so the cert is skipped/flagged
rather than emitting misaligned data.
Per Mike's direction, published the 68 STAGE-3-clean models. Restarted the `testdatadb` service so
the new template is live, canaried a single cert (DSCA30-01 / 148059-3 -> updated, 0 errors), then
re-pushed all 30,423 PASS certs for the 68 clean models via `uploadBySerialNumbers` from a fresh
node process. Result: updated=26,022, unchanged=2,738, created=0, errors=0, skipped=1,663. The
26,022 updates replaced the old defective DSCA renders on the live site with the rebuilt, validated
ones; the 1,663 skips are the count-guard correctly refusing individual ambiguous certs.
All work committed to the `ad2` branch (4 commits) and pushed. No vault access on AD2 (no
sops/age, vault repo absent), so the Syncro #32441 ticket update could not be posted from here —
left as a handoff to GURU-5070 (see Pending / Handoff).
## Key Decisions
- **Skip, don't guess, on ambiguous layouts.** When a DSCA model's value-bearing STATUS count !=
its spec-bearing template-row count, the simple positional zip would misplace values. Chose to
return null (skip + flag for STAGE 3 per-subtype mapping) rather than publish misaligned data,
per the handoff's "do not guess" discipline. This is what produces the 1,663 push skips.
- **Scoped the parser/format fixes to DSCA only.** The step-response detection fix and the
index-4 value-start fix live in `formatMeasuredExact` (new) and a DSCA-gated branch in
`parseRawData`, leaving the already-validated 5B/8B/7B/DSCT/SCMVAS paths byte-unchanged.
- **DSCA decimal code N -> toFixed(N) exactly.** Unlike 5B/8B (where format code 2 means 1
decimal), DSCA uses the trailing status digit as a literal decimal-place count; verified against
the golden (e.g. Output Reg code 2 -> "0.00").
- **Gate STAGE 3 on the Final-Test section, not the whole sheet.** ACCURACY-block column spacing
is the handoff's explicitly-deferred cosmetic gap; canonicalizing rule lines + collapsing
whitespace isolates real content diffs (names/values/specs/statuses) from spacing noise.
- **Published only the 68 clean models.** Highest-confidence set; idempotent push replaces
defective live certs. Left the 26 last-digit-diff models, ~32 ambiguous layouts, and 231
untemplated models for follow-up.
- **Did not restart-then-publish through the running service.** Re-push ran from a fresh node
process (picks up new template via `require`); the service restart only refreshes the internal
tool's on-demand renders.
## Problems Encountered
- **Sync rebase conflict in `errorlog.md`** — two machines appended entries concurrently. Resolved
by keeping both entries (append-only log) and `git rebase --continue`.
- **`sync.sh` push failed (`src refspec main does not match any`)** — known AD2 fork gotcha
(sync.sh is not fork-aware on push). Pushed manually with `git push --force-with-lease origin ad2`
(rebase rewrote history). See memory `project_ad2_dataforth_fork`.
- **Dropped rows on DSCA models without a step-response line** — `parseRawData` ate the first
STATUS group. Fixed by skipping the step-response consume for DSCA when the next line starts with
PASS/FAIL.
- **Negative measured values rendered without sign** — value substring started at index 5 (assumed
a space at index 4), but negatives put `-` at index 4. Fixed to start at index 4.
- **First STAGE 3 run showed 0 matches / 2,316 mismatches** — the validator's normalization wasn't
canonicalizing rule lines, so the deferred ACCURACY separator dash-count cosmetic masked all
downstream content. Reworked to canonicalize rule lines and gate on the Final-Test section.
- **No vault access on AD2** — cannot fetch Syncro creds to post #32441. Handoff to 5070 (below).
## Configuration Changes
Deployed (live, outside repo — `C:\Shares\testdatadb`):
- `templates/datasheet-exact.js` — DSCA wire-in (edited). Save-state:
`templates/datasheet-exact.js.bak-2026-06-18-1334`.
- `_validate_dsca_stage3.js`, `_push_clean68.js`, `_clean-models.json`, `_dsca-stage3-report.txt`,
`_stage3-run.log`, `_push-clean68.log` — operational scripts/outputs (created).
Repo (`ad2` branch):
- `projects/dataforth-dos/datasheet-pipeline/implementation/templates/datasheet-exact.js` — reconciled (modified).
- `projects/dataforth-dos/datasheet-pipeline/implementation/dsca-templates.json` — added.
- `projects/dataforth-dos/tools/validate-dsca-stage3.js` — added.
- `projects/dataforth-dos/tools/push-clean68.js` — added.
- `projects/dataforth-dos/DSCA-STAGE3-REPORT-2026-06-18.txt` — added.
- `projects/dataforth-dos/dsca-clean68-models.json` — added (the 68 published models).
- `errorlog.md` — conflict resolution during sync.
## Credentials & Secrets
No new credentials discovered or created this session. Referenced (already vaulted per the handoff
at `clients/dataforth/testdatadb-postgres`):
- PostgreSQL superuser `postgres` / `Paper123!@#`; app `testdatadb_app` / `DfTestDB2026!`
(`db.js` defaults: host localhost:5432, database `testdatadb`).
- Hoffman/CloudFilter uploader creds: `C:\ProgramData\dataforth-uploader\credentials.json`
(fields CF_TOKEN_URL, CF_API_BASE, CF_CLIENT_ID, CF_CLIENT_SECRET, CF_SCOPE). Not vaulted; lives
on the box only.
## Infrastructure & Servers
- **AD2** (192.168.0.6) — Dataforth domain controller; runs the deployed pipeline at
`C:\Shares\testdatadb`. Windows service `testdatadb` (Automatic, Running) — restarted this session.
- **PostgreSQL 18** on AD2 (localhost:5432, db `testdatadb`); table `test_records`.
- **Staged originals**: `C:/Shares/test/STAGE/**/*.TXT` (11,956 total; 2,806 DSCA) — STAGE-1 source
and STAGE-3 ground truth.
- **Hoffman API** (`CF_API_BASE/api/v1/TestReportDataFiles/bulk` POST, idempotent; GET is paginated
list only) — the live customer-facing datasheet site.
## Commands & Outputs
- Sync conflict recovery: `git add errorlog.md && GIT_EDITOR=true git rebase --continue` then
`git push --force-with-lease origin ad2`.
- Module load-check after each edit: `node -e 'require("./templates/datasheet-exact.js")'`.
- STAGE 3 run: `node _validate_dsca_stage3.js` -> `_dsca-stage3-report.txt`. Summary: 68 clean,
26 Final-Test mismatch (193 certs), ~32 null; 2,123/2,316 match.
- Canary: `uploadBySerialNumbers(["148059-3"])` -> `updated=1 errors=0`.
- Full push: `node _push_clean68.js` -> `created=0 updated=26022 unchanged=2738 errors=0 skipped=1663`
over 30,423 PASS certs.
## Pending / Incomplete Tasks
Not yet published / remaining DSCA work:
1. **26 models, last-digit measured diffs** — two causes: (a) QB single-precision half-up rounding
vs JS double `toFixed` (e.g. raw 9.9995 code3 -> "9.999" here, "10.000" in golden) — fixable but
float-precision-sensitive, re-run validator to confirm no regression; (b) retest data-vintage,
where the staged `.TXT` is an older test run than the DB latest-wins record (Fix 3) — not a
render bug.
2. **~32 ambiguous layouts** (DSCA33-*, DSCA45-*, DSCA49-* families) — need the canonical
per-subtype slot mapping (status entry index -> canonical DSCA slot -> template row by name);
currently skipped (null) by the count-guard.
3. **231 untemplated models / 23,866 certs** — need a STAGE 1 extension (more staged originals;
only 126/357 DSCA models in the DB currently have a template — 70.1% of certs).
4. **Fix 5** (backfill 379 cryptolocker-era units from staged `.TXT`) and Fix-3 cleanup items — not
started this session.
## Note for mike (handoff to GURU-5070)
AD2 has no vault access (no sops/age, `D:/vault` absent), so I could not post the Syncro **#32441**
update from here. On GURU-5070 (which has vault + Syncro access), please post an internal/hidden
note to #32441 summarizing this session:
> Fix 2 (DSCA Final-Test rebuild) STAGE 2 + STAGE 3 complete. Wired per-model staged templates into
> the live renderer; fixed two render defects (dropped negative signs, dropped Final-Test rows on
> models lacking the step-response line). Built a STAGE 3 validator over 2,806 staged DSCA originals:
> 68 models are Final-Test content-clean. Published those 68 to Hoffman — 26,022 certs updated,
> 0 errors (replacing the old defective DSCA renders live). Remaining: 26 models with measured
> last-digit diffs (rounding-mode + retest vintage), ~32 ambiguous-layout families (DSCA33/45/49)
> needing per-subtype slot mapping, and 231 untemplated models (23,866 certs) needing a STAGE 1
> extension. Byte-for-byte DOS cosmetic fidelity (leading rule line + ~1-space input-column spacing)
> still deferred across all families. Details: ad2 branch commits + DSCA-STAGE3-REPORT-2026-06-18.txt.
## Reference Information
- Handoff: `projects/dataforth-dos/DATASHEET-FIX2-5-HANDOFF-2026-06-18.md`
- STAGE 3 report: `projects/dataforth-dos/DSCA-STAGE3-REPORT-2026-06-18.txt`
- Published set: `projects/dataforth-dos/dsca-clean68-models.json` (68 models)
- Ticket: Syncro **#32441** (customer-facing thread: John Lehman)
- `ad2` commits this session: `e7fa7cc6` (STAGE 2), `c03bdc9a` (STAGE 3 validator+report),
`f798e8ea` (publish artifacts); `fc9fff81` (sync auto-commit, errorlog conflict resolution).
- Deployed render path: `database/render-datasheet.js` -> `templates/datasheet-exact.js`;
push path: `database/upload-to-api.js` (`uploadBySerialNumbers`, BATCH=100).

View File

@@ -1,361 +0,0 @@
<!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 var(--insp,460px);min-height:0;height:100%}
.pane{min-height:0;overflow:auto}
/* ---------- presets ---------- */
.presets{display:flex;flex-direction:column;gap:5px}
.preset{display:flex;align-items:center;gap:7px;width:100%;text-align:left;font:inherit;font-size:12.5px;
height:30px;padding:0 9px;border:1px solid var(--border);border-radius:6px;background:#fff;color:var(--ink);cursor:pointer}
.preset:hover{background:var(--accent-soft);border-color:#bfdbfe}
.preset .pi{width:15px;text-align:center;color:var(--ink-3)}
.preset.fam{display:inline-flex;width:auto;height:26px;font-family:var(--mono);font-size:11.5px;padding:0 8px}
.preset.fam:disabled,.preset:disabled{opacity:.5;cursor:not-allowed;background:#fff}
.famrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
/* ---------- 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{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}
.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>Quick searches</h3>
<div class="presets" id="presets"></div>
<div class="famrow" id="families"></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="resizer" id="resizer" title="Drag to resize"></div>
<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 id="dsframe" title="datasheet"></iframe>';
const _f=document.getElementById('dsframe'); _f.onload=fitCert; _f.src=ds+'?format=html';
syncUrl();
}
// ---- filters wiring ----
function debouncedSearch(){ clearTimeout(timer); timer=setTimeout(()=>{state.page=0;search();},280); }
$('omni').addEventListener('input', e=>{ routeOmni(e.target.value); debouncedSearch(); });
$('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]='');
$('omni').value='';$('route').textContent='';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value='';
state.page=0; search(); };
// ---- cert fit-to-width (same-origin: scale the rendered cert so it never side-scrolls) ----
function fitCert(){
const f=document.getElementById('dsframe'); if(!f) return;
try{ const doc=f.contentDocument; if(!doc) return;
const root=doc.documentElement; root.style.zoom='';
const natural=Math.max(doc.body?doc.body.scrollWidth:0, root.scrollWidth);
const avail=f.clientWidth-12;
root.style.zoom = (natural>avail) ? Math.max(0.45, avail/natural) : 1;
}catch(e){}
}
window.addEventListener('resize', fitCert);
// ---- resizable inspector ----
(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(340, Math.min(window.innerWidth-560, 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(); } });
})();
// ---- quick-search presets ----
function clearAll(){ ['serial','model','q','result','station','logtype','from','to'].forEach(k=>state[k]='');
$('omni').value='';$('route').textContent='';$('fFrom').value=$('fTo').value=$('fStation').value=$('fLog').value=$('fModel').value=''; }
function applyPreset(fn){ clearAll(); fn(); state.page=0; search(); }
const _iso=d=>d.toISOString().slice(0,10);
const PRESETS=[
{ic:'◷',label:'Recent',fn:()=>{}},
{ic:'✕',label:'Failures',fn:()=>{state.result='FAIL';}},
{ic:'•',label:'Today',fn:()=>{const t=_iso(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=_iso(d);}},
{ic:'∷',label:'This year',fn:()=>{state.from=new Date().getFullYear()+'-01-01';}},
];
const SOON=[
{label:'Latest upload batch',why:'needs an upload-time sort param in /api/search'},
{label:'Retested units',why:'needs a retest flag in the pipeline'},
{label:'Not yet published',why:'needs a published filter in /api/search'},
];
function renderPresets(){
const host=$('presets'); host.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); host.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; host.appendChild(b); });
const fam=$('families'); fam.innerHTML='';
['DSCA','8B','5B','7B','SCM5B'].forEach(m=>{ const b=document.createElement('button'); b.className='preset fam';
b.textContent=m; b.onclick=()=>applyPreset(()=>{state.model=m;$('fModel').value=m;}); fam.appendChild(b); });
}
// ---- 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(' · ')); };
renderPresets();
$('omni').focus();
search();
}
boot();
</script>
</body>
</html>