diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index f820e05..1557c1c 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -91,7 +91,6 @@ function storesOverlap(aItem, bItem) { const b = bItem?.stores; if (!a || !b) return false; - // stores are Set(storeLabel). Exact-label overlap is the intended rule. for (const s of a) { if (b.has(s)) return true; } @@ -116,7 +115,6 @@ function isBCStoreLabel(label) { 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; @@ -145,16 +143,8 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { return scored.slice(0, limit).map((x) => x.it); } -function recommendSimilar( - allAgg, - pinned, - limit, - otherPinnedSku, - mappedSkus, - isIgnoredPairFn -) { - if (!pinned || !pinned.name) - return topSuggestions(allAgg, 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 || ""); @@ -167,11 +157,8 @@ function recommendSimilar( if (it.sku === pinned.sku) continue; if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; - // NEW: never suggest same-store pairs if (storesOverlap(pinned, it)) continue; - - if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) - continue; + if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue; const s = similarityScore(base, it.name || ""); if (s > 0) scored.push({ it, s }); @@ -180,7 +167,6 @@ function recommendSimilar( return scored.slice(0, limit).map((x) => x.it); } -// FAST initial pairing (approx) with ignore-pair exclusion + same-store exclusion function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) { const items = allAgg.filter((it) => { if (!it) return false; @@ -197,9 +183,7 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const itemNormName = new Map(); for (const it of items) { - 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) { @@ -232,8 +216,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn if (isUnknownSkuKey(bSku)) continue; if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; - - // NEW: never suggest same-store pairs if (storesOverlap(a, b)) continue; cand.set(bSku, b); @@ -282,8 +264,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const bSku = String(p.b.sku || ""); if (!aSku || !bSku || aSku === bSku) continue; if (used.has(aSku) || used.has(bSku)) continue; - - // NEW: extra guard if (storesOverlap(p.a, p.b)) continue; used.add(aSku); @@ -359,20 +339,21 @@ export async function renderSkuLinker($app) { if (!r || r.removed) continue; const skuKey = String(keySkuForRow(r) || "").trim(); if (!skuKey) continue; + const storeLabel = String(r.storeLabel || r.store || "").trim(); const url = String(r.url || "").trim(); if (!storeLabel || !url) continue; + let m = URL_BY_SKU_STORE.get(skuKey); if (!m) URL_BY_SKU_STORE.set(skuKey, (m = new Map())); if (!m.has(storeLabel)) m.set(storeLabel, url); } - // candidates for this page (hide unknown u: entirely) const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku)); const meta = await loadSkuMetaBestEffort(); const mappedSkus = buildMappedSkuSet(meta.links || []); - const ignoreSet = rules.ignoreSet; // already canonicalized as "a|b" + const ignoreSet = rules.ignoreSet; function isIgnoredPair(a, b) { return rules.isIgnoredPair(String(a || ""), String(b || "")); @@ -389,7 +370,6 @@ export async function renderSkuLinker($app) { const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store"); - // IMPORTANT: link must match displayed store label const href = URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) || String(it.sampleUrl || "").trim() || @@ -407,21 +387,18 @@ export async function renderSkuLinker($app) {
${renderThumbHtml(it.img)}
+
-
+
${esc(it.name || "(no name)")}
+ ${pinned ? `
Pinned (click again to unpin)
` : ``} +
+ +
+
${esc(price)}
+ ${storeBadge} ${esc(displaySku(it.sku))}
- - -
- ${esc(price)} -
-
- ${storeBadge} -
- - ${pinned ? `
Pinned (click again to unpin)
` : ``}
@@ -446,7 +423,6 @@ export async function renderSkuLinker($app) { if (otherPinned) { const oSku = String(otherPinned.sku || ""); out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || ""))); - // NEW: never show same-store pairs when other side pinned out = out.filter((it) => !storesOverlap(otherPinned, it)); } return out; @@ -483,7 +459,6 @@ export async function renderSkuLinker($app) { return; } - // NEW: enforce same-store rule even on manual pinning if (other && storesOverlap(other, it)) { $status.textContent = "Not allowed: both items belong to the same store."; return; @@ -520,8 +495,7 @@ export async function renderSkuLinker($app) { 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."; + $status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1."; return; } @@ -542,7 +516,6 @@ export async function renderSkuLinker($app) { return; } - // NEW: same-store rule if (storesOverlap(pinnedL, pinnedR)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; @@ -558,11 +531,8 @@ export async function renderSkuLinker($app) { $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 = ""; - } + 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 = ""; } function updateAll() { @@ -571,8 +541,7 @@ export async function renderSkuLinker($app) { updateButtons(); } - let tL = null, - tR = null; + let tL = null, tR = null; $qL.addEventListener("input", () => { if (tL) clearTimeout(tL); tL = setTimeout(() => { @@ -615,19 +584,12 @@ export async function renderSkuLinker($app) { return; } - // Direction: if either is BC-based, 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; - } + 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)} …`; diff --git a/viz/app/search_page.js b/viz/app/search_page.js index b52a6fc..ffd3c17 100644 --- a/viz/app/search_page.js +++ b/viz/app/search_page.js @@ -39,6 +39,7 @@ export function renderSearch($app) { const out = new Map(); for (const r of Array.isArray(listings) ? listings : []) { if (!r || r.removed) continue; + const skuKey = String(keySkuForRow(r) || "").trim(); if (!skuKey) continue; @@ -80,7 +81,7 @@ export function renderSearch($app) { const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store"); - // IMPORTANT: link must match the displayed store label + // link must match displayed store label const href = urlForAgg(it, store); const storeBadge = href ? `
-
- ${renderThumbHtml(it.img)} -
+
${renderThumbHtml(it.img)}
+
-
+
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))}
- -
- ${esc(price)} -
-
+
+
${esc(price)}
${storeBadge} + ${esc(displaySku(it.sku))}
@@ -181,25 +178,21 @@ export function renderSearch($app) { return `
-
- ${renderThumbHtml(img)} -
+
${renderThumbHtml(img)}
+
-
+
${esc(r.name || "(no name)")}
- ${esc(displaySku(sku))} +
+ ${esc(kind)} +
- -
- ${esc(kind)} +
+
${esc(priceLine)}
${storeBadge} -
-
- ${esc(priceLine)} -
-
- ${esc(when)} +
${esc(when)}
+ ${esc(displaySku(sku))}
@@ -222,10 +215,7 @@ export function renderSearch($app) { if (!indexReady) return; const tokens = tokenizeQuery($q.value); - if (!tokens.length) { - // recent gets rendered later after rules load - return; - } + if (!tokens.length) return; const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens)); renderAggregates(matches); diff --git a/viz/style.css b/viz/style.css index 080cdc3..7d34706 100644 --- a/viz/style.css +++ b/viz/style.css @@ -19,7 +19,7 @@ body { a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } -/* Make badge-links look like badges (not accent-blue) + clearly clickable */ +/* badge links should look like badges, but clearly clickable */ a.badge { color: var(--muted); } a.badge:hover { text-decoration: underline; cursor: pointer; } @@ -82,20 +82,21 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } .itemRow { display: flex; gap: 12px; - align-items: flex-start; + align-items: stretch; /* key: let thumb fill height */ } .thumbBox { width: 64px; - height: 64px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); background: #0b0d10; flex: 0 0 64px; display: flex; - align-items: center; + align-items: stretch; justify-content: center; + height: auto; + min-height: 64px; } .thumb { @@ -114,6 +115,26 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } .itemBody { flex: 1; min-width: 0; + display: flex; + gap: 14px; + align-items: stretch; +} + +.itemMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} + +.itemFacts { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 8px; } .itemTop { @@ -141,7 +162,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } } .meta { - margin-top: 8px; display: flex; gap: 10px; flex-wrap: wrap; @@ -151,6 +171,12 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } +.priceBig { + font-size: 14px; + font-weight: 700; + color: var(--text); +} + .topbar { display: flex; align-items: center; @@ -232,9 +258,19 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } @media (max-width: 640px) { .container { padding: 14px; } - .thumbBox { width: 56px; height: 56px; flex: 0 0 56px; } + .thumbBox { width: 56px; flex: 0 0 56px; min-height: 56px; } .detailThumbBox { width: 84px; height: 84px; flex: 0 0 84px; } + /* on mobile, keep the "facts" inline so cards don't get super tall */ + .itemBody { flex-direction: column; gap: 10px; } + .itemFacts { + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: center; + gap: 10px; + } + .chartBox { height: 58vh; min-height: 260px;