diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml index 1acc277..6939723 100644 --- a/.github/workflows/pages.yaml +++ b/.github/workflows/pages.yaml @@ -30,6 +30,25 @@ jobs: - name: Configure Pages uses: actions/configure-pages@v4 + # Make sku_links.json available to the static site without committing a second copy. + - name: Stage sku_links.json into site artifact + shell: bash + run: | + set -euo pipefail + mkdir -p viz/data + if [[ -f data/sku_links.json ]]; then + cp -f data/sku_links.json viz/data/sku_links.json + else + # keep site functional even if file is missing + cat > viz/data/sku_links.json <<'EOF' + { + "generatedAt": "", + "links": [], + "ignores": [] + } + EOF + fi + - name: Upload site artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/viz/app/api.js b/viz/app/api.js index 817bfe4..8c41374 100644 --- a/viz/app/api.js +++ b/viz/app/api.js @@ -1,74 +1,114 @@ export async function fetchJson(url) { - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); - return await res.json(); + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return await res.json(); +} + +export async function fetchText(url) { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return await res.text(); +} + +export function inferGithubOwnerRepo() { + const host = location.hostname || ""; + const m = host.match(/^([a-z0-9-]+)\.github\.io$/i); + if (m) { + const owner = m[1]; + const parts = (location.pathname || "/").split("/").filter(Boolean); + const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`; + return { owner, repo }; } - - export async function fetchText(url) { - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); - return await res.text(); + return { owner: "brennanwilkes", repo: "spirit-tracker" }; +} + +export function isLocalWriteMode() { + const h = String(location.hostname || "").toLowerCase(); + return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); +} + +/* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */ + +export async function apiReadSkuMetaFromLocalServer() { + const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; +} + +export 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(); +} + +export async function apiWriteSkuIgnore(skuA, skuB) { + const res = await fetch("/__stviz/sku-ignores", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ skuA, skuB }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); +} + +/** + * Best-effort read of sku meta: + * - On GitHub Pages: expects file at viz/data/sku_links.json + * - On local server: reads via /__stviz/sku-links (disk) + */ +export async function loadSkuMetaBestEffort() { + // 1) GitHub Pages / static deploy inside viz/ + try { + const j = await fetchJson("./data/sku_links.json"); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; + } catch {} + + // 2) alternate static path (in case you later serve viz under a subpath) + try { + const j = await fetchJson("/data/sku_links.json"); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; + } catch {} + + // 3) Local server API (disk) + try { + return await apiReadSkuMetaFromLocalServer(); + } catch {} + + return { links: [], ignores: [] }; +} + +/* ---- GitHub history helpers ---- */ + +export async function githubListCommits({ owner, repo, branch, path }) { + const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; + const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`; + const page1 = await fetchJson(u1); + + if (Array.isArray(page1) && page1.length === 100) { + const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`; + const page2 = await fetchJson(u2); + return [...page1, ...(Array.isArray(page2) ? page2 : [])]; } - - export function inferGithubOwnerRepo() { - const host = location.hostname || ""; - const m = host.match(/^([a-z0-9-]+)\.github\.io$/i); - if (m) { - const owner = m[1]; - const parts = (location.pathname || "/").split("/").filter(Boolean); - const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`; - return { owner, repo }; - } - return { owner: "brennanwilkes", repo: "spirit-tracker" }; - } - - export function isLocalWriteMode() { - const h = String(location.hostname || "").toLowerCase(); - return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); - } - - /* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */ - - export async function loadSkuLinksBestEffort() { - try { - const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); - if (!r.ok) return []; - const j = await r.json(); - return Array.isArray(j?.links) ? j.links : []; - } catch { - return []; - } - } - - export 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(); - } - - /* ---- GitHub history helpers ---- */ - - export async function githubListCommits({ owner, repo, branch, path }) { - const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; - const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`; - const page1 = await fetchJson(u1); - - if (Array.isArray(page1) && page1.length === 100) { - const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`; - const page2 = await fetchJson(u2); - return [...page1, ...(Array.isArray(page2) ? page2 : [])]; - } - - return Array.isArray(page1) ? page1 : []; - } - - export async function githubFetchFileAtSha({ owner, repo, sha, path }) { - const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(sha)}/${path}`; - const txt = await fetchText(raw); - return JSON.parse(txt); - } - \ No newline at end of file + + return Array.isArray(page1) ? page1 : []; +} + +export async function githubFetchFileAtSha({ owner, repo, sha, path }) { + const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(sha)}/${path}`; + const txt = await fetchText(raw); + return JSON.parse(txt); +} diff --git a/viz/app/catalog.js b/viz/app/catalog.js index 53f5236..859529e 100644 --- a/viz/app/catalog.js +++ b/viz/app/catalog.js @@ -1,25 +1,29 @@ import { normImg } from "./dom.js"; import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js"; -// Build one row per SKU + combined searchable text across all listings of that SKU -export function aggregateBySku(listings) { +// Build one row per *canonical* SKU (after applying sku map) + combined searchable text +export function aggregateBySku(listings, canonicalizeSkuFn) { + const canon = typeof canonicalizeSkuFn === "function" ? canonicalizeSkuFn : (x) => x; + const bySku = new Map(); for (const r of listings) { - const sku = keySkuForRow(r); + const rawSku = keySkuForRow(r); + const sku = canon(rawSku); const name = String(r?.name || ""); const url = String(r?.url || ""); const storeLabel = String(r?.storeLabel || r?.store || ""); const img = normImg(r?.img || r?.image || r?.thumb || ""); + const pNum = parsePriceToNumber(r?.price); const pStr = String(r?.price || ""); let agg = bySku.get(sku); if (!agg) { agg = { - sku, + sku, // canonical sku name: name || "", img: "", cheapestPriceStr: pStr || "", @@ -52,7 +56,7 @@ export function aggregateBySku(listings) { if (name) agg._imgByName.set(name, img); } - // cheapest + // cheapest (across all merged rows) if (pNum !== null) { if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) { agg.cheapestPriceNum = pNum; @@ -61,8 +65,9 @@ export function aggregateBySku(listings) { } } - // search parts + // search parts: include canonical + raw sku so searching either works agg._searchParts.push(sku); + if (rawSku && rawSku !== sku) agg._searchParts.push(rawSku); if (name) agg._searchParts.push(name); if (url) agg._searchParts.push(url); if (storeLabel) agg._searchParts.push(storeLabel); diff --git a/viz/app/item_page.js b/viz/app/item_page.js index 6255900..642fa8d 100644 --- a/viz/app/item_page.js +++ b/viz/app/item_page.js @@ -2,6 +2,7 @@ import { esc, renderThumbHtml, dateOnly } from "./dom.js"; import { parsePriceToNumber, keySkuForRow, displaySku } from "./sku.js"; import { loadIndex } from "./state.js"; import { inferGithubOwnerRepo, githubListCommits, githubFetchFileAtSha, fetchJson } from "./api.js"; +import { loadSkuRules } from "./mapping.js"; /* ---------------- Chart lifecycle ---------------- */ @@ -16,22 +17,44 @@ export function destroyChart() { /* ---------------- History helpers ---------------- */ -function findItemBySkuInDb(obj, skuKey, storeLabel) { +function findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel) { const items = Array.isArray(obj?.items) ? obj.items : []; + let best = null; + + // Build quick lookup for real sku entries (cheap) + const want = new Set(); + for (const s of skuKeys) { + const x = String(s || "").trim(); + if (x) want.add(x); + } + for (const it of items) { if (!it || it.removed) continue; const real = String(it.sku || "").trim(); - if (real && real === skuKey) return it; + if (real && want.has(real)) { + const p = parsePriceToNumber(it.price); + if (p !== null) best = best === null ? p : Math.min(best, p); + continue; + } - // synthetic match for blank sku items: hash storeLabel|url - if (!real && String(skuKey || "").startsWith("u:")) { - const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" }; - const k = keySkuForRow(row); - if (k === skuKey) return it; + // synthetic match (only relevant if a caller passes u: keys) + if (!real) { + // if any skuKey is synthetic, match by hashing storeLabel|url + for (const skuKey of skuKeys) { + const k = String(skuKey || ""); + if (!k.startsWith("u:")) continue; + const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" }; + const kk = keySkuForRow(row); + if (kk === k) { + const p = parsePriceToNumber(it.price); + if (p !== null) best = best === null ? p : Math.min(best, p); + } + } } } - return null; + + return best; } function computeSuggestedY(values) { @@ -62,7 +85,7 @@ function collapseCommitsToDaily(commits) { } function cacheKeySeries(sku, dbFile, cacheBust) { - return `stviz:v2:series:${cacheBust}:${sku}:${dbFile}`; + return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`; } function loadSeriesCache(sku, dbFile, cacheBust) { @@ -100,9 +123,11 @@ async function loadDbCommitsManifest() { /* ---------------- Page ---------------- */ -export async function renderItem($app, sku) { +export async function renderItem($app, skuInput) { destroyChart(); - console.log("[renderItem] skuKey=", sku); + + const rules = await loadSkuRules(); + const sku = rules.canonicalSku(String(skuInput || "")); $app.innerHTML = `
@@ -140,8 +165,11 @@ export async function renderItem($app, sku) { const idx = await loadIndex(); const all = Array.isArray(idx.items) ? idx.items : []; - const want = String(sku || ""); - const cur = all.filter((x) => keySkuForRow(x) === want); + + // include toSku + all fromSkus mapped to it + const skuGroup = rules.groupForCanonical(sku); + + const cur = all.filter((x) => skuGroup.has(String(keySkuForRow(x) || ""))); if (!cur.length) { $title.textContent = "Item not found in current index"; @@ -150,6 +178,7 @@ export async function renderItem($app, sku) { return; } + // pick bestName by most common across merged rows const nameCounts = new Map(); for (const r of cur) { const n = String(r.name || ""); @@ -167,12 +196,26 @@ export async function renderItem($app, sku) { } $title.textContent = bestName; - // pick image that matches bestName (fallback any) + // choose thumbnail from cheapest listing across merged rows (fallback: first that matches name) let bestImg = ""; + let bestPrice = null; + for (const r of cur) { - if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) { - bestImg = String(r.img).trim(); - break; + const p = parsePriceToNumber(r.price); + const img = String(r?.img || "").trim(); + if (p !== null && img) { + if (bestPrice === null || p < bestPrice) { + bestPrice = p; + bestImg = img; + } + } + } + if (!bestImg) { + for (const r of cur) { + if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) { + bestImg = String(r.img).trim(); + break; + } } } if (!bestImg) { @@ -183,8 +226,10 @@ export async function renderItem($app, sku) { } } } + $thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `
`; + // show store links from merged rows (may include multiple per store; OK) $links.innerHTML = cur .slice() .sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || ""))) @@ -196,8 +241,14 @@ export async function renderItem($app, sku) { const repo = gh.repo; const branch = "data"; + // dbFile -> rows (because merged skus can exist in same dbFile) const byDbFile = new Map(); - for (const r of cur) if (r.dbFile && !byDbFile.has(r.dbFile)) byDbFile.set(r.dbFile, r); + for (const r of cur) { + if (!r.dbFile) continue; + const k = String(r.dbFile); + if (!byDbFile.has(k)) byDbFile.set(k, []); + byDbFile.get(k).push(r); + } const dbFiles = [...byDbFile.keys()].sort(); $status.textContent = `Loading history for ${dbFiles.length} store file(s)…`; @@ -210,9 +261,11 @@ export async function renderItem($app, sku) { const cacheBust = String(idx.generatedAt || new Date().toISOString()); const today = dateOnly(idx.generatedAt || new Date().toISOString()); + const skuKeys = [...skuGroup]; + for (const dbFile of dbFiles) { - const row = byDbFile.get(dbFile); - const storeLabel = String(row.storeLabel || row.store || dbFile); + const rows = byDbFile.get(dbFile) || []; + const storeLabel = String(rows[0]?.storeLabel || rows[0]?.store || dbFile); const cached = loadSeriesCache(sku, dbFile, cacheBust); if (cached && Array.isArray(cached.points) && cached.points.length) { @@ -275,23 +328,25 @@ export async function renderItem($app, sku) { } } - const it = findItemBySkuInDb(obj, sku, storeLabel); - const pNum = it ? parsePriceToNumber(it.price) : null; + const pNum = findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel); points.set(d, pNum); if (pNum !== null) values.push(pNum); allDatesSet.add(d); - compactPoints.push({ date: d, price: pNum }); } - // Always add "today" from current index row - const curP = parsePriceToNumber(row.price); - if (curP !== null) { - points.set(today, curP); - values.push(curP); + // Always add "today" from current index (min across merged rows in this store/dbFile) + let curMin = null; + for (const r of rows) { + const p = parsePriceToNumber(r.price); + if (p !== null) curMin = curMin === null ? p : Math.min(curMin, p); + } + if (curMin !== null) { + points.set(today, curMin); + values.push(curMin); allDatesSet.add(today); - compactPoints.push({ date: today, price: curP }); + compactPoints.push({ date: today, price: curMin }); } saveSeriesCache(sku, dbFile, cacheBust, compactPoints); @@ -316,7 +371,6 @@ export async function renderItem($app, sku) { })); const ctx = $canvas.getContext("2d"); - // Chart is global from the UMD script include CHART = new Chart(ctx, { type: "line", data: { labels, datasets }, diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index 2ce1cfd..daab4e8 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -2,7 +2,8 @@ import { esc, renderThumbHtml } from "./dom.js"; import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js"; import { loadIndex } from "./state.js"; import { aggregateBySku } from "./catalog.js"; -import { isLocalWriteMode, loadSkuLinksBestEffort, apiWriteSkuLink } from "./api.js"; +import { isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js"; +import { loadSkuRules } from "./mapping.js"; /* ---------------- Similarity helpers ---------------- */ @@ -79,6 +80,29 @@ function buildMappedSkuSet(links) { return s; } +function openLinkHtml(url) { + const u = String(url || "").trim(); + if (!u) return ""; + return `open`; +} + +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; +} + +/* ---------------- Suggestion helpers ---------------- */ + function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { const scored = []; for (const it of allAgg) { @@ -96,11 +120,13 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { return scored.slice(0, limit).map((x) => x.it); } -function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) { +function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) { if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); const base = String(pinned.name || ""); + const pinnedSku = String(pinned.sku || ""); const scored = []; + for (const it of allAgg) { if (!it) continue; if (isUnknownSkuKey(it.sku)) continue; @@ -108,6 +134,8 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) { if (it.sku === pinned.sku) continue; if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; + if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue; + const s = similarityScore(base, it.name || ""); if (s > 0) scored.push({ it, s }); } @@ -115,8 +143,8 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) { return scored.slice(0, limit).map((x) => x.it); } -// FAST initial pairing (approx) -function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) { +// FAST initial pairing (approx) with ignore-pair exclusion +function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) { const items = allAgg.filter((it) => { if (!it) return false; if (isUnknownSkuKey(it.sku)) return false; @@ -162,6 +190,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) { if (!bSku || bSku === aSku) continue; if (mappedSkus && mappedSkus.has(bSku)) continue; if (isUnknownSkuKey(bSku)) continue; + + if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; + cand.set(bSku, b); } if (cand.size >= MAX_CAND_TOTAL) break; @@ -216,31 +247,11 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) { return out; } -function openLinkHtml(url) { - const u = String(url || "").trim(); - if (!u) return ""; - return `open`; -} - -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; -} - /* ---------------- Page ---------------- */ export async function renderSkuLinker($app) { const localWrite = isLocalWriteMode(); + const rules = await loadSkuRules(); $app.innerHTML = `
@@ -253,7 +264,7 @@ export async function renderSkuLinker($app) {
- Unknown SKUs are hidden. Existing mapped SKUs are excluded. With both pinned, LINK SKU writes to sku_links.json (local only). + Unknown SKUs are hidden. Existing mapped SKUs are excluded. LINK SKU writes map; IGNORE PAIR writes a "do-not-suggest" pair (local only).
@@ -273,6 +284,7 @@ export async function renderSkuLinker($app) {
@@ -285,6 +297,7 @@ export async function renderSkuLinker($app) { const $listL = document.getElementById("listL"); const $listR = document.getElementById("listR"); const $linkBtn = document.getElementById("linkBtn"); + const $ignoreBtn = document.getElementById("ignoreBtn"); const $status = document.getElementById("status"); $listL.innerHTML = `
Loading index…
`; @@ -293,12 +306,18 @@ export async function renderSkuLinker($app) { const idx = await loadIndex(); const allRows = Array.isArray(idx.items) ? idx.items : []; - const allAgg = aggregateBySku(allRows).filter((it) => !isUnknownSkuKey(it.sku)); + // candidates for this page (hide unknown u: entirely) + const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku)); - const existingLinks = await loadSkuLinksBestEffort(); - const mappedSkus = buildMappedSkuSet(existingLinks); + const meta = await loadSkuMetaBestEffort(); + const mappedSkus = buildMappedSkuSet(meta.links || []); + const ignoreSet = rules.ignoreSet; // already canonicalized as "a|b" - const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28); + function isIgnoredPair(a, b) { + return rules.isIgnoredPair(String(a || ""), String(b || "")); + } + + const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28, isIgnoredPair); let pinnedL = null; let pinnedR = null; @@ -336,12 +355,19 @@ export async function renderSkuLinker($app) { const otherSku = otherPinned ? String(otherPinned.sku || "") : ""; if (tokens.length) { - return allAgg + const out = allAgg .filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens)) .slice(0, 80); + + // if the other side is pinned, also exclude ignored pairs + if (otherPinned) { + const oSku = String(otherPinned.sku || ""); + return out.filter((it) => !isIgnoredPair(oSku, String(it.sku || ""))); + } + return out; } - if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus); + if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair); if (initialPairs && initialPairs.length) { const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b); @@ -396,35 +422,50 @@ export async function renderSkuLinker($app) { attachHandlers($list, side); } - function updateButton() { + function updateButtons() { if (!localWrite) { $linkBtn.disabled = true; + $ignoreBtn.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; - if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking."; + $ignoreBtn.disabled = true; + if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring."; return; } - if (String(pinnedL.sku || "") === String(pinnedR.sku || "")) { + + const a = String(pinnedL.sku || ""); + const b = String(pinnedR.sku || ""); + + if (a === b) { $linkBtn.disabled = true; + $ignoreBtn.disabled = true; $status.textContent = "Not allowed: both sides cannot be the same SKU."; return; } - if (mappedSkus.has(String(pinnedL.sku)) || mappedSkus.has(String(pinnedR.sku))) { + + if (mappedSkus.has(a) || mappedSkus.has(b)) { $linkBtn.disabled = true; - $status.textContent = "Not allowed: one of these SKUs is already mapped."; - return; + $ignoreBtn.disabled = false; // still allow ignoring even if mapped? you can decide; default allow + } else { + $linkBtn.disabled = false; + $ignoreBtn.disabled = false; + } + + if (isIgnoredPair(a, b)) { + $status.textContent = "This pair is already ignored."; + } else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") { + $status.textContent = ""; } - $linkBtn.disabled = false; - if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = ""; } function updateAll() { renderSide("L"); renderSide("R"); - updateButton(); + updateButtons(); } let tL = null, tR = null; @@ -461,8 +502,12 @@ export async function renderSkuLinker($app) { $status.textContent = "Not allowed: one of these SKUs is already mapped."; return; } + if (isIgnoredPair(a, b)) { + $status.textContent = "This pair is already ignored."; + return; + } - // Direction: if either is BC-based, FROM is BC sku. + // Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku. const aBC = skuIsBC(allRows, a); const bBC = skuIsBC(allRows, b); @@ -490,5 +535,39 @@ export async function renderSkuLinker($app) { } }); + $ignoreBtn.addEventListener("click", async () => { + if (!(pinnedL && pinnedR) || !localWrite) return; + + 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 ignored."; + return; + } + if (a === b) { + $status.textContent = "Not allowed: both sides cannot be the same SKU."; + return; + } + if (isIgnoredPair(a, b)) { + $status.textContent = "This pair is already ignored."; + return; + } + + $status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)} …`; + + try { + const out = await apiWriteSkuIgnore(a, b); + // update in-memory ignore set + ignoreSet.add(rules.canonicalPairKey(a, b)); + $status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`; + pinnedL = null; + pinnedR = null; + updateAll(); + } catch (e) { + $status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`; + } + }); + updateAll(); } diff --git a/viz/app/mapping.js b/viz/app/mapping.js new file mode 100644 index 0000000..310d13b --- /dev/null +++ b/viz/app/mapping.js @@ -0,0 +1,124 @@ +import { loadSkuMetaBestEffort } from "./api.js"; + +let CACHED = null; + +function canonicalPairKey(a, b) { + const x = String(a || ""); + const y = String(b || ""); + if (!x || !y) return ""; + return x < y ? `${x}|${y}` : `${y}|${x}`; +} + +function buildForwardMap(links) { + const m = new Map(); + for (const x of Array.isArray(links) ? links : []) { + const fromSku = String(x?.fromSku || "").trim(); + const toSku = String(x?.toSku || "").trim(); + if (fromSku && toSku && fromSku !== toSku) m.set(fromSku, toSku); + } + return m; +} + +function resolveSkuWithMap(sku, forwardMap) { + const s0 = String(sku || "").trim(); + if (!s0) return s0; + + // Only resolve real SKUs; leave synthetic u: alone + if (s0.startsWith("u:")) return s0; + + const seen = new Set(); + let cur = s0; + while (forwardMap.has(cur)) { + if (seen.has(cur)) break; // cycle guard + seen.add(cur); + cur = String(forwardMap.get(cur) || "").trim() || cur; + } + return cur || s0; +} + +function buildToGroups(links, forwardMap) { + // group: canonical toSku -> Set(all skus mapping to it, transitively) incl toSku itself + const groups = new Map(); + + // seed: include all explicit endpoints + for (const x of Array.isArray(links) ? links : []) { + const fromSku = String(x?.fromSku || "").trim(); + const toSku = String(x?.toSku || "").trim(); + if (!fromSku || !toSku) continue; + + const canonTo = resolveSkuWithMap(toSku, forwardMap); + if (!groups.has(canonTo)) groups.set(canonTo, new Set([canonTo])); + groups.get(canonTo).add(fromSku); + groups.get(canonTo).add(toSku); + } + + // close transitively: any sku that resolves to canonTo belongs in its group + // (cheap pass: expand by resolving all known skus in current link set) + const allSkus = new Set(); + for (const x of Array.isArray(links) ? links : []) { + const a = String(x?.fromSku || "").trim(); + const b = String(x?.toSku || "").trim(); + if (a) allSkus.add(a); + if (b) allSkus.add(b); + } + + for (const s of allSkus) { + const canon = resolveSkuWithMap(s, forwardMap); + if (!groups.has(canon)) groups.set(canon, new Set([canon])); + groups.get(canon).add(s); + } + + return groups; +} + +function buildIgnoreSet(ignores) { + const s = new Set(); + for (const x of Array.isArray(ignores) ? ignores : []) { + const a = String(x?.skuA || x?.a || x?.left || "").trim(); + const b = String(x?.skuB || x?.b || x?.right || "").trim(); + const k = canonicalPairKey(a, b); + if (k) s.add(k); + } + return s; +} + +export async function loadSkuRules() { + if (CACHED) return CACHED; + + const meta = await loadSkuMetaBestEffort(); + const links = Array.isArray(meta?.links) ? meta.links : []; + const ignores = Array.isArray(meta?.ignores) ? meta.ignores : []; + + const forwardMap = buildForwardMap(links); + const toGroups = buildToGroups(links, forwardMap); + const ignoreSet = buildIgnoreSet(ignores); + + function canonicalSku(sku) { + return resolveSkuWithMap(sku, forwardMap); + } + + function groupForCanonical(toSku) { + const canon = canonicalSku(toSku); + const g = toGroups.get(canon); + return g ? new Set(g) : new Set([canon]); + } + + function isIgnoredPair(a, b) { + const k = canonicalPairKey(a, b); + return k ? ignoreSet.has(k) : false; + } + + CACHED = { + links, + ignores, + forwardMap, + toGroups, + ignoreSet, + canonicalSku, + groupForCanonical, + isIgnoredPair, + canonicalPairKey, + }; + + return CACHED; +} diff --git a/viz/app/search_page.js b/viz/app/search_page.js index 03eca14..98dca4e 100644 --- a/viz/app/search_page.js +++ b/viz/app/search_page.js @@ -2,6 +2,7 @@ import { esc, renderThumbHtml, prettyTs } from "./dom.js"; import { tokenizeQuery, matchesAllTokens, displaySku } from "./sku.js"; import { loadIndex, loadRecent, loadSavedQuery, saveQuery } from "./state.js"; import { aggregateBySku } from "./catalog.js"; +import { loadSkuRules } from "./mapping.js"; export function renderSearch($app) { $app.innerHTML = ` @@ -79,13 +80,15 @@ export function renderSearch($app) { } } - function renderRecent(recent) { + function renderRecent(recent, canonicalSkuFn) { const items = Array.isArray(recent?.items) ? recent.items : []; if (!items.length) { $results.innerHTML = `
Type to search…
`; return; } + const canon = typeof canonicalSkuFn === "function" ? canonicalSkuFn : (x) => x; + const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3; const limited = items.slice(0, 140); @@ -115,7 +118,9 @@ export function renderSearch($app) { const when = r.ts ? prettyTs(r.ts) : r.date || ""; - const sku = String(r.sku || ""); + const rawSku = String(r.sku || ""); + const sku = canon(rawSku); + const img = aggBySku.get(sku)?.img || ""; return ` @@ -162,11 +167,7 @@ export function renderSearch($app) { const tokens = tokenizeQuery($q.value); if (!tokens.length) { - loadRecent() - .then(renderRecent) - .catch(() => { - $results.innerHTML = `
Type to search…
`; - }); + // recent gets rendered later after rules load return; } @@ -176,18 +177,20 @@ export function renderSearch($app) { $results.innerHTML = `
Loading index…
`; - loadIndex() - .then((idx) => { + Promise.all([loadIndex(), loadSkuRules()]) + .then(([idx, rules]) => { const listings = Array.isArray(idx.items) ? idx.items : []; - allAgg = aggregateBySku(listings); + allAgg = aggregateBySku(listings, rules.canonicalSku); aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); indexReady = true; $q.focus(); - applySearch(); - return loadRecent(); - }) - .then((recent) => { - if (!tokenizeQuery($q.value).length) renderRecent(recent); + + const tokens = tokenizeQuery($q.value); + if (tokens.length) { + applySearch(); + } else { + return loadRecent().then((recent) => renderRecent(recent, rules.canonicalSku)); + } }) .catch((e) => { $results.innerHTML = `
Failed to load: ${esc(e.message)}
`; @@ -197,6 +200,17 @@ export function renderSearch($app) { $q.addEventListener("input", () => { saveQuery($q.value); if (t) clearTimeout(t); - t = setTimeout(applySearch, 50); + t = setTimeout(() => { + const tokens = tokenizeQuery($q.value); + if (!tokens.length) { + loadSkuRules() + .then((rules) => loadRecent().then((recent) => renderRecent(recent, rules.canonicalSku))) + .catch(() => { + $results.innerHTML = `
Type to search…
`; + }); + return; + } + applySearch(); + }, 50); }); } diff --git a/viz/serve.js b/viz/serve.js index f9d7e05..81e8d12 100755 --- a/viz/serve.js +++ b/viz/serve.js @@ -30,16 +30,20 @@ function safePath(urlPath) { // Project-level file (shared by viz + report tooling) const LINKS_FILE = path.join(projectRoot, "data", "sku_links.json"); -function readLinks() { +function readMeta() { try { const raw = fs.readFileSync(LINKS_FILE, "utf8"); const obj = JSON.parse(raw); - if (obj && Array.isArray(obj.links)) return obj; + + const links = obj && Array.isArray(obj.links) ? obj.links : []; + const ignores = obj && Array.isArray(obj.ignores) ? obj.ignores : []; + + return { generatedAt: obj?.generatedAt || new Date().toISOString(), links, ignores }; } catch {} - return { generatedAt: new Date().toISOString(), links: [] }; + return { generatedAt: new Date().toISOString(), links: [], ignores: [] }; } -function writeLinks(obj) { +function writeMeta(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"); @@ -59,11 +63,12 @@ const server = http.createServer((req, res) => { const u = req.url || "/"; const url = new URL(u, "http://127.0.0.1"); - // Local API: append / read sku links file on disk (only exists when using this local server) + // Local API: read/write sku links + ignore pairs on disk (only exists when using this local server) + 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 }); + const obj = readMeta(); + return sendJson(res, 200, { ok: true, count: obj.links.length, links: obj.links, ignores: obj.ignores }); } if (req.method === "POST") { @@ -76,9 +81,9 @@ const server = http.createServer((req, res) => { const toSku = String(inp.toSku || "").trim(); if (!fromSku || !toSku) return sendJson(res, 400, { ok: false, error: "fromSku/toSku required" }); - const obj = readLinks(); + const obj = readMeta(); obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() }); - writeLinks(obj); + writeMeta(obj); return sendJson(res, 200, { ok: true, count: obj.links.length, file: "data/sku_links.json" }); } catch (e) { @@ -91,6 +96,38 @@ const server = http.createServer((req, res) => { return send(res, 405, "Method Not Allowed"); } + if (url.pathname === "/__stviz/sku-ignores") { + if (req.method === "GET") { + const obj = readMeta(); + return sendJson(res, 200, { ok: true, count: obj.ignores.length, ignores: obj.ignores }); + } + + if (req.method === "POST") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + try { + const inp = JSON.parse(body || "{}"); + const skuA = String(inp.skuA || "").trim(); + const skuB = String(inp.skuB || "").trim(); + if (!skuA || !skuB) return sendJson(res, 400, { ok: false, error: "skuA/skuB required" }); + if (skuA === skuB) return sendJson(res, 400, { ok: false, error: "skuA and skuB must differ" }); + + const obj = readMeta(); + obj.ignores.push({ skuA, skuB, createdAt: new Date().toISOString() }); + writeMeta(obj); + + return sendJson(res, 200, { ok: true, count: obj.ignores.length, file: "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) {