@@ -647,6 +670,7 @@ async function renderSkuLinker() {
${esc(price)}
${esc(store)}${esc(plus)}
+ ${open}
${esc(it.sampleUrl || "")}
${pinned ? `
Pinned (click again to unpin)
` : ``}
@@ -658,9 +682,18 @@ async function renderSkuLinker() {
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);
+
+ // Never show same sku as other pinned
+ const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
+
+ if (tokens.length) {
+ return allAgg
+ .filter((it) => it && it.sku !== otherSku && matchesAllTokens(it.searchText, tokens))
+ .slice(0, 80);
+ }
+
+ if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku);
+ return topSuggestions(allAgg, 60, "");
}
function attachHandlers($root, side) {
@@ -670,6 +703,14 @@ async function renderSkuLinker() {
const it = allAgg.find((x) => String(x.sku || "") === skuKey);
if (!it) return;
+ if (isUnknownSkuKey(it.sku)) return;
+
+ const other = side === "L" ? pinnedR : pinnedL;
+ if (other && String(other.sku || "") === String(it.sku || "")) {
+ $status.textContent = "Not allowed: both sides cannot be the same SKU.";
+ return;
+ }
+
if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it;
else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it;
@@ -703,11 +744,16 @@ async function renderSkuLinker() {
}
if (!(pinnedL && pinnedR)) {
$linkBtn.disabled = true;
- $status.textContent = "Pin one item on each side to enable linking.";
+ if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking.";
+ return;
+ }
+ if (String(pinnedL.sku || "") === String(pinnedR.sku || "")) {
+ $linkBtn.disabled = true;
+ $status.textContent = "Not allowed: both sides cannot be the same SKU.";
return;
}
$linkBtn.disabled = false;
- $status.textContent = "";
+ if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = "";
}
function updateAll() {
@@ -719,11 +765,17 @@ async function renderSkuLinker() {
let tL = null, tR = null;
$qL.addEventListener("input", () => {
if (tL) clearTimeout(tL);
- tL = setTimeout(updateAll, 50);
+ tL = setTimeout(() => {
+ $status.textContent = "";
+ updateAll();
+ }, 50);
});
$qR.addEventListener("input", () => {
if (tR) clearTimeout(tR);
- tR = setTimeout(updateAll, 50);
+ tR = setTimeout(() => {
+ $status.textContent = "";
+ updateAll();
+ }, 50);
});
$linkBtn.addEventListener("click", async () => {
@@ -732,6 +784,15 @@ async function renderSkuLinker() {
const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || "");
+ if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) {
+ $status.textContent = "Not allowed: unknown SKUs cannot be linked.";
+ return;
+ }
+ if (a === b) {
+ $status.textContent = "Not allowed: both sides cannot be the same SKU.";
+ return;
+ }
+
// Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku.
const aBC = skuIsBC(allRows, a);
const bBC = skuIsBC(allRows, b);
@@ -744,7 +805,7 @@ async function renderSkuLinker() {
try {
const out = await apiWriteSkuLink(fromSku, toSku);
- $status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}).`;
+ $status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}) to data/sku_links.json.`;
} catch (e) {
$status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`;
}
@@ -917,7 +978,6 @@ async function renderItem(sku) {
let cur = all.filter((x) => keySkuForRow(x) === want);
if (!cur.length) {
- // debug: show some Keg N Cork synthetic keys to see what we're actually generating
const knc = all.filter(
(x) => String(x.storeLabel || x.store || "").toLowerCase().includes("keg") && !String(x.sku || "").trim()
);
diff --git a/viz/serve.js b/viz/serve.js
index f5cd1db..f9d7e05 100755
--- a/viz/serve.js
+++ b/viz/serve.js
@@ -5,7 +5,8 @@ const http = require("http");
const fs = require("fs");
const path = require("path");
-const root = path.resolve(__dirname);
+const root = path.resolve(__dirname); // viz/
+const projectRoot = path.resolve(__dirname, ".."); // repo root
const MIME = {
".html": "text/html; charset=utf-8",
@@ -26,7 +27,8 @@ function safePath(urlPath) {
return norm;
}
-const LINKS_FILE = path.join(root, "data", "sku_links.json");
+// Project-level file (shared by viz + report tooling)
+const LINKS_FILE = path.join(projectRoot, "data", "sku_links.json");
function readLinks() {
try {
@@ -57,7 +59,7 @@ 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)
+ // Local API: append / read sku links file on disk (only exists when using this local server)
if (url.pathname === "/__stviz/sku-links") {
if (req.method === "GET") {
const obj = readLinks();
@@ -78,7 +80,7 @@ const server = http.createServer((req, res) => {
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" });
+ return sendJson(res, 200, { ok: true, count: obj.links.length, file: "data/sku_links.json" });
} catch (e) {
return sendJson(res, 400, { ok: false, error: String(e && e.message ? e.message : e) });
}