From 3bedd13bab45ab918e040bd869ab973571578a32 Mon Sep 17 00:00:00 2001 From: Brennan Wilkes Date: Wed, 28 Jan 2026 10:54:10 -0800 Subject: [PATCH] feat: UI Improvements --- viz/app/item_page.js | 25 ++++++++--- viz/app/linker_page.js | 100 ++++++++++++++++++++++++++++++++--------- viz/app/pending.js | 4 +- 3 files changed, 101 insertions(+), 28 deletions(-) diff --git a/viz/app/item_page.js b/viz/app/item_page.js index b034c9d..486021f 100644 --- a/viz/app/item_page.js +++ b/viz/app/item_page.js @@ -67,18 +67,25 @@ function computeSuggestedY(values) { const nums = values.filter((v) => Number.isFinite(v)); if (!nums.length) return { suggestedMin: undefined, suggestedMax: undefined }; - let min = nums[0], - max = nums[0]; + let min = nums[0], max = nums[0]; for (const n of nums) { if (n < min) min = n; if (n > max) max = n; } - if (min === max) return { suggestedMin: min * 0.95, suggestedMax: max * 1.05 }; - const pad = (max - min) * 0.08; - return { suggestedMin: Math.max(0, min - pad), suggestedMax: max + pad }; + const range = max - min; + const pad = range === 0 ? Math.max(1, min * 0.05) : range * 0.08; + + const rawMin = Math.max(0, min - pad); + const rawMax = max + pad; + + const suggestedMin = Math.floor(rawMin / 10) * 10; + const suggestedMax = Math.ceil(rawMax / 10) * 10; + + return { suggestedMin, suggestedMax }; } + function cacheKeySeries(sku, dbFile, cacheBust) { return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`; } @@ -534,7 +541,13 @@ export async function renderItem($app, skuInput) { }, scales: { x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } }, - y: { ...ySug, ticks: { callback: (v) => `$${Number(v).toFixed(0)}` } }, + y: { + ...ySug, + ticks: { + stepSize: 10, + callback: (v) => `$${Number(v).toFixed(0)}`, + }, + }, }, }, }); diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index af7e6c9..1a2349d 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -22,6 +22,7 @@ import { addPendingIgnore, pendingCounts, movePendingToSubmitted, + clearPendingEdits, } from "./pending.js"; /* ---------------- Similarity helpers ---------------- */ @@ -51,7 +52,6 @@ function smwsKeyFromName(name) { return m ? m[1] : ""; } - function isNumberToken(t) { return /^\d+$/.test(String(t || "")); } @@ -131,10 +131,9 @@ function similarityScore(aName, bName) { const gate = firstMatch ? 1.0 : 0.12; const numGate = numberMismatchPenalty(aToks, bToks); - return numGate * ( - firstMatch * 3.0 + - overlapTail * 2.2 * gate + - levSim * (firstMatch ? 1.0 : 0.15) + return ( + numGate * + (firstMatch * 3.0 + overlapTail * 2.2 * gate + levSim * (firstMatch ? 1.0 : 0.15)) ); } @@ -198,7 +197,7 @@ function buildMappedSkuSet(links) { function isBCStoreLabel(label) { const s = String(label || "").toLowerCase(); - return s.includes("bcl") || s.includes("strath")|| s.includes("gull")|| s.includes("legacy"); + return s.includes("bcl") || s.includes("strath") || s.includes("gull") || s.includes("legacy"); } function skuIsBC(allRows, skuKey) { @@ -304,8 +303,7 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { } function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) { - if (!pinned || !pinned.name) - return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); + if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); const base = String(pinned.name || ""); const pinnedSku = String(pinned.sku || ""); @@ -319,7 +317,10 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; if (storesOverlap(pinned, it)) continue; - if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) + if ( + typeof isIgnoredPairFn === "function" && + isIgnoredPairFn(pinnedSku, String(it.sku || "")) + ) continue; // SMWS exact NUM.NUM match => force to top (requires SMWS + code match) @@ -348,7 +349,6 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI return scored.slice(0, limit).map((x) => x.it); } - function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) { const itemsAll = allAgg.filter((it) => !!it); @@ -396,7 +396,10 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn if (!arr0 || arr0.length < 2) continue; // Bound bucket size - const arr = arr0.slice().sort((a, b) => itemRank(b) - itemRank(a)).slice(0, 80); + const arr = arr0 + .slice() + .sort((a, b) => itemRank(b) - itemRank(a)) + .slice(0, 80); const mapped = []; const unmapped = []; @@ -407,8 +410,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn } // Pick best anchor (prefer mapped if available) - const anchor = - (mapped.length ? mapped : unmapped).slice().sort((a, b) => itemRank(b) - itemRank(a))[0]; + const anchor = (mapped.length ? mapped : unmapped) + .slice() + .sort((a, b) => itemRank(b) - itemRank(a))[0]; if (!anchor) continue; @@ -479,7 +483,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const itemNormName = new Map(); for (const it of work) { - const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10); + const toks = Array.from(new Set(tokenizeQuery(it.name || ""))) + .filter(Boolean) + .slice(0, 10); itemTokens.set(it.sku, toks); itemNormName.set(it.sku, normSearchText(it.name || "")); for (const t of toks) { @@ -573,7 +579,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn return out.slice(0, limitPairs); } - /* ---------------- Page ---------------- */ export async function renderSkuLinker($app) { @@ -585,6 +590,12 @@ export async function renderSkuLinker($app) {
+ ${!localWrite ? `` : ``} + ${ + !localWrite + ? `` + : `` + } SKU Linker ${ localWrite @@ -630,6 +641,8 @@ export async function renderSkuLinker($app) { const $linkBtn = document.getElementById("linkBtn"); const $ignoreBtn = document.getElementById("ignoreBtn"); const $status = document.getElementById("status"); + const $pendingTop = !localWrite ? document.getElementById("pendingTop") : null; + const $clearPendingBtn = !localWrite ? document.getElementById("clearPendingBtn") : null; $listL.innerHTML = `
Loading index…
`; $listR.innerHTML = `
Loading index…
`; @@ -676,7 +689,7 @@ export async function renderSkuLinker($app) { 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"); + const store = it.cheapestStoreLabel || [...it.stores][0] || "Store"; const href = URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) || @@ -684,7 +697,9 @@ export async function renderSkuLinker($app) { ""; const storeBadge = href - ? `${esc(store)}${esc(plus)}` + ? `${esc( + store + )}${esc(plus)}` : `${esc(store)}${esc(plus)}`; const pinnedBadge = pinned ? `PINNED` : ``; @@ -731,8 +746,7 @@ export async function renderSkuLinker($app) { } // auto-suggestions: never include mapped skus - if (otherPinned) - return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair); + 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); @@ -807,6 +821,14 @@ export async function renderSkuLinker($app) { $pr.disabled = c0.total === 0; } + if ($pendingTop && $clearPendingBtn) { + const c0 = pendingCounts(); + $pendingTop.textContent = c0.total ? `Pending: ${c0.total}` : ""; + $pendingTop.style.display = c0.total ? "inline-flex" : "none"; + $clearPendingBtn.style.display = c0.total ? "inline-flex" : "none"; + $clearPendingBtn.disabled = c0.total === 0; + } + if (!(pinnedL && pinnedR)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; @@ -817,7 +839,8 @@ export async function renderSkuLinker($app) { ? `Pending changes: ${c.links} link(s), ${c.ignores} ignore(s). Create PR when ready.` : "Pin one item on each side to enable linking / ignoring."; } else { - if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring."; + if (!$status.textContent) + $status.textContent = "Pin one item on each side to enable linking / ignoring."; } return; } @@ -861,6 +884,39 @@ export async function renderSkuLinker($app) { } } + if ($clearPendingBtn) { + $clearPendingBtn.addEventListener("click", async () => { + const c0 = pendingCounts(); + if (c0.total === 0) return; + + const ok = window.confirm( + `Clear ${c0.total} pending change(s)? This only clears local staged edits.` + ); + if (!ok) return; + + clearPendingEdits(); + + clearSkuRulesCache(); + rules = await loadSkuRules(); + ignoreSet = rules.ignoreSet; + + const rebuilt = buildMappedSkuSet(rules.links || []); + mappedSkus.clear(); + for (const x of rebuilt) mappedSkus.add(x); + + const $pr = document.getElementById("createPrBtn"); + if ($pr) { + const c = pendingCounts(); + $pr.disabled = c.total === 0; + } + + pinnedL = null; + pinnedR = null; + $status.textContent = "Cleared pending staged edits."; + updateAll(); + }); + } + const $createPrBtn = document.getElementById("createPrBtn"); if ($createPrBtn) { $createPrBtn.addEventListener("click", async () => { @@ -1025,7 +1081,9 @@ export async function renderSkuLinker($app) { try { for (let i = 0; i < uniq.length; i++) { const w = uniq[i]; - $status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku(w.toSku)} …`; + $status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku( + w.toSku + )} …`; await apiWriteSkuLink(w.fromSku, w.toSku); } diff --git a/viz/app/pending.js b/viz/app/pending.js index 91c1916..a92e977 100644 --- a/viz/app/pending.js +++ b/viz/app/pending.js @@ -164,7 +164,9 @@ export function applyPendingToMeta(meta) { // merge links (dedupe by from→to) const seenL = new Set( - base.links.map((x) => linkKey(String(x?.fromSku || "").trim(), String(x?.toSku || "").trim())).filter(Boolean) + base.links + .map((x) => linkKey(String(x?.fromSku || "").trim(), String(x?.toSku || "").trim())) + .filter(Boolean) ); for (const x of overlay.links) { const k = linkKey(x.fromSku, x.toSku);