From fdc0091689f0b56107d488d0c1450bab602214cb Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Tue, 20 Jan 2026 13:10:58 -0800 Subject: [PATCH] feat: SKU Linking tool --- viz/app.js | 294 ++++++++++++++++++++++++++++++++++++++++++++++++-- viz/serve.js | 63 +++++++++++ viz/style.css | 12 +++ 3 files changed, 363 insertions(+), 6 deletions(-) diff --git a/viz/app.js b/viz/app.js index 03fcd60..9323737 100644 --- a/viz/app.js +++ b/viz/app.js @@ -4,6 +4,7 @@ * Hash routes: * #/ search * #/item/ detail + * #/link sku linker (local-write only) */ const $app = document.getElementById("app"); @@ -71,8 +72,6 @@ function displaySku(key) { return String(key || "").startsWith("u:") ? "unknown" : String(key || ""); } - - // Normalize for search: lowercase, punctuation -> space, collapse spaces function normSearchText(s) { return String(s ?? "") @@ -116,6 +115,7 @@ function route() { const parts = h.replace(/^#\/?/, "").split("/").filter(Boolean); if (parts.length === 0) return renderSearch(); if (parts[0] === "item" && parts[1]) return renderItem(decodeURIComponent(parts[1])); + if (parts[0] === "link") return renderSkuLinker(); return renderSearch(); } @@ -167,7 +167,6 @@ function aggregateBySku(listings) { const bySku = new Map(); for (const r of listings) { - const sku = keySkuForRow(r); const name = String(r?.name || ""); @@ -276,8 +275,11 @@ function renderSearch() { $app.innerHTML = `
-

Spirit Tracker Viz

-
Search name / url / sku (word AND)
+
+

Spirit Tracker Viz

+
Search name / url / sku (word AND)
+
+ Link SKUs
@@ -470,6 +472,287 @@ function renderSearch() { }); } +/* ---------------- SKU Linker ---------------- */ + +function isLocalWriteMode() { + const h = String(location.hostname || "").toLowerCase(); + return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); +} + +function levenshtein(a, b) { + a = String(a || ""); + b = String(b || ""); + const n = a.length, m = b.length; + if (!n) return m; + if (!m) return n; + const dp = new Array(m + 1); + for (let j = 0; j <= m; j++) dp[j] = j; + + for (let i = 1; i <= n; i++) { + let prev = dp[0]; + dp[0] = i; + const ca = a.charCodeAt(i - 1); + for (let j = 1; j <= m; j++) { + const tmp = dp[j]; + const cost = ca === b.charCodeAt(j - 1) ? 0 : 1; + dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost); + prev = tmp; + } + } + return dp[m]; +} + +function similarityScore(aName, bName) { + const a = normSearchText(aName); + const b = normSearchText(bName); + if (!a || !b) return 0; + + const A = new Set(tokenizeQuery(a)); + const B = new Set(tokenizeQuery(b)); + let inter = 0; + for (const w of A) if (B.has(w)) inter++; + const denom = Math.max(1, Math.max(A.size, B.size)); + const overlap = inter / denom; // 0..1 + + const d = levenshtein(a, b); + const maxLen = Math.max(1, Math.max(a.length, b.length)); + const levSim = 1 - d / maxLen; // ~0..1 + + return overlap * 2.2 + levSim * 1.0; +} + +function isBCStoreLabel(label) { + const s = String(label || "").toLowerCase(); + return s.includes("bcl") || s.includes("strath"); +} + +// infer BC-ness by checking any row for that skuKey in current index +function skuIsBC(allRows, skuKey) { + for (const r of allRows) { + if (keySkuForRow(r) !== skuKey) continue; + const lab = String(r.storeLabel || r.store || ""); + if (isBCStoreLabel(lab)) return true; + } + return false; +} + +function topSuggestions(allAgg, limit) { + const scored = allAgg.map((it) => { + const stores = it.stores ? it.stores.size : 0; + const hasPrice = it.cheapestPriceNum !== null ? 1 : 0; + const hasName = it.name ? 1 : 0; + return { it, s: stores * 2 + hasPrice * 1.2 + hasName * 1.0 }; + }); + scored.sort((a, b) => b.s - a.s); + return scored.slice(0, limit).map((x) => x.it); +} + +function recommendSimilar(allAgg, pinned, limit) { + if (!pinned || !pinned.name) return topSuggestions(allAgg, limit); + const base = String(pinned.name || ""); + const scored = []; + for (const it of allAgg) { + if (!it || it.sku === pinned.sku) continue; + const s = similarityScore(base, it.name || ""); + if (s > 0) scored.push({ it, s }); + } + scored.sort((a, b) => b.s - a.s); + return scored.slice(0, limit).map((x) => x.it); +} + +async function apiWriteSkuLink(fromSku, toSku) { + const res = await fetch("/__stviz/sku-links", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fromSku, toSku }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); +} + +async function renderSkuLinker() { + destroyChart(); + + const localWrite = isLocalWriteMode(); + + $app.innerHTML = ` +
+
+ +
+ SKU Linker + ${esc(localWrite ? "LOCAL WRITE" : "READ-ONLY")} +
+ +
+
+ Search or pin items in each column. With both pinned, LINK SKU writes to viz/data/sku_links.json (local only). +
+ +
+
+
Left
+ +
+
+ +
+
Right
+ +
+
+
+
+ + +
+ `; + + document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); + + const $qL = document.getElementById("qL"); + const $qR = document.getElementById("qR"); + const $listL = document.getElementById("listL"); + const $listR = document.getElementById("listR"); + const $linkBtn = document.getElementById("linkBtn"); + const $status = document.getElementById("status"); + + $listL.innerHTML = `
Loading index…
`; + $listR.innerHTML = `
Loading index…
`; + + const idx = await loadIndex(); + const allRows = Array.isArray(idx.items) ? idx.items : []; + const allAgg = aggregateBySku(allRows); + + let pinnedL = null; + let pinnedR = null; + + function renderCard(it, pinned) { + const storeCount = it.stores.size || 0; + const plus = storeCount > 1 ? ` +${storeCount - 1}` : ""; + const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; + const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store"); + return ` +
+
+
${renderThumbHtml(it.img)}
+
+
+
${esc(it.name || "(no name)")}
+ ${esc(displaySku(it.sku))} +
+
+ ${esc(price)} + ${esc(store)}${esc(plus)} +
+
${esc(it.sampleUrl || "")}
+ ${pinned ? `
Pinned (click again to unpin)
` : ``} +
+
+
+ `; + } + + function sideItems(query, otherPinned) { + const tokens = tokenizeQuery(query); + if (tokens.length) return allAgg.filter((it) => matchesAllTokens(it.searchText, tokens)).slice(0, 80); + if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60); + return topSuggestions(allAgg, 60); + } + + function attachHandlers($root, side) { + for (const el of Array.from($root.querySelectorAll(".item"))) { + el.addEventListener("click", () => { + const skuKey = el.getAttribute("data-sku") || ""; + const it = allAgg.find((x) => String(x.sku || "") === skuKey); + if (!it) return; + + if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it; + else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it; + + updateAll(); + }); + } + } + + function renderSide(side) { + const pinned = side === "L" ? pinnedL : pinnedR; + const other = side === "L" ? pinnedR : pinnedL; + const query = side === "L" ? $qL.value : $qR.value; + const $list = side === "L" ? $listL : $listR; + + if (pinned) { + $list.innerHTML = renderCard(pinned, true); + attachHandlers($list, side); + return; + } + + const items = sideItems(query, other); + $list.innerHTML = items.length ? items.map((it) => renderCard(it, false)).join("") : `
No matches.
`; + attachHandlers($list, side); + } + + function updateButton() { + if (!localWrite) { + $linkBtn.disabled = true; + $status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1."; + return; + } + if (!(pinnedL && pinnedR)) { + $linkBtn.disabled = true; + $status.textContent = "Pin one item on each side to enable linking."; + return; + } + $linkBtn.disabled = false; + $status.textContent = ""; + } + + function updateAll() { + renderSide("L"); + renderSide("R"); + updateButton(); + } + + let tL = null, tR = null; + $qL.addEventListener("input", () => { + if (tL) clearTimeout(tL); + tL = setTimeout(updateAll, 50); + }); + $qR.addEventListener("input", () => { + if (tR) clearTimeout(tR); + tR = setTimeout(updateAll, 50); + }); + + $linkBtn.addEventListener("click", async () => { + if (!(pinnedL && pinnedR) || !localWrite) return; + + const a = String(pinnedL.sku || ""); + const b = String(pinnedR.sku || ""); + + // Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku. + const aBC = skuIsBC(allRows, a); + const bBC = skuIsBC(allRows, b); + + let fromSku = a, toSku = b; + if (aBC && !bBC) { fromSku = a; toSku = b; } + else if (bBC && !aBC) { fromSku = b; toSku = a; } + + $status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`; + + try { + const out = await apiWriteSkuLink(fromSku, toSku); + $status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}).`; + } catch (e) { + $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; + } + }); + + updateAll(); +} + /* ---------------- Detail (chart) ---------------- */ let CHART = null; @@ -526,7 +809,6 @@ function findItemBySkuInDb(obj, skuKey, dbFile, storeLabel) { return null; } - function computeSuggestedY(values) { const nums = values.filter((v) => Number.isFinite(v)); if (!nums.length) return { suggestedMin: undefined, suggestedMax: undefined }; diff --git a/viz/serve.js b/viz/serve.js index 35fc5a4..f5cd1db 100755 --- a/viz/serve.js +++ b/viz/serve.js @@ -26,8 +26,70 @@ function safePath(urlPath) { return norm; } +const LINKS_FILE = path.join(root, "data", "sku_links.json"); + +function readLinks() { + try { + const raw = fs.readFileSync(LINKS_FILE, "utf8"); + const obj = JSON.parse(raw); + if (obj && Array.isArray(obj.links)) return obj; + } catch {} + return { generatedAt: new Date().toISOString(), links: [] }; +} + +function writeLinks(obj) { + obj.generatedAt = new Date().toISOString(); + fs.mkdirSync(path.dirname(LINKS_FILE), { recursive: true }); + fs.writeFileSync(LINKS_FILE, JSON.stringify(obj, null, 2) + "\n", "utf8"); +} + +function send(res, code, body, headers) { + res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8", ...(headers || {}) }); + res.end(body); +} + +function sendJson(res, code, obj) { + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(obj)); +} + const server = http.createServer((req, res) => { const u = req.url || "/"; + const url = new URL(u, "http://127.0.0.1"); + + // Local-only API: append / read links file (this server only runs locally) + if (url.pathname === "/__stviz/sku-links") { + if (req.method === "GET") { + const obj = readLinks(); + return sendJson(res, 200, { ok: true, count: obj.links.length, links: obj.links }); + } + + if (req.method === "POST") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + try { + const inp = JSON.parse(body || "{}"); + const fromSku = String(inp.fromSku || "").trim(); + const toSku = String(inp.toSku || "").trim(); + if (!fromSku || !toSku) return sendJson(res, 400, { ok: false, error: "fromSku/toSku required" }); + + const obj = readLinks(); + obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() }); + writeLinks(obj); + + return sendJson(res, 200, { ok: true, count: obj.links.length, file: "viz/data/sku_links.json" }); + } catch (e) { + return sendJson(res, 400, { ok: false, error: String(e && e.message ? e.message : e) }); + } + }); + return; + } + + return send(res, 405, "Method Not Allowed"); + } + + // Static let file = safePath(u === "/" ? "/index.html" : u); if (!file) { res.writeHead(400); @@ -54,4 +116,5 @@ const server = http.createServer((req, res) => { const port = Number(process.env.PORT || 8080); server.listen(port, "127.0.0.1", () => { process.stdout.write(`Serving ${root} on http://127.0.0.1:${port}\n`); + process.stdout.write(`SKU links file: ${LINKS_FILE}\n`); }); diff --git a/viz/style.css b/viz/style.css index c29a80f..16d6686 100644 --- a/viz/style.css +++ b/viz/style.css @@ -238,3 +238,15 @@ a:hover { text-decoration: underline; } width: 100% !important; height: 100% !important; } + +/* --- SKU linker additions --- */ +.pinnedItem { + outline: 2px solid #37566b; + border-color: #37566b; +} + +.linkBar { + margin-top: 12px; + position: sticky; + bottom: 0; +}