9bca4b608a
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
234 lines
8.2 KiB
JavaScript
234 lines
8.2 KiB
JavaScript
// UI/UX Score Loop — pass 1 driver.
|
|
// Loads each route at three sizes in Chrome Canary, captures screenshots,
|
|
// logs console errors, runs a small interaction probe per flow, and
|
|
// writes a JSON report. Does not modify any source files.
|
|
|
|
import puppeteer from "puppeteer-core";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
|
|
const BASE = "http://127.0.0.1:5173";
|
|
const SHOTS = "/tmp/cyclone-uiux/shots";
|
|
const REPORT = "/tmp/cyclone-uiux/report.json";
|
|
|
|
const SIZES = [
|
|
{ name: "desktop", w: 1440, h: 900 },
|
|
{ name: "tablet", w: 768, h: 1024 },
|
|
{ name: "mobile", w: 375, h: 812 },
|
|
];
|
|
|
|
// Routes to load. path = the route; name = the flow label; ready = a
|
|
// selector we wait for to consider the page "rendered".
|
|
const FLOWS = [
|
|
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
|
|
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
|
|
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
|
|
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
|
|
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
|
|
{ name: "remittances", path: "/remittances", ready: "main, table" },
|
|
{ name: "providers", path: "/providers", ready: "main, table" },
|
|
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
|
|
{ name: "acks", path: "/acks", ready: "main, table" },
|
|
{ name: "batches", path: "/batches", ready: "main, table" },
|
|
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
|
|
{ name: "activity", path: "/activity", ready: "main" },
|
|
{ name: "404", path: "/does-not-exist", ready: "main" },
|
|
];
|
|
|
|
async function setupViewports(browser) {
|
|
const pages = [];
|
|
for (const size of SIZES) {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
|
pages.push({ page, size });
|
|
}
|
|
return pages;
|
|
}
|
|
|
|
async function probeFlow(page, flow) {
|
|
const consoleErrors = [];
|
|
const pageErrors = [];
|
|
const failedRequests = [];
|
|
|
|
const onConsole = (msg) => {
|
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
};
|
|
const onPageError = (err) => pageErrors.push(err.message);
|
|
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
|
|
|
|
page.on("console", onConsole);
|
|
page.on("pageerror", onPageError);
|
|
page.on("requestfailed", onRequestFailed);
|
|
|
|
const t0 = Date.now();
|
|
let rendered = false;
|
|
let readyError = null;
|
|
try {
|
|
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
|
|
if (flow.ready) {
|
|
try {
|
|
await page.waitForSelector(flow.ready, { timeout: 5000 });
|
|
rendered = true;
|
|
} catch (e) {
|
|
readyError = e.message;
|
|
}
|
|
} else {
|
|
rendered = true;
|
|
}
|
|
} catch (e) {
|
|
readyError = e.message;
|
|
}
|
|
const loadMs = Date.now() - t0;
|
|
|
|
page.off("console", onConsole);
|
|
page.off("pageerror", onPageError);
|
|
page.off("requestfailed", onRequestFailed);
|
|
|
|
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
|
|
}
|
|
|
|
async function probeInteractions(page, flow) {
|
|
const findings = [];
|
|
// Generic a11y / structural probes per flow.
|
|
try {
|
|
// Sidebar visible? (md+ shows it; < md hides it)
|
|
const aside = await page.$("aside");
|
|
findings.push({ check: "sidebar-present", pass: !!aside });
|
|
} catch (e) {
|
|
findings.push({ check: "sidebar-present", pass: false, err: e.message });
|
|
}
|
|
try {
|
|
// Top bar present?
|
|
const main = await page.$("main#main-content");
|
|
findings.push({ check: "main-present", pass: !!main });
|
|
} catch (e) {
|
|
findings.push({ check: "main-present", pass: false, err: e.message });
|
|
}
|
|
try {
|
|
// H1 or page heading?
|
|
const heading = await page.evaluate(() => {
|
|
const h = document.querySelector("h1, h2");
|
|
return h ? h.textContent?.trim().slice(0, 60) : null;
|
|
});
|
|
findings.push({ check: "heading-present", pass: !!heading, value: heading });
|
|
} catch (e) {
|
|
findings.push({ check: "heading-present", pass: false, err: e.message });
|
|
}
|
|
|
|
// Flow-specific probes.
|
|
if (flow.name === "claims" || flow.name === "claims-denied") {
|
|
try {
|
|
const chips = await page.$$("[role='radio'], button[role='radio']");
|
|
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
|
|
} catch (e) {
|
|
findings.push({ check: "status-chips", pass: false, err: e.message });
|
|
}
|
|
try {
|
|
const search = await page.$("input[placeholder*='Search']");
|
|
findings.push({ check: "search-input", pass: !!search });
|
|
} catch (e) {
|
|
findings.push({ check: "search-input", pass: false, err: e.message });
|
|
}
|
|
}
|
|
if (flow.name === "upload") {
|
|
try {
|
|
const dropzone = await page.$("section[aria-label='File upload']");
|
|
findings.push({ check: "dropzone-present", pass: !!dropzone });
|
|
const selects = await page.$$("button[role='combobox']");
|
|
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
|
|
} catch (e) {
|
|
findings.push({ check: "upload-elements", pass: false, err: e.message });
|
|
}
|
|
}
|
|
if (flow.name === "inbox") {
|
|
try {
|
|
const lanes = await page.$$("main > div > div");
|
|
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
|
|
} catch (e) {
|
|
findings.push({ check: "lane-cards", pass: false, err: e.message });
|
|
}
|
|
}
|
|
if (flow.name === "404") {
|
|
try {
|
|
const text = await page.evaluate(() => document.body.innerText);
|
|
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
|
|
} catch (e) {
|
|
findings.push({ check: "404-text", pass: false, err: e.message });
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
async function main() {
|
|
await mkdir(SHOTS, { recursive: true });
|
|
const browser = await puppeteer.launch({
|
|
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
headless: "new",
|
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
});
|
|
const startedAt = new Date().toISOString();
|
|
const results = [];
|
|
|
|
for (const size of SIZES) {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
|
|
|
for (const flow of FLOWS) {
|
|
const probe = await probeFlow(page, flow);
|
|
const interactions = await probeInteractions(page, flow);
|
|
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
|
|
try {
|
|
await page.screenshot({ path: shot, fullPage: false });
|
|
} catch (e) {
|
|
// ignore — recording in result
|
|
}
|
|
results.push({
|
|
flow: flow.name,
|
|
path: flow.path,
|
|
size: size.name,
|
|
viewport: { w: size.w, h: size.h },
|
|
probe,
|
|
interactions,
|
|
shot,
|
|
});
|
|
console.log(
|
|
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
|
|
`render=${probe.rendered} load=${probe.loadMs}ms ` +
|
|
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
|
|
);
|
|
}
|
|
|
|
await page.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// Aggregate.
|
|
const summary = {
|
|
startedAt,
|
|
endedAt: new Date().toISOString(),
|
|
flows: results.length,
|
|
sizes: SIZES.map((s) => s.name),
|
|
renderedOk: results.filter((r) => r.probe.rendered).length,
|
|
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
|
|
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
|
|
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
|
|
results,
|
|
};
|
|
await writeFile(REPORT, JSON.stringify(summary, null, 2));
|
|
console.log("\nSummary:", JSON.stringify({
|
|
flows: summary.flows,
|
|
renderedOk: summary.renderedOk,
|
|
withConsoleErrors: summary.withConsoleErrors,
|
|
withPageErrors: summary.withPageErrors,
|
|
withFailedRequests: summary.withFailedRequests,
|
|
}, null, 2));
|
|
console.log("\nReport:", REPORT);
|
|
console.log("Shots:", SHOTS);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("FATAL", e);
|
|
process.exit(1);
|
|
});
|