From 13f15a8eb0d40ecedb1f96ca81ee81bbad1253a2 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Fri, 30 Jan 2026 13:37:34 -0800 Subject: [PATCH] feat: V3 store page --- viz/app/linker_page.js | 41 ++++ viz/app/search_page.js | 108 +++++------ viz/app/store_page.js | 424 ++++++++++++++++++++++------------------- viz/style.css | 112 ++++++----- 4 files changed, 384 insertions(+), 301 deletions(-) diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index a15a913..b3b7312 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -1114,11 +1114,52 @@ export async function renderSkuLinker($app) { }); } + function findAggForPreselectSku(rawSku) { + const want = String(rawSku || "").trim(); + if (!want) return null; + + // exact match first + let it = allAgg.find((x) => String(x?.sku || "") === want); + if (it) return it; + + // try canonical group match + const canonWant = String(rules.canonicalSku(want) || want).trim(); + if (!canonWant) return null; + + it = allAgg.find((x) => String(x?.sku || "") === canonWant); + if (it) return it; + + // any member whose canonicalSku matches + return ( + allAgg.find((x) => String(rules.canonicalSku(String(x?.sku || "")) || "") === canonWant) || + null + ); + } + function updateAll() { + // One-time left preselect from hash query: + // #/link/?left= + // (works with your router because "link" stays as the first path segment) + if (!updateAll._didPreselect) { + updateAll._didPreselect = true; + + const h = String(location.hash || ""); + const qi = h.indexOf("?"); + if (qi !== -1) { + const qs = new URLSearchParams(h.slice(qi + 1)); + const leftSku = String(qs.get("left") || qs.get("sku") || "").trim(); + if (leftSku && !pinnedL) { + const it = findAggForPreselectSku(leftSku); + if (it) pinnedL = it; + } + } + } + renderSide("L"); renderSide("R"); updateButtons(); } + let tL = null, tR = null; diff --git a/viz/app/search_page.js b/viz/app/search_page.js index c600517..9dfecc9 100644 --- a/viz/app/search_page.js +++ b/viz/app/search_page.js @@ -9,16 +9,18 @@ export function renderSearch($app) { $app.innerHTML = `
-
+

Spirit Tracker Viz

Search name / url / sku (word AND)
-
Stores:
-
+
- Link SKUs + +
+ Link SKUs +
@@ -30,7 +32,7 @@ export function renderSearch($app) { const $q = document.getElementById("q"); const $results = document.getElementById("results"); - const $storeBar = document.getElementById("storeBar"); + const $stores = document.getElementById("stores"); $q.value = loadSavedQuery(); @@ -41,14 +43,6 @@ export function renderSearch($app) { // canonicalSku -> storeLabel -> url let URL_BY_SKU_STORE = new Map(); - function normStoreLabel(s) { - return String(s || "").trim().toLowerCase(); - } - - function storeLabelFromRow(r) { - return String(r?.storeLabel || r?.store || "").trim(); - } - function buildUrlMap(listings, canonicalSkuFn) { const out = new Map(); for (const r of Array.isArray(listings) ? listings : []) { @@ -60,7 +54,7 @@ export function renderSearch($app) { const sku = String(canonicalSkuFn ? canonicalSkuFn(skuKey) : skuKey); if (!sku) continue; - const storeLabel = storeLabelFromRow(r); + const storeLabel = String(r.storeLabel || r.store || "").trim(); const url = String(r.url || "").trim(); if (!storeLabel || !url) continue; @@ -77,17 +71,26 @@ export function renderSearch($app) { return URL_BY_SKU_STORE.get(sku)?.get(s) || ""; } - function renderStoreBar(listingsLive, storeLabelMapDisplay) { - if (!$storeBar) return; + function normStoreLabel(s) { + return String(s || "").trim(); + } + + function renderStoreButtons(listings) { + // include all stores seen (live or removed) so the selector is stable + const set = new Set(); + for (const r of Array.isArray(listings) ? listings : []) { + const lab = normStoreLabel(r?.storeLabel || r?.store || ""); + if (lab) set.add(lab); + } + const stores = Array.from(set).sort((a, b) => a.localeCompare(b)); - const stores = Array.from(storeLabelMapDisplay.values()).sort((a, b) => a.localeCompare(b)); if (!stores.length) { - $storeBar.innerHTML = `No stores`; + $stores.innerHTML = ""; return; } - $storeBar.innerHTML = stores - .map((s) => `${esc(s)}`) + $stores.innerHTML = stores + .map((s) => `${esc(s)}`) .join(""); } @@ -115,6 +118,8 @@ export function renderSearch($app) { )}` : `${esc(store)}${esc(plus)}`; + const skuLink = `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`; + return `
@@ -124,7 +129,9 @@ export function renderSearch($app) {
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))} + ${esc(displaySku(it.sku))}
${esc(price)} @@ -175,13 +182,7 @@ export function renderSearch($app) { return Number.isFinite(ms2) ? ms2 : 0; } - // Custom priority: - // - Sales that make this store cheapest (or tied cheapest) are most interesting - // - New unique (no other stores have canonical SKU) - // - Other sales (not cheapest) are demoted - // - Removed - // - Price increases - // - New (available elsewhere) + // Custom priority (unchanged) function rankRecent(r, canonSkuFn) { const rawSku = String(r?.sku || ""); const sku = String(canonSkuFn ? canonSkuFn(rawSku) : rawSku); @@ -229,41 +230,38 @@ export function renderSearch($app) { // 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 - if (p > 0) return 6000 + p; // Bucket #4 - return 5900; // weird but keep below real pct + if (p >= 20) return 9000 + p; + if (p >= 10) return 7000 + p; + if (p > 0) return 6000 + p; + return 5900; } else { - if (p >= 20) return 4500 + p; // Bucket #5 (below NEW unique) - if (p >= 10) return 1500 + p; // Bucket #8 - if (p > 0) return 1200 + p; // Bucket #9 - return 1000; // bottom-ish + if (p >= 20) return 4500 + p; + if (p >= 10) return 1500 + p; + if (p > 0) return 1200 + p; + return 1000; } } if (kind === "price_down") { score = saleBucketScore(saleIsCheapest, pctOff); } else if (isNewUnique) { - score = 8000; // Bucket #2 + score = 8000; } else if (kind === "removed") { - score = 3000; // Bucket #6 + score = 3000; } else if (kind === "price_up") { - score = 2000 + Math.min(99, Math.max(0, pctUp || 0)); // Bucket #7 + score = 2000 + Math.min(99, Math.max(0, pctUp || 0)); } else if (kind === "new") { - score = 1100; // Bucket #10 + score = 1100; } else if (kind === "restored") { - // not in your bucket list, but keep it reasonably high (below NEW unique, above removals) score = 5000; } 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); @@ -289,7 +287,6 @@ export function renderSearch($app) { const ms = t ? Date.parse(t) : NaN; if (Number.isFinite(ms)) return ms; - // fallback: date-only => treat as start of day UTC-ish const d = String(r?.date || ""); const ms2 = d ? Date.parse(d + "T00:00:00Z") : NaN; return Number.isFinite(ms2) ? ms2 : 0; @@ -305,7 +302,6 @@ export function renderSearch($app) { return; } - // --- DEDUPE: canonical SKU -> (store -> most recent event for that store) --- const bySkuStore = new Map(); for (const r of inWindow) { @@ -325,7 +321,6 @@ export function renderSearch($app) { if (!prev || eventMs(prev) < ms) storeMap.set(storeLabel, r); } - // --- PICK ONE PER SKU: choose the most "important" among latest-per-store events --- const picked = []; for (const [sku, storeMap] of bySkuStore.entries()) { let best = null; @@ -411,6 +406,8 @@ export function renderSearch($app) { ? ` style="color:rgba(20,110,40,0.95); background:rgba(20,110,40,0.10); border:1px solid rgba(20,110,40,0.20);"` : ""; + const skuLink = `#/link/?left=${encodeURIComponent(String(sku || ""))}`; + return `
@@ -420,7 +417,9 @@ export function renderSearch($app) {
${esc(r.name || "(no name)")}
- ${esc(displaySku(sku))} + ${esc(displaySku(sku))}
${esc(kindLabel)} @@ -454,7 +453,6 @@ export function renderSearch($app) { const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens)); - // If query prefixes an SMWS distillery name, also surface SMWS bottles by code XXX.YYY where XXX matches. const wantCodes = new Set(smwsDistilleryCodesForQueryPrefix($q.value)); if (!wantCodes.size) { renderAggregates(matches); @@ -473,27 +471,16 @@ export function renderSearch($app) { } } - // Put SMWS distillery matches first, then normal search matches. renderAggregates([...extra, ...matches]); } $results.innerHTML = `
Loading index…
`; - if ($storeBar) $storeBar.innerHTML = `Loading stores…`; Promise.all([loadIndex(), loadSkuRules()]) .then(([idx, rules]) => { const listings = Array.isArray(idx.items) ? idx.items : []; - // Build store list from LIVE rows only - const live = listings.filter((r) => r && !r.removed); - const storeDisplayByNorm = new Map(); - for (const r of live) { - const label = storeLabelFromRow(r); - if (!label) continue; - const n = normStoreLabel(label); - if (!storeDisplayByNorm.has(n)) storeDisplayByNorm.set(n, label); - } - renderStoreBar(live, storeDisplayByNorm); + renderStoreButtons(listings); allAgg = aggregateBySku(listings, rules.canonicalSku); aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); @@ -511,7 +498,6 @@ export function renderSearch($app) { }) .catch((e) => { $results.innerHTML = `
Failed to load: ${esc(e.message)}
`; - if ($storeBar) $storeBar.innerHTML = ``; }); let t = null; diff --git a/viz/app/store_page.js b/viz/app/store_page.js index cff5c44..52a89db 100644 --- a/viz/app/store_page.js +++ b/viz/app/store_page.js @@ -1,5 +1,11 @@ import { esc, renderThumbHtml } from "./dom.js"; -import { tokenizeQuery, matchesAllTokens, displaySku, keySkuForRow, parsePriceToNumber } from "./sku.js"; +import { + tokenizeQuery, + matchesAllTokens, + displaySku, + keySkuForRow, + parsePriceToNumber, +} from "./sku.js"; import { loadIndex } from "./state.js"; import { aggregateBySku } from "./catalog.js"; import { loadSkuRules } from "./mapping.js"; @@ -8,54 +14,57 @@ function normStoreLabel(s) { return String(s || "").trim().toLowerCase(); } -function storeLabelFromRow(r) { - return String(r?.storeLabel || r?.store || "").trim(); -} +function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) { + // Prefer the most recent-ish url if multiple exist; stable enough for viz. + let bestUrl = ""; + let bestScore = -1; -function storeQueryKey(storeNorm) { - return `stviz:v1:store:q:${storeNorm}`; -} - -function loadStoreQuery(storeNorm) { - try { - return localStorage.getItem(storeQueryKey(storeNorm)) || ""; - } catch { - return ""; + function scoreUrl(u) { + if (!u) return -1; + let s = u.length; + if (/\bproduct\/\d+\//.test(u)) s += 50; + if (/[a-z0-9-]{8,}/i.test(u)) s += 10; + return s; } + + for (const r of listingsLive) { + if (!r || r.removed) continue; + const store = normStoreLabel(r.storeLabel || r.store || ""); + if (store !== storeLabelNorm) continue; + + const skuKey = String(rulesCache?.canonicalSku(keySkuForRow(r)) || keySkuForRow(r)); + if (skuKey !== canonSku) continue; + + const u = String(r.url || "").trim(); + const sc = scoreUrl(u); + if (sc > bestScore) { + bestScore = sc; + bestUrl = u; + } else if (sc === bestScore && u && bestUrl && u < bestUrl) { + bestUrl = u; + } + } + return bestUrl; } -function saveStoreQuery(storeNorm, v) { - try { - localStorage.setItem(storeQueryKey(storeNorm), String(v ?? "")); - } catch {} -} +// small module-level cache so we can reuse in readLinkHrefForSkuInStore +let rulesCache = null; -function urlQuality(u) { - u = String(u || "").trim(); - if (!u) return -1; - let s = 0; - s += u.length; - if (/\bproduct\/\d+\//.test(u)) s += 50; - if (/[a-z0-9-]{8,}/i.test(u)) s += 10; - return s; -} - -export async function renderStore($app, storeParamRaw) { - const storeParam = String(storeParamRaw || "").trim(); - const storeNorm = normStoreLabel(storeParam); +export async function renderStore($app, storeLabelRaw) { + const storeLabel = String(storeLabelRaw || "").trim(); $app.innerHTML = ` -
+
- ${esc(storeParam || "Store")} -
+ ${esc(storeLabel || "Store")}
- -
+ +
+
`; @@ -63,175 +72,156 @@ export async function renderStore($app, storeParamRaw) { document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); const $q = document.getElementById("q"); - const $results = document.getElementById("results"); const $status = document.getElementById("status"); - - $q.value = loadStoreQuery(storeNorm); + const $results = document.getElementById("results"); + const $sentinel = document.getElementById("sentinel"); $results.innerHTML = `
Loading…
`; - const [idx, rules] = await Promise.all([loadIndex(), loadSkuRules()]); - const allRows = Array.isArray(idx.items) ? idx.items : []; + const idx = await loadIndex(); + rulesCache = await loadSkuRules(); + const rules = rulesCache; - // Live only - const liveAll = allRows.filter((r) => r && !r.removed); + const listingsAll = Array.isArray(idx.items) ? idx.items : []; + const liveAll = listingsAll.filter((r) => r && !r.removed); - // Resolve store display label (in case casing differs) - let storeDisplay = storeParam || "Store"; - { - const dispByNorm = new Map(); - for (const r of liveAll) { - const lab = storeLabelFromRow(r); - if (!lab) continue; - const n = normStoreLabel(lab); - if (!dispByNorm.has(n)) dispByNorm.set(n, lab); - } - storeDisplay = dispByNorm.get(storeNorm) || storeDisplay; - } + const storeNorm = normStoreLabel(storeLabel); - // Filter rows for this store - const liveStore = liveAll.filter((r) => normStoreLabel(storeLabelFromRow(r)) === storeNorm); - - if (!liveStore.length) { - $results.innerHTML = `
No in-stock items for this store.
`; - $status.textContent = ""; - return; - } - - // Global presence + min-price map (by canonical sku) - const presenceBySku = new Map(); // sku -> Set(storeNorm) - const minPriceBySkuStore = new Map(); // sku -> Map(storeNorm -> minPrice) + // Build global per-canonical-SKU store presence + min prices + const storesBySku = new Map(); // sku -> Set(storeLabelNorm) + const minPriceBySkuStore = new Map(); // sku -> Map(storeLabelNorm -> minPrice) for (const r of liveAll) { - const storeLab = storeLabelFromRow(r); - const sNorm = normStoreLabel(storeLab); - if (!sNorm) continue; + const store = normStoreLabel(r.storeLabel || r.store || ""); + if (!store) continue; - const skuKey = String(keySkuForRow(r) || "").trim(); - if (!skuKey) continue; + const skuKey = keySkuForRow(r); + const sku = String(rules.canonicalSku(skuKey) || skuKey); - const sku = String(rules.canonicalSku(skuKey) || "").trim(); - if (!sku) continue; + let ss = storesBySku.get(sku); + if (!ss) storesBySku.set(sku, (ss = new Set())); + ss.add(store); - let pres = presenceBySku.get(sku); - if (!pres) presenceBySku.set(sku, (pres = new Set())); - pres.add(sNorm); - - const p = parsePriceToNumber(r?.price); - if (p === null) continue; - - let m = minPriceBySkuStore.get(sku); - if (!m) minPriceBySkuStore.set(sku, (m = new Map())); - - const prev = m.get(sNorm); - if (!Number.isFinite(prev) || p < prev) m.set(sNorm, p); - } - - // Build store-only aggregates (canonicalized) - const storeAgg = aggregateBySku(liveStore, rules.canonicalSku); - - // Best URL for this store per canonical SKU - const urlBySku = new Map(); // sku -> url - for (const r of liveStore) { - const skuKey = String(keySkuForRow(r) || "").trim(); - if (!skuKey) continue; - const sku = String(rules.canonicalSku(skuKey) || "").trim(); - if (!sku) continue; - - const u = String(r?.url || "").trim(); - if (!u) continue; - - const prev = urlBySku.get(sku); - if (!prev) { - urlBySku.set(sku, u); - continue; + const p = parsePriceToNumber(r.price); + if (p !== null) { + let m = minPriceBySkuStore.get(sku); + if (!m) minPriceBySkuStore.set(sku, (m = new Map())); + const prev = m.get(store); + if (prev === undefined || p < prev) m.set(store, p); } - - const a = urlQuality(prev); - const b = urlQuality(u); - if (b > a) urlBySku.set(sku, u); - else if (b === a && u < prev) urlBySku.set(sku, u); } - function computeCompare(it) { - const sku = String(it?.sku || ""); - const pres = presenceBySku.get(sku) || new Set([storeNorm]); - const exclusive = pres.size === 1 && pres.has(storeNorm); + function bestAllPrice(sku) { + const m = minPriceBySkuStore.get(sku); + if (!m) return null; + let best = null; + for (const v of m.values()) best = best === null ? v : Math.min(best, v); + return best; + } - const storePrice = Number.isFinite(it?.cheapestPriceNum) ? it.cheapestPriceNum : null; - - const m = minPriceBySkuStore.get(sku) || new Map(); - let bestAll = null; - let bestOther = null; - - for (const [sNorm, p] of m.entries()) { - if (!Number.isFinite(p)) continue; - bestAll = bestAll === null ? p : Math.min(bestAll, p); - if (sNorm !== storeNorm) bestOther = bestOther === null ? p : Math.min(bestOther, p); + function bestOtherPrice(sku, store) { + const m = minPriceBySkuStore.get(sku); + if (!m) return null; + let best = null; + for (const [k, v] of m.entries()) { + if (k === store) continue; + best = best === null ? v : Math.min(best, v); } - - // pct: (this - nextBestOther)/nextBestOther * 100 - const pct = - storePrice !== null && bestOther !== null && bestOther > 0 - ? ((storePrice - bestOther) / bestOther) * 100 - : null; - - const EPS = 0.01; - const isBestPrice = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false; - - return { exclusive, pct, isBestPrice }; + return best; } - const items = storeAgg + // Store-specific live rows only (in-stock for that store) + const rowsStoreLive = liveAll.filter((r) => normStoreLabel(r.storeLabel || r.store || "") === storeNorm); + + // Aggregate in this store, grouped by canonical SKU (so mappings count as same bottle) + let items = aggregateBySku(rowsStoreLive, rules.canonicalSku); + + // Decorate each item with pricing comparisons + exclusivity + const EPS = 0.01; + + items = items .map((it) => { - const c = computeCompare(it); + const sku = String(it.sku || ""); + const storeSet = storesBySku.get(sku) || new Set([storeNorm]); + const exclusive = storeSet.size === 1 && storeSet.has(storeNorm); + + const storePrice = Number.isFinite(it.cheapestPriceNum) ? it.cheapestPriceNum : null; + const bestAll = bestAllPrice(sku); + const other = bestOtherPrice(sku, storeNorm); + + const isBest = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false; + + // Sort key: compare vs cheapest alternative store (if we're best, that's "next best"; else that's "best") + const pctVsOther = + storePrice !== null && other !== null && other > 0 ? ((storePrice - other) / other) * 100 : null; + + const pctVsBest = + storePrice !== null && bestAll !== null && bestAll > 0 ? ((storePrice - bestAll) / bestAll) * 100 : null; + return { ...it, - _exclusive: c.exclusive, - _pct: c.pct, - _isBestPrice: c.isBestPrice, + _exclusive: exclusive, + _storePrice: storePrice, + _bestAll: bestAll, + _bestOther: other, + _isBest: isBest, + _pctVsOther: pctVsOther, + _pctVsBest: pctVsBest, }; }) .sort((a, b) => { if (a._exclusive !== b._exclusive) return a._exclusive ? -1 : 1; - const ap = Number.isFinite(a._pct) ? a._pct : Infinity; - const bp = Number.isFinite(b._pct) ? b._pct : Infinity; - if (ap !== bp) return ap - bp; + const pa = a._pctVsOther; + const pb = b._pctVsOther; + const sa = pa === null ? 999999 : pa; + const sb = pb === null ? 999999 : pb; + if (sa !== sb) return sa - sb; - return (String(a.name) + String(a.sku)).localeCompare(String(b.name) + String(b.sku)); + return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku); }); - function pctBadge(pct) { - if (!Number.isFinite(pct)) return null; + function priceBadgeHtml(it) { + if (it._exclusive) return ""; - const p = Math.round(pct); - const txt = `${p >= 0 ? "+" : ""}${p}% vs next`; + const pOther = it._pctVsOther; + const pBest = it._pctVsBest; - if (pct < -5) return { cls: "badge badgeGood", txt }; - if (pct > 5) return { cls: "badge badgeBad", txt }; - return { cls: "badge badgeNeutral", txt }; // -5%..+5% + // If we can't compare, skip the % badge. + if (pOther === null || !Number.isFinite(pOther)) return ""; + + // Grey zone: -5% .. +5% => "same as next best price" + if (Math.abs(pOther) <= 5) { + return `same as next best price`; + } + + // Deals: we're best (cheaper than other) + if (pOther < 0 && it._bestOther !== null && it._bestOther > 0 && it._storePrice !== null) { + const pct = Math.round(((it._bestOther - it._storePrice) / it._bestOther) * 100); + if (pct <= 0) return `same as next best price`; + return `${esc(pct)}% vs next best price`; + } + + // More expensive: show vs best price + if (pBest !== null && Number.isFinite(pBest) && pBest > 0) { + const pct = Math.round(pBest); + if (pct <= 5) return `same as next best price`; + return `${esc(pct)}% vs best price`; + } + + return ""; } function renderCard(it) { const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; - const href = urlBySku.get(String(it.sku || "")) || String(it.sampleUrl || "").trim(); + const href = String(it.sampleUrl || "").trim(); - const storeBadge = href - ? `${esc( - storeDisplay - )}` - : `${esc(storeDisplay)}`; + const exclusiveBadge = it._exclusive ? `Exclusive` : ""; + const bestBadge = !it._exclusive && it._isBest ? `Best Price` : ""; + const pctBadge = priceBadgeHtml(it); - const badges = []; - - if (it._exclusive) badges.push(`EXCLUSIVE`); - if (!it._exclusive && it._isBestPrice) badges.push(`Best Price`); - - if (!it._exclusive) { - const pb = pctBadge(it._pct); - if (pb) badges.push(`${esc(pb.txt)}`); - } + const skuLink = + `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`; return `
@@ -240,12 +230,20 @@ export async function renderStore($app, storeParamRaw) {
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))} + ${esc( + displaySku(it.sku) + )}
-
- ${badges.join("")} +
+ ${exclusiveBadge} + ${bestBadge} + ${pctBadge} ${esc(price)} - ${storeBadge} + ${ + href + ? `Open` + : `` + }
@@ -253,41 +251,81 @@ export async function renderStore($app, storeParamRaw) { `; } - function renderList(filtered) { - if (!filtered.length) { - $results.innerHTML = `
No matches.
`; + // ---- Infinite scroll paging ---- + const PAGE_SIZE = 140; + + let filtered = items.slice(); + let shown = 0; + + function setStatus() { + const total = filtered.length; + if (!total) { + $status.textContent = "No in-stock items for this store."; return; } + $status.textContent = `In stock: ${total} item(s).`; + } - const limited = filtered.slice(0, 120); - $results.innerHTML = limited.map(renderCard).join(""); + function renderNext(reset) { + if (reset) { + $results.innerHTML = ""; + shown = 0; + } - for (const el of Array.from($results.querySelectorAll(".item"))) { - el.addEventListener("click", () => { - const sku = el.getAttribute("data-sku") || ""; - if (!sku) return; - saveStoreQuery(storeNorm, $q.value); - location.hash = `#/item/${encodeURIComponent(sku)}`; - }); + const slice = filtered.slice(shown, shown + PAGE_SIZE); + shown += slice.length; + + if (slice.length) { + $results.insertAdjacentHTML("beforeend", slice.map(renderCard).join("")); + } + + if (!filtered.length) { + $sentinel.textContent = ""; + } else if (shown >= filtered.length) { + $sentinel.textContent = `Showing ${shown} / ${filtered.length}`; + } else { + $sentinel.textContent = `Showing ${shown} / ${filtered.length}…`; } } - function applySearch() { + // Click -> item page (delegated). SKU + Open links stopPropagation already. + $results.addEventListener("click", (e) => { + const el = e.target.closest(".item"); + if (!el) return; + const sku = el.getAttribute("data-sku") || ""; + if (!sku) return; + location.hash = `#/item/${encodeURIComponent(sku)}`; + }); + + function applyFilter() { const tokens = tokenizeQuery($q.value); - saveStoreQuery(storeNorm, $q.value); - - const filtered = tokens.length ? items.filter((it) => matchesAllTokens(it.searchText, tokens)) : items; - - $status.textContent = `In stock: ${items.length}. Showing: ${filtered.length}.`; - renderList(filtered); + if (!tokens.length) { + filtered = items.slice(); + } else { + filtered = items.filter((it) => matchesAllTokens(it.searchText, tokens)); + } + setStatus(); + renderNext(true); } - $q.focus(); - applySearch(); + // Initial render + setStatus(); + renderNext(true); + + const io = new IntersectionObserver( + (entries) => { + const hit = entries.some((x) => x.isIntersecting); + if (!hit) return; + if (shown >= filtered.length) return; + renderNext(false); + }, + { root: null, rootMargin: "600px 0px", threshold: 0.01 } + ); + io.observe($sentinel); let t = null; $q.addEventListener("input", () => { if (t) clearTimeout(t); - t = setTimeout(applySearch, 50); + t = setTimeout(applyFilter, 60); }); } diff --git a/viz/style.css b/viz/style.css index 6fca2a8..c03734e 100644 --- a/viz/style.css +++ b/viz/style.css @@ -22,6 +22,10 @@ a:hover { text-decoration: underline; } a.badge { color: var(--muted); } a.badge:hover { text-decoration: underline; cursor: pointer; } +/* SKU badge links (search/store -> link page) */ +a.skuLink { color: var(--muted); } +a.skuLink:hover { text-decoration: underline; cursor: pointer; } + .container { max-width: 980px; margin: 0 auto; @@ -31,9 +35,19 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } .header { display: flex; gap: 12px; - align-items: center; + align-items: flex-start; justify-content: space-between; margin-bottom: 14px; + flex-wrap: wrap; /* keeps Link SKUs comfy on small screens */ +} + +.headerLeft { + flex: 1 1 520px; + min-width: 240px; +} + +.headerRight { + flex: 0 0 auto; } .h1 { @@ -148,6 +162,29 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } gap: 6px; } +.badgeGood { + color: rgba(20,110,40,0.95); + background: rgba(20,110,40,0.10); + border-color: rgba(20,110,40,0.25); +} + +.badgeNeutral { + color: var(--muted); + background: rgba(255,255,255,0.02); +} + +.badgeBad { + color: rgba(160,40,40,0.95); + background: rgba(160,40,40,0.12); + border-color: rgba(160,40,40,0.25); +} + +.badgeBest { + color: rgba(160,120,20,0.95); + background: rgba(160,120,20,0.10); + border-color: rgba(160,120,20,0.22); +} + .metaRow { margin-top: 8px; display: flex; @@ -159,8 +196,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } font-size: 13px; } -.metaRowWrap { flex-wrap: wrap; } /* used for store page so badges can wrap */ - .price { font-weight: 700; color: var(--text); @@ -186,6 +221,13 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } .btn:hover { border-color: #2f3a46; } +.btnWide { + padding-left: 14px; + padding-right: 14px; + min-width: 120px; + text-align: center; +} + .links { display: flex; gap: 10px; @@ -198,54 +240,39 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } font-size: 12px; } -/* --- Store selector pills (search page header) --- */ +/* --- Store selector (top of search page) --- */ .storeBarWrap { - margin-top: 8px; - display: flex; - gap: 8px; - align-items: center; - min-width: 0; -} - -.storeBarLabel { - flex: 0 0 auto; + margin-top: 12px; /* slightly more gap from the top text */ } .storeBar { display: flex; gap: 8px; - flex-wrap: wrap; /* desktop: wrap nicely into 2 lines if needed */ - min-width: 0; + flex-wrap: wrap; } -.storePill { - padding: 2px 9px; +.storeBtn { + border: 1px solid var(--border); + background: #0f1318; + color: var(--muted); + border-radius: 999px; + padding: 6px 10px; font-size: 12px; + line-height: 1; + display: inline-flex; + align-items: center; } -/* Badge color variants (store page percent badges) */ -.badgeGood { - color: rgba(20,110,40,0.95); - background: rgba(20,110,40,0.10); - border-color: rgba(20,110,40,0.20); +.storeBtn:hover { + border-color: #2f3a46; + color: var(--text); + text-decoration: none; } -.badgeBad { - color: rgba(180,70,60,0.95); - background: rgba(180,70,60,0.10); - border-color: rgba(180,70,60,0.25); -} - -.badgeNeutral { - color: rgba(154,166,178,0.95); - background: rgba(154,166,178,0.08); - border-color: rgba(154,166,178,0.18); -} - -.badgeExclusive { - color: rgba(125,211,252,0.95); - background: rgba(125,211,252,0.10); - border-color: rgba(125,211,252,0.20); +.storeBtnActive { + border-color: #37566b; + outline: 1px solid #37566b; + color: var(--text); } /* Detail view sizing */ @@ -310,15 +337,6 @@ a.badge:hover { text-decoration: underline; cursor: pointer; } min-height: 260px; padding: 8px; } - - /* mobile: make stores a single-row scroll instead of wrapping */ - .storeBar { - flex-wrap: nowrap; - overflow-x: auto; - padding-bottom: 6px; - -webkit-overflow-scrolling: touch; - } - .storeBar::-webkit-scrollbar { display: none; } } .chartBox canvas {