let state; let groups = []; let selectedIndex = -1; let loadedIndex = -1; // which document the PDF viewer currently shows/loads let pdfViewer = null; let loadRequestId = 0; const people = document.querySelector("#people"); const fields = document.querySelector("#fields"); const viewer = document.querySelector(".viewer"); const pdfContainer = document.querySelector("#pdfViewer"); const pdfMessage = document.querySelector("#pdfMessage"); const status = document.querySelector("#status"); const search = document.querySelector("#search"); const errorBanner = document.querySelector("#errorBanner"); const fieldDefs = [ ["Name / Company", "name_company"], ["Prospective Buyer", "prospective_buyer"], ["Company", "company"], null, ["Phone", "phone"], ["Cell", "cell"], ["Email", "email"], null, ["Address", "address"], ["State", "state"], null, ["Businesses from Notes", "notes_business_raw"], ["Types of Businesses", "types_of_business_raw"], ["Background Experience", "background_experience"], null, ["How Did You Hear", "how_did_you_hear"], ["Interested in Updates", "interested_in_updates"], ["Down Payment", { key: "down_payment_raw", fallback: "down_payment" }], ["Total Purchase Price", "total_purchase_price"], ["Date of Introduction", "date_of_introduction"], null, ["Notes Page", "_notes_page"], ["Buyer Info Page", "_info_page"], ["CA Page", "_ca_page"], ]; const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); // Normalized person key from the vision-extracted buyer name: lowercase, // punctuation stripped, name tokens sorted so "Zahoor Bilal" and // "Bilal Zahoor" compare equal. function personKey(group) { const name = group.docs.map((d) => d.prospective_buyer).find((v) => typeof v === "string" && v.trim() ); if (!name) return null; const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ") .trim().split(/\s+/).filter(Boolean).sort(); return tokens.length ? tokens.join(" ") : null; } function buildGroups(docs) { const map = new Map(); docs.forEach((doc, index) => { const key = doc.name_from_filename; const group = map.get(key) || { key, displayName: doc.prospective_buyer || key, docs: [], text: "", }; group.docs.push({ ...doc, index }); group.text += " " + [ key, doc.prospective_buyer, doc.types_of_business_raw, doc.notes_business_raw, doc.address, ].filter(Boolean).join(" "); map.set(key, group); }); // Second pass: merge filename-based groups that refer to the same person // according to the extracted prospective_buyer. This catches typos in the // scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would // otherwise show the same buyer twice. Groups without a prospective_buyer // are never merged. const byPerson = new Map(); const merged = []; for (const group of map.values()) { const pKey = personKey(group); const target = pKey ? byPerson.get(pKey) : undefined; if (target) { target.docs.push(...group.docs); target.text += " " + group.text; continue; } if (pKey) byPerson.set(pKey, group); merged.push(group); } for (const group of merged) { group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name)); const preferred = group.docs.find((d) => typeof d.prospective_buyer === "string" && d.prospective_buyer.trim() ); if (preferred) group.displayName = preferred.prospective_buyer; } return merged.sort((a, b) => a.key.localeCompare(b.key)); } function visibleGroups() { const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean); return groups.filter((group) => terms.every((term) => group.text.toLowerCase().includes(term)) ); } function updateStatus(shownCount = visibleGroups().length) { const source = state.dataSource === "sample" ? "sample data" : "JSON file"; status.textContent = `${shownCount} people / ${state.documents.length} documents \u00b7 ${source}`; status.className = ""; } function showError(message) { errorBanner.textContent = message; errorBanner.hidden = !message; } function renderList() { const shown = visibleGroups(); people.innerHTML = shown.map((group) => `
${ esc(group.displayName) } (${group.docs.length})
${ group.docs.map((doc) => ` `).join("") }
`).join(""); updateStatus(shown.length); } async function loadPdf(index) { const doc = state.documents[index]; if (!doc) return; // Deduplicate: don't reload the document that is already shown. // NOTE: must compare against loadedIndex, NOT selectedIndex -- // select() updates selectedIndex before calling loadPdf(), so a // selectedIndex comparison is always true and blocks every reload. if (index === loadedIndex && pdfViewer) { return; } loadedIndex = index; const previousZoom = pdfViewer ? pdfViewer.zoom : 1; if (pdfViewer) { pdfViewer.destroy(); } pdfViewer = new PdfViewer(pdfContainer, { initialZoom: previousZoom }); viewer.classList.remove("loaded"); pdfMessage.hidden = false; pdfMessage.textContent = "Loading PDF\u2026"; const reqId = "pdf-" + (++loadRequestId); console.log( `[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`, ); console.log(`[BizMatch QC] PDF ${reqId} prepare requested`); const startTime = performance.now(); try { const response = await fetch("/api/pdf/prepare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ index, requestId: reqId }), }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Prepare failed: HTTP ${response.status}`); } const data = await response.json(); const prepareMs = Math.round(performance.now() - startTime); console.log( `[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`, ); if (index !== selectedIndex) { // A newer selection happened while preparing, don't show stale result return; } viewer.classList.add("loaded"); pdfMessage.hidden = true; console.log(`[BizMatch QC] PDF ${reqId} viewer load started`); await pdfViewer.load(data.url, data.size); console.log(`[BizMatch QC] PDF ${reqId} rendering complete`); } catch (error) { if (index !== selectedIndex) return; loadedIndex = -1; // allow retry: clicking the same document again reloads it const message = error instanceof Error ? error.message : String(error); pdfMessage.textContent = `Cannot open PDF: ${message}`; pdfMessage.hidden = false; } } function select(index) { selectedIndex = index; const doc = state.documents[index]; if (!doc) return; let rowIdx = 0; fields.innerHTML = `

${esc(doc.name_from_filename)}

${esc(doc.file_name)} \u00b7 ${ esc(doc._doc_type || "unknown") } \u00b7 ${esc(doc._pages_total ?? "?")} pages

${ doc._vision_error ? `

Vision error: ${esc(doc._vision_error)}

` : "" } ${ fieldDefs.map((def) => { if (!def) return '
'; const [label, keyOrObj] = def; let value; if (typeof keyOrObj === "object") { value = esc(doc[keyOrObj.key] || doc[keyOrObj.fallback] || "\u2014"); } else { value = esc(doc[keyOrObj] || "\u2014"); } const bgClass = rowIdx % 2 === 0 ? "row-even" : "row-odd"; rowIdx++; return `
${label}
${value}
`; }).join("") } `; void loadPdf(index); renderList(); } people.addEventListener("click", (event) => { const button = event.target.closest("[data-index]"); if (button) select(Number(button.dataset.index)); }); search.addEventListener("input", renderList); document.addEventListener("keydown", (event) => { if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const delta = event.key === "ArrowDown" ? 1 : -1; select( Math.max( 0, Math.min( state.documents.length - 1, selectedIndex < 0 ? 0 : selectedIndex + delta, ), ), ); } }); const dialog = document.querySelector("#settingsDialog"); const jsonPath = document.querySelector("#jsonPath"); const pdfBase = document.querySelector("#pdfBase"); const useAnonData = document.querySelector("#useAnonData"); const settingsError = document.querySelector("#settingsError"); const saveSettings = document.querySelector("#saveSettings"); document.querySelector("#settings").onclick = () => { jsonPath.value = state.config.jsonPath || ""; pdfBase.value = state.config.pdfBaseDirectory || ""; useAnonData.checked = !!state.config.useAnonymousData; settingsError.hidden = true; settingsError.textContent = ""; dialog.showModal(); }; saveSettings.onclick = async (event) => { event.preventDefault(); saveSettings.disabled = true; settingsError.hidden = true; try { const response = await fetch("/api/config", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonPath: jsonPath.value, pdfBaseDirectory: pdfBase.value, useAnonymousData: useAnonData.checked, }), }); const body = await response.json(); if (!response.ok) throw new Error(body.error || "Could not save settings."); dialog.close(); await load(); } catch (error) { settingsError.textContent = error instanceof Error ? error.message : String(error); settingsError.hidden = false; } finally { saveSettings.disabled = false; } }; async function load() { try { const response = await fetch("/api/state", { cache: "no-store" }); if (!response.ok) { throw new Error(`State request failed with HTTP ${response.status}.`); } state = await response.json(); loadedIndex = -1; // document set changed; index-based dedup is invalid now groups = buildGroups(state.documents); showError(state.loadError || ""); renderList(); if (state.documents.length) select(0); } catch (error) { showError(error instanceof Error ? error.message : String(error)); } } void load(); // ---- Window size reporting ---- // The backend's native size APIs are unreliable (see main.ts), so the // webview reports its own viewport size. The first report, sent shortly // after startup, calibrates the decoration offset on the backend; later // reports track user resizes. let metricsTimer = null; function reportWindowMetrics() { fetch("/api/window-metrics", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ innerWidth: globalThis.innerWidth, innerHeight: globalThis.innerHeight, }), }).catch(() => {}); } globalThis.addEventListener("resize", () => { clearTimeout(metricsTimer); metricsTimer = setTimeout(reportWindowMetrics, 250); }); setTimeout(reportWindowMetrics, 800); // calibration report