mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
fix: SKUs cant match other SKUs
This commit is contained in:
parent
fdc0091689
commit
d3b7e54b73
2 changed files with 84 additions and 22 deletions
96
viz/app.js
96
viz/app.js
|
|
@ -72,6 +72,10 @@ function displaySku(key) {
|
||||||
return String(key || "").startsWith("u:") ? "unknown" : String(key || "");
|
return String(key || "").startsWith("u:") ? "unknown" : String(key || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnknownSkuKey(key) {
|
||||||
|
return String(key || "").startsWith("u:");
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize for search: lowercase, punctuation -> space, collapse spaces
|
// Normalize for search: lowercase, punctuation -> space, collapse spaces
|
||||||
function normSearchText(s) {
|
function normSearchText(s) {
|
||||||
return String(s ?? "")
|
return String(s ?? "")
|
||||||
|
|
@ -536,23 +540,33 @@ function skuIsBC(allRows, skuKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function topSuggestions(allAgg, limit) {
|
function topSuggestions(allAgg, limit, otherPinnedSku) {
|
||||||
const scored = allAgg.map((it) => {
|
const scored = [];
|
||||||
|
for (const it of allAgg) {
|
||||||
|
if (!it) continue;
|
||||||
|
if (isUnknownSkuKey(it.sku)) continue;
|
||||||
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
const stores = it.stores ? it.stores.size : 0;
|
const stores = it.stores ? it.stores.size : 0;
|
||||||
const hasPrice = it.cheapestPriceNum !== null ? 1 : 0;
|
const hasPrice = it.cheapestPriceNum !== null ? 1 : 0;
|
||||||
const hasName = it.name ? 1 : 0;
|
const hasName = it.name ? 1 : 0;
|
||||||
return { it, s: stores * 2 + hasPrice * 1.2 + hasName * 1.0 };
|
scored.push({ it, s: stores * 2 + hasPrice * 1.2 + hasName * 1.0 });
|
||||||
});
|
}
|
||||||
scored.sort((a, b) => b.s - a.s);
|
scored.sort((a, b) => b.s - a.s);
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendSimilar(allAgg, pinned, limit) {
|
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku) {
|
||||||
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit);
|
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku);
|
||||||
|
|
||||||
const base = String(pinned.name || "");
|
const base = String(pinned.name || "");
|
||||||
const scored = [];
|
const scored = [];
|
||||||
for (const it of allAgg) {
|
for (const it of allAgg) {
|
||||||
if (!it || it.sku === pinned.sku) continue;
|
if (!it) continue;
|
||||||
|
if (isUnknownSkuKey(it.sku)) continue;
|
||||||
|
if (it.sku === pinned.sku) continue;
|
||||||
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
const s = similarityScore(base, it.name || "");
|
const s = similarityScore(base, it.name || "");
|
||||||
if (s > 0) scored.push({ it, s });
|
if (s > 0) scored.push({ it, s });
|
||||||
}
|
}
|
||||||
|
|
@ -586,7 +600,7 @@ async function renderSkuLinker() {
|
||||||
|
|
||||||
<div class="card" style="padding:14px;">
|
<div class="card" style="padding:14px;">
|
||||||
<div class="small" style="margin-bottom:10px;">
|
<div class="small" style="margin-bottom:10px;">
|
||||||
Search or pin items in each column. With both pinned, LINK SKU writes to viz/data/sku_links.json (local only).
|
Unknown SKUs are hidden. With both pinned, LINK SKU writes to data/sku_links.json (local only).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:16px;">
|
<div style="display:flex; gap:16px;">
|
||||||
|
|
@ -625,16 +639,25 @@ async function renderSkuLinker() {
|
||||||
|
|
||||||
const idx = await loadIndex();
|
const idx = await loadIndex();
|
||||||
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
||||||
const allAgg = aggregateBySku(allRows);
|
|
||||||
|
// Build candidates; hide unknown (u:...) entirely for this page
|
||||||
|
const allAgg = aggregateBySku(allRows).filter((it) => !isUnknownSkuKey(it.sku));
|
||||||
|
|
||||||
let pinnedL = null;
|
let pinnedL = null;
|
||||||
let pinnedR = null;
|
let pinnedR = null;
|
||||||
|
|
||||||
|
function openLinkHtml(url) {
|
||||||
|
const u = String(url || "").trim();
|
||||||
|
if (!u) return "";
|
||||||
|
return `<a class="badge" href="${esc(u)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">open</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCard(it, pinned) {
|
function renderCard(it, pinned) {
|
||||||
const storeCount = it.stores.size || 0;
|
const storeCount = it.stores.size || 0;
|
||||||
const plus = storeCount > 1 ? ` +${storeCount - 1}` : "";
|
const plus = storeCount > 1 ? ` +${storeCount - 1}` : "";
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
||||||
|
const open = openLinkHtml(it.sampleUrl || "");
|
||||||
return `
|
return `
|
||||||
<div class="item ${pinned ? "pinnedItem" : ""}" data-sku="${esc(it.sku)}">
|
<div class="item ${pinned ? "pinnedItem" : ""}" data-sku="${esc(it.sku)}">
|
||||||
<div class="itemRow">
|
<div class="itemRow">
|
||||||
|
|
@ -647,6 +670,7 @@ async function renderSkuLinker() {
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="mono">${esc(price)}</span>
|
<span class="mono">${esc(price)}</span>
|
||||||
<span class="badge">${esc(store)}${esc(plus)}</span>
|
<span class="badge">${esc(store)}${esc(plus)}</span>
|
||||||
|
${open}
|
||||||
</div>
|
</div>
|
||||||
<div class="meta"><span class="mono">${esc(it.sampleUrl || "")}</span></div>
|
<div class="meta"><span class="mono">${esc(it.sampleUrl || "")}</span></div>
|
||||||
${pinned ? `<div class="small">Pinned (click again to unpin)</div>` : ``}
|
${pinned ? `<div class="small">Pinned (click again to unpin)</div>` : ``}
|
||||||
|
|
@ -658,9 +682,18 @@ async function renderSkuLinker() {
|
||||||
|
|
||||||
function sideItems(query, otherPinned) {
|
function sideItems(query, otherPinned) {
|
||||||
const tokens = tokenizeQuery(query);
|
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);
|
// Never show same sku as other pinned
|
||||||
return topSuggestions(allAgg, 60);
|
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) {
|
function attachHandlers($root, side) {
|
||||||
|
|
@ -670,6 +703,14 @@ async function renderSkuLinker() {
|
||||||
const it = allAgg.find((x) => String(x.sku || "") === skuKey);
|
const it = allAgg.find((x) => String(x.sku || "") === skuKey);
|
||||||
if (!it) return;
|
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;
|
if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it;
|
||||||
else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it;
|
else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it;
|
||||||
|
|
||||||
|
|
@ -703,11 +744,16 @@ async function renderSkuLinker() {
|
||||||
}
|
}
|
||||||
if (!(pinnedL && pinnedR)) {
|
if (!(pinnedL && pinnedR)) {
|
||||||
$linkBtn.disabled = true;
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
$linkBtn.disabled = false;
|
$linkBtn.disabled = false;
|
||||||
$status.textContent = "";
|
if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
|
|
@ -719,11 +765,17 @@ async function renderSkuLinker() {
|
||||||
let tL = null, tR = null;
|
let tL = null, tR = null;
|
||||||
$qL.addEventListener("input", () => {
|
$qL.addEventListener("input", () => {
|
||||||
if (tL) clearTimeout(tL);
|
if (tL) clearTimeout(tL);
|
||||||
tL = setTimeout(updateAll, 50);
|
tL = setTimeout(() => {
|
||||||
|
$status.textContent = "";
|
||||||
|
updateAll();
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
$qR.addEventListener("input", () => {
|
$qR.addEventListener("input", () => {
|
||||||
if (tR) clearTimeout(tR);
|
if (tR) clearTimeout(tR);
|
||||||
tR = setTimeout(updateAll, 50);
|
tR = setTimeout(() => {
|
||||||
|
$status.textContent = "";
|
||||||
|
updateAll();
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
$linkBtn.addEventListener("click", async () => {
|
$linkBtn.addEventListener("click", async () => {
|
||||||
|
|
@ -732,6 +784,15 @@ async function renderSkuLinker() {
|
||||||
const a = String(pinnedL.sku || "");
|
const a = String(pinnedL.sku || "");
|
||||||
const b = String(pinnedR.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.
|
// Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku.
|
||||||
const aBC = skuIsBC(allRows, a);
|
const aBC = skuIsBC(allRows, a);
|
||||||
const bBC = skuIsBC(allRows, b);
|
const bBC = skuIsBC(allRows, b);
|
||||||
|
|
@ -744,7 +805,7 @@ async function renderSkuLinker() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const out = await apiWriteSkuLink(fromSku, toSku);
|
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) {
|
} catch (e) {
|
||||||
$status.textContent = `Write failed: ${String(e && e.message ? e.message : 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);
|
let cur = all.filter((x) => keySkuForRow(x) === want);
|
||||||
|
|
||||||
if (!cur.length) {
|
if (!cur.length) {
|
||||||
// debug: show some Keg N Cork synthetic keys to see what we're actually generating
|
|
||||||
const knc = all.filter(
|
const knc = all.filter(
|
||||||
(x) => String(x.storeLabel || x.store || "").toLowerCase().includes("keg") && !String(x.sku || "").trim()
|
(x) => String(x.storeLabel || x.store || "").toLowerCase().includes("keg") && !String(x.sku || "").trim()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
10
viz/serve.js
10
viz/serve.js
|
|
@ -5,7 +5,8 @@ const http = require("http");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const root = path.resolve(__dirname);
|
const root = path.resolve(__dirname); // viz/
|
||||||
|
const projectRoot = path.resolve(__dirname, ".."); // repo root
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
".html": "text/html; charset=utf-8",
|
".html": "text/html; charset=utf-8",
|
||||||
|
|
@ -26,7 +27,8 @@ function safePath(urlPath) {
|
||||||
return norm;
|
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() {
|
function readLinks() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,7 +59,7 @@ const server = http.createServer((req, res) => {
|
||||||
const u = req.url || "/";
|
const u = req.url || "/";
|
||||||
const url = new URL(u, "http://127.0.0.1");
|
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 (url.pathname === "/__stviz/sku-links") {
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const obj = readLinks();
|
const obj = readLinks();
|
||||||
|
|
@ -78,7 +80,7 @@ const server = http.createServer((req, res) => {
|
||||||
obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() });
|
obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() });
|
||||||
writeLinks(obj);
|
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) {
|
} catch (e) {
|
||||||
return sendJson(res, 400, { ok: false, error: String(e && e.message ? e.message : e) });
|
return sendJson(res, 400, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue