diff --git a/viz/app/main.js b/viz/app/main.js index 9b3d51a..a2429d5 100644 --- a/viz/app/main.js +++ b/viz/app/main.js @@ -1,14 +1,16 @@ /** * Hash routes: - * #/ search - * #/item/ detail - * #/link sku linker (local-write only) + * #/ search + * #/item/ detail + * #/link sku linker (local-write only) + * #/store/ store page (in-stock only) */ import { destroyChart } from "./item_page.js"; import { renderSearch } from "./search_page.js"; import { renderItem } from "./item_page.js"; import { renderSkuLinker } from "./linker_page.js"; +import { renderStore } from "./store_page.js"; function route() { const $app = document.getElementById("app"); @@ -23,6 +25,7 @@ function route() { if (parts.length === 0) return renderSearch($app); if (parts[0] === "item" && parts[1]) return renderItem($app, decodeURIComponent(parts[1])); if (parts[0] === "link") return renderSkuLinker($app); + if (parts[0] === "store" && parts[1]) return renderStore($app, decodeURIComponent(parts[1])); return renderSearch($app); } diff --git a/viz/app/search_page.js b/viz/app/search_page.js index 2e64ce9..1fa3bbc 100644 --- a/viz/app/search_page.js +++ b/viz/app/search_page.js @@ -16,6 +16,16 @@ export function renderSearch($app) { Link SKUs +
+
+ + +
+
+
+
@@ -26,6 +36,10 @@ export function renderSearch($app) { const $q = document.getElementById("q"); const $results = document.getElementById("results"); + const $storeSelect = document.getElementById("storeSelect"); + const $storeFilter = document.getElementById("storeFilter"); + const $stores = document.getElementById("stores"); + $q.value = loadSavedQuery(); let aggBySku = new Map(); @@ -35,6 +49,53 @@ export function renderSearch($app) { // canonicalSku -> storeLabel -> url let URL_BY_SKU_STORE = new Map(); + // --- Store nav --- + let ALL_STORES = []; + + function storeLabelForRow(r) { + return String(r?.storeLabel || r?.store || "").trim(); + } + + function extractStores(listings) { + const s = new Set(); + for (const r of Array.isArray(listings) ? listings : []) { + const lab = storeLabelForRow(r); + if (lab) s.add(lab); + } + return Array.from(s).sort((a, b) => a.localeCompare(b)); + } + + function renderStoreSelect(stores) { + $storeSelect.innerHTML = + `` + + stores.map((x) => ``).join(""); + + $storeSelect.addEventListener("change", () => { + const v = String($storeSelect.value || ""); + if (!v) return; + location.hash = `#/store/${encodeURIComponent(v)}`; + $storeSelect.value = ""; + }); + } + + function renderStoreChips(filterText) { + const f = String(filterText || "").trim().toLowerCase(); + const filtered = !f ? ALL_STORES : ALL_STORES.filter((x) => String(x).toLowerCase().includes(f)); + + $stores.innerHTML = filtered.length + ? filtered + .map( + (lab) => + `${esc(lab)}` + ) + .join("") + : `
No store matches.
`; + } + + $storeFilter.addEventListener("input", () => { + renderStoreChips($storeFilter.value); + }); + function buildUrlMap(listings, canonicalSkuFn) { const out = new Map(); for (const r of Array.isArray(listings) ? listings : []) { @@ -157,14 +218,14 @@ export function renderSearch($app) { function rankRecent(r, canonSkuFn) { const rawSku = String(r?.sku || ""); const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku); - + const agg = aggBySku.get(sku) || null; - + const storeLabelRaw = String(r?.storeLabel || r?.store || "").trim(); const bestStoreRaw = String(agg?.cheapestStoreLabel || "").trim(); - + const normStore = (s) => String(s || "").trim().toLowerCase(); - + // Normalize kind let kind = String(r?.kind || ""); if (kind === "price_change") { @@ -175,36 +236,36 @@ export function renderSearch($app) { else if (n > o) kind = "price_up"; } } - + const pctOff = kind === "price_down" ? salePctOff(r?.oldPrice || "", r?.newPrice || "") : null; const pctUp = kind === "price_up" ? pctChange(r?.oldPrice || "", r?.newPrice || "") : null; - + const isNew = kind === "new"; const storeCount = agg?.stores?.size || 0; const isNewUnique = isNew && storeCount <= 1; - + // Cheapest checks (use aggregate index) const newPriceNum = kind === "price_down" || kind === "price_up" ? parsePriceToNumber(r?.newPrice || "") : null; const bestPriceNum = Number.isFinite(agg?.cheapestPriceNum) ? agg.cheapestPriceNum : null; - + const EPS = 0.01; const priceMatchesBest = Number.isFinite(newPriceNum) && Number.isFinite(bestPriceNum) ? Math.abs(newPriceNum - bestPriceNum) <= EPS : false; - + const storeIsBest = normStore(storeLabelRaw) && normStore(bestStoreRaw) && normStore(storeLabelRaw) === normStore(bestStoreRaw); - + const saleIsCheapestHere = kind === "price_down" && storeIsBest && priceMatchesBest; const saleIsTiedCheapest = kind === "price_down" && !storeIsBest && priceMatchesBest; const saleIsCheapest = saleIsCheapestHere || saleIsTiedCheapest; - + // Bucketed scoring (higher = earlier) let score = 0; - + // Helper for sales buckets function saleBucketScore(isCheapest, pct) { const p = Number.isFinite(pct) ? pct : 0; - + if (isCheapest) { if (p >= 20) return 9000 + p; // Bucket #1 if (p >= 10) return 7000 + p; // Bucket #3 @@ -217,7 +278,7 @@ export function renderSearch($app) { return 1000; // bottom-ish } } - + if (kind === "price_down") { score = saleBucketScore(saleIsCheapest, pctOff); } else if (isNewUnique) { @@ -234,16 +295,16 @@ export function renderSearch($app) { } else { score = 0; } - + // Tie-breaks: within bucket prefer bigger % for sales, then recency let tie = 0; if (kind === "price_down") tie = (pctOff || 0) * 100000 + tsValue(r); else if (kind === "price_up") tie = (pctUp || 0) * 100000 + tsValue(r); else tie = tsValue(r); - + return { sku, kind, pctOff, storeCount, isNewUnique, score, tie }; } - + function renderRecent(recent, canonicalSkuFn) { const items = Array.isArray(recent?.items) ? recent.items : []; if (!items.length) { @@ -373,15 +434,11 @@ export function renderSearch($app) { const offBadge = meta.kind === "price_down" && meta.pctOff !== null - ? `[${esc( - meta.pctOff - )}% Off]` + ? `[${esc(meta.pctOff)}% Off]` : ""; const kindBadgeStyle = - meta.kind === "new" && meta.isNewUnique - ? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"` - : ""; + meta.kind === "new" && meta.isNewUnique ? ` class="badge good"` : ` class="badge"`; return `
@@ -395,7 +452,7 @@ export function renderSearch($app) { ${esc(displaySku(sku))}
- ${esc(kindLabel)} + ${esc(kindLabel)} ${esc(priceLine)} ${offBadge} ${storeBadge} @@ -418,7 +475,6 @@ export function renderSearch($app) { } } - function applySearch() { if (!indexReady) return; @@ -455,6 +511,12 @@ export function renderSearch($app) { Promise.all([loadIndex(), loadSkuRules()]) .then(([idx, rules]) => { const listings = Array.isArray(idx.items) ? idx.items : []; + + // store nav: build once + ALL_STORES = extractStores(listings); + renderStoreSelect(ALL_STORES); + renderStoreChips($storeFilter.value); + allAgg = aggregateBySku(listings, rules.canonicalSku); aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); URL_BY_SKU_STORE = buildUrlMap(listings, rules.canonicalSku); diff --git a/viz/app/store_page.js b/viz/app/store_page.js new file mode 100644 index 0000000..26d2c58 --- /dev/null +++ b/viz/app/store_page.js @@ -0,0 +1,300 @@ +import { esc, renderThumbHtml } from "./dom.js"; +import { + tokenizeQuery, + matchesAllTokens, + displaySku, + keySkuForRow, + parsePriceToNumber, + normSearchText, +} from "./sku.js"; +import { loadIndex } from "./state.js"; +import { aggregateBySku } from "./catalog.js"; +import { loadSkuRules } from "./mapping.js"; + +function storeLabelForRow(r) { + return String(r?.storeLabel || r?.store || "").trim(); +} + +function extractStores(listings) { + const s = new Set(); + for (const r of Array.isArray(listings) ? listings : []) { + const lab = storeLabelForRow(r); + if (lab) s.add(lab); + } + return Array.from(s).sort((a, b) => a.localeCompare(b)); +} + +function urlQuality(u) { + const s = String(u || "").trim(); + if (!s) return -1; + let sc = 0; + sc += s.length; + if (/\bproduct\/\d+\//.test(s)) sc += 50; + if (/[a-z0-9-]{8,}/i.test(s)) sc += 10; + return sc; +} + +function pctClass(pct) { + if (!Number.isFinite(pct)) return "neutral"; + if (pct >= 5) return "good"; + if (pct <= -5) return "bad"; + return "neutral"; +} + +function pctLabel(pct) { + if (!Number.isFinite(pct)) return "No compare"; + const s = pct > 0 ? `+${pct}` : `${pct}`; + return `${s}% vs next`; +} + +export async function renderStore($app, storeLabelInput) { + $app.innerHTML = ` +
+
+ + ${esc(storeLabelInput || "Store")} +
+
+ +
+
Loading…
+ +
+
+
+ `; + + document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); + + const $subtitle = document.getElementById("subtitle"); + const $q = document.getElementById("q"); + const $results = document.getElementById("results"); + + $results.innerHTML = `
Loading index…
`; + + let idx, rules; + try { + [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]); + } catch (e) { + $results.innerHTML = `
Failed to load: ${esc(e?.message || String(e))}
`; + $subtitle.textContent = ""; + return; + } + + const listings = Array.isArray(idx?.items) ? idx.items : []; + const stores = extractStores(listings); + + // Normalize store label by case-insensitive match (helps if someone edits hash by hand) + const wantLower = String(storeLabelInput || "").trim().toLowerCase(); + const storeLabel = + stores.find((s) => String(s).toLowerCase() === wantLower) || String(storeLabelInput || "").trim(); + + // Update header badge text (if normalized) + const $badge = document.querySelector(".topbar .badge"); + if ($badge) $badge.textContent = storeLabel || "Store"; + + if (!storeLabel || !stores.includes(storeLabel)) { + $subtitle.textContent = "Unknown store."; + $results.innerHTML = + `
Pick a store:
` + + `
` + + stores.map((s) => `${esc(s)}`).join("") + + `
`; + return; + } + + // Build canonical aggregates + const allAgg = aggregateBySku(listings, rules.canonicalSku); + + // Build per-(canonical sku)->store min price + best URL (LIVE only) + const PRICE_BY_SKU_STORE = new Map(); // sku -> Map(store -> {priceNum, priceStr}) + const URL_BY_SKU_STORE = new Map(); // sku -> Map(store -> url) + + for (const r of listings) { + if (!r || r.removed) continue; + + const skuKey = String(keySkuForRow(r) || "").trim(); + if (!skuKey) continue; + + const sku = String(rules.canonicalSku(skuKey) || "").trim(); + if (!sku) continue; + + const lab = storeLabelForRow(r); + if (!lab) continue; + + // price + const pNum = parsePriceToNumber(r.price); + const pStr = String(r.price || "").trim(); + + let sm = PRICE_BY_SKU_STORE.get(sku); + if (!sm) PRICE_BY_SKU_STORE.set(sku, (sm = new Map())); + + if (pNum !== null) { + const prev = sm.get(lab); + if (!prev || pNum < prev.priceNum) sm.set(lab, { priceNum: pNum, priceStr: pStr }); + else if (prev && pNum === prev.priceNum && pStr && (!prev.priceStr || pStr.length < prev.priceStr.length)) + sm.set(lab, { priceNum: pNum, priceStr: pStr }); + } + + // url (prefer better) + const url = String(r.url || "").trim(); + if (url) { + let um = URL_BY_SKU_STORE.get(sku); + if (!um) URL_BY_SKU_STORE.set(sku, (um = new Map())); + + const prev = um.get(lab); + if (!prev) um.set(lab, url); + else { + const a = urlQuality(prev); + const b = urlQuality(url); + if (b > a) um.set(lab, url); + else if (b === a && url < prev) um.set(lab, url); + } + } + } + + // Build store-only list (in stock in this store), compute exclusive + pct vs next cheapest other store + const EPS = 0.01; + + const base = []; + for (const it of allAgg) { + if (!it || !it.stores || !it.stores.has(storeLabel)) continue; + + const pm = PRICE_BY_SKU_STORE.get(String(it.sku || "")); + const storeRec = pm?.get(storeLabel) || null; + const storePriceNum = storeRec?.priceNum ?? null; + const storePriceStr = storeRec?.priceStr || it.cheapestPriceStr || "(no price)"; + + let globalMin = null; + let otherMin = null; + + if (pm) { + for (const [lab, rec] of pm.entries()) { + const v = rec?.priceNum; + if (!Number.isFinite(v)) continue; + + globalMin = globalMin === null ? v : Math.min(globalMin, v); + + if (lab !== storeLabel) { + otherMin = otherMin === null ? v : Math.min(otherMin, v); + } + } + } + + const bestHere = globalMin !== null && storePriceNum !== null && Math.abs(storePriceNum - globalMin) <= EPS; + + let pct = null; + if (storePriceNum !== null && otherMin !== null && otherMin > 0) { + pct = Math.round(((otherMin - storePriceNum) / otherMin) * 100); + } + + const exclusive = it.stores.size === 1; + + const href = + URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(storeLabel) || + String(it.sampleUrl || "").trim() || + ""; + + const storeSearchText = normSearchText([it.searchText || "", href || ""].join(" | ")); + + base.push({ + ...it, + _exclusive: exclusive, + _bestHere: bestHere, + _pct: pct, + _storePriceNum: storePriceNum, + _storePriceStr: storePriceStr, + _href: href, + _storeSearchText: storeSearchText, + }); + } + + base.sort((a, b) => { + const ax = a._exclusive ? 0 : 1; + const bx = b._exclusive ? 0 : 1; + if (ax !== bx) return ax - bx; + + // Exclusives: stable by name + if (ax === 0) return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku)); + + const ap = Number.isFinite(a._pct) ? a._pct : -1e9; + const bp = Number.isFinite(b._pct) ? b._pct : -1e9; + if (bp !== ap) return bp - ap; + + return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku)); + }); + + $subtitle.textContent = `In-stock items for ${storeLabel} — Exclusives first, then best deals.`; + + function renderList(query) { + const tokens = tokenizeQuery(query); + + const items = !tokens.length + ? base + : base.filter((it) => matchesAllTokens(String(it._storeSearchText || ""), tokens)); + + if (!items.length) { + $results.innerHTML = `
No matches.
`; + return; + } + + $results.innerHTML = items + .map((it) => { + const exclusiveBadge = it._exclusive ? `EXCLUSIVE` : ``; + const bestBadge = it._bestHere ? `Best Price` : ``; + + const pct = it._pct; + const pctBadge = + it._exclusive + ? `` + : `${esc(pctLabel(pct))}`; + + const href = String(it._href || "").trim(); + const storeBadge = href + ? `${esc(storeLabel)}` + : `${esc(storeLabel)}`; + + const price = String(it._storePriceStr || "(no price)"); + + return ` +
+
+
${renderThumbHtml(it.img)}
+
+
+
${esc(it.name || "(no name)")}
+ ${esc(displaySku(it.sku))} +
+
+ ${exclusiveBadge} + ${bestBadge} + ${pctBadge} + ${esc(price)} + ${storeBadge} +
+
+
+
+ `; + }) + .join(""); + + for (const el of Array.from($results.querySelectorAll(".item"))) { + el.addEventListener("click", () => { + const sku = el.getAttribute("data-sku") || ""; + if (!sku) return; + location.hash = `#/item/${encodeURIComponent(sku)}`; + }); + } + } + + renderList(""); + + let t = null; + $q.addEventListener("input", () => { + if (t) clearTimeout(t); + t = setTimeout(() => renderList($q.value), 50); + }); +} diff --git a/viz/style.css b/viz/style.css index 65bcf72..75c1656 100644 --- a/viz/style.css +++ b/viz/style.css @@ -276,3 +276,68 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } position: sticky; bottom: 0; } + +/* --- Store nav (search page) --- */ +.storeNav { + margin-bottom: 12px; +} + +.storeNavRow { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.storeSelect { + flex: 1; +} + +.storeFilter { + width: 260px; + max-width: 100%; +} + +.storeList { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 160px; + overflow: auto; + padding-right: 2px; +} + +.storeChip { + user-select: none; +} + +@media (max-width: 640px) { + .storeFilter { display: none; } + .storeList { max-height: 220px; } +} + +/* --- Highlight badges (store page + reused) --- */ +.badge.good { + color: rgba(20,110,40,0.95); + background: rgba(20,110,40,0.10); + border: 1px solid rgba(20,110,40,0.20); +} + +.badge.bad { + color: rgba(160,40,40,0.95); + background: rgba(160,40,40,0.12); + border: 1px solid rgba(160,40,40,0.22); +} + +.badge.neutral { + color: var(--muted); + background: rgba(200,200,200,0.06); + border: 1px solid rgba(200,200,200,0.16); +} + +.badge.exclusive { + color: rgba(20,110,40,0.95); + background: rgba(20,110,40,0.10); + border: 1px solid rgba(20,110,40,0.20); +}