From ae0cf44e988e06a36cf3741b903d31c0fa2e3a87 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Mon, 2 Feb 2026 17:45:26 -0800 Subject: [PATCH] link sku --- viz/app/store_page.js | 249 ++++++++++++++++++++++++++++++++++-------- viz/style.css | 33 ++++++ 2 files changed, 239 insertions(+), 43 deletions(-) diff --git a/viz/app/store_page.js b/viz/app/store_page.js index f0995dd..276c0de 100644 --- a/viz/app/store_page.js +++ b/viz/app/store_page.js @@ -14,6 +14,12 @@ function normStoreLabel(s) { return String(s || "").trim().toLowerCase(); } +function abbrevStoreLabel(s) { + const t = String(s || "").trim(); + if (!t) return ""; + return t.split(/\s+/)[0] || t; +} + function readLinkHrefForSkuInStore(listingsLive, canonSku, storeLabelNorm) { // Prefer the most recent-ish url if multiple exist; stable enough for viz. let bestUrl = ""; @@ -54,12 +60,13 @@ let rulesCache = null; export async function renderStore($app, storeLabelRaw) { const storeLabel = String(storeLabelRaw || "").trim(); + const storeLabelShort = abbrevStoreLabel(storeLabel) || (storeLabel ? storeLabel : "Store"); $app.innerHTML = `
- ${esc(storeLabel || "Store")} + ${esc(storeLabelShort || "Store")}
@@ -96,7 +103,10 @@ export async function renderStore($app, storeLabelRaw) { >
- +
+ + +
@@ -109,7 +119,15 @@ export async function renderStore($app, storeLabelRaw) { and Last Stock - Only sold here +
+ Sort + +
@@ -117,7 +135,13 @@ export async function renderStore($app, storeLabelRaw) {
Price compare - Cross-store pricing +
+ Comparison + +
@@ -144,6 +168,10 @@ export async function renderStore($app, storeLabelRaw) { const $maxPriceLabel = document.getElementById("maxPriceLabel"); const $priceWrap = document.getElementById("priceWrap"); + const $clearSearch = document.getElementById("clearSearch"); + const $exSort = document.getElementById("exSort"); + const $cmpMode = document.getElementById("cmpMode"); + // Persist query per store const storeNorm = normStoreLabel(storeLabel); const LS_KEY = `viz:storeQuery:${storeNorm}`; @@ -156,6 +184,16 @@ export async function renderStore($app, storeLabelRaw) { let savedMaxPrice = savedMaxPriceRaw !== null ? Number(savedMaxPriceRaw) : null; if (!Number.isFinite(savedMaxPrice)) savedMaxPrice = null; + // Persist exclusives sort per store + const LS_EX_SORT = `viz:storeExclusiveSort:${storeNorm}`; + const savedExSort = String(localStorage.getItem(LS_EX_SORT) || ""); + if (savedExSort) $exSort.value = savedExSort; + + // Persist comparison technique per store + const LS_CMP_MODE = `viz:storeCompareMode:${storeNorm}`; + const savedCmpMode = String(localStorage.getItem(LS_CMP_MODE) || ""); + if (savedCmpMode) $cmpMode.value = savedCmpMode; + $resultsExclusive.innerHTML = `
Loading…
`; $resultsCompare.innerHTML = ``; @@ -166,6 +204,49 @@ export async function renderStore($app, storeLabelRaw) { const listingsAll = Array.isArray(idx.items) ? idx.items : []; const liveAll = listingsAll.filter((r) => r && !r.removed); + function dateMsFromRow(r) { + if (!r) return null; + const keys = [ + "firstSeenAt", + "firstSeen", + "createdAt", + "created", + "addedAt", + "added", + "date", + "ts", + "timestamp", + ]; + for (const k of keys) { + const v = r[k]; + if (v === undefined || v === null) continue; + if (typeof v === "number" && Number.isFinite(v)) { + // If seconds-ish, normalize to ms + if (v > 0 && v < 2e10) return v < 2e9 ? v * 1000 : v; + return v; + } + if (typeof v === "string") { + const t = Date.parse(v); + if (Number.isFinite(t)) return t; + } + } + return null; + } + + // Build earliest "first in DB" timestamp per canonical SKU (includes removed rows) + const firstSeenBySku = new Map(); // sku -> ms + for (const r of listingsAll) { + if (!r) continue; + const skuKey = keySkuForRow(r); + const sku = String(rules.canonicalSku(skuKey) || skuKey); + + const ms = dateMsFromRow(r); + if (ms === null) continue; + + const prev = firstSeenBySku.get(sku); + if (prev === undefined || ms < prev) firstSeenBySku.set(sku, ms); + } + // Build "ever seen" store presence per canonical SKU (includes removed rows) const everStoresBySku = new Map(); // sku -> Set(storeLabelNorm) for (const r of listingsAll) { @@ -235,50 +316,51 @@ export async function renderStore($app, storeLabelRaw) { // Decorate each item with pricing comparisons + exclusivity const EPS = 0.01; - items = items - .map((it) => { - const sku = String(it.sku || ""); - const liveStoreSet = storesBySku.get(sku) || new Set([storeNorm]); - const everStoreSet = everStoresBySku.get(sku) || liveStoreSet; + items = items.map((it) => { + const sku = String(it.sku || ""); + const liveStoreSet = storesBySku.get(sku) || new Set([storeNorm]); + const everStoreSet = everStoresBySku.get(sku) || liveStoreSet; - const soloLiveHere = liveStoreSet.size === 1 && liveStoreSet.has(storeNorm); - const lastStock = soloLiveHere && everStoreSet.size > 1; - const exclusive = soloLiveHere && !lastStock; + const soloLiveHere = liveStoreSet.size === 1 && liveStoreSet.has(storeNorm); + const lastStock = soloLiveHere && everStoreSet.size > 1; + const exclusive = soloLiveHere && !lastStock; - const storePrice = Number.isFinite(it.cheapestPriceNum) ? it.cheapestPriceNum : null; - const bestAll = bestAllPrice(sku); - const other = bestOtherPrice(sku, 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; + const isBest = storePrice !== null && bestAll !== null ? storePrice <= bestAll + EPS : false; - const diffVsOther = storePrice !== null && other !== null ? storePrice - other : null; - const diffVsBest = storePrice !== null && bestAll !== null ? storePrice - bestAll : null; + const diffVsOtherDollar = storePrice !== null && other !== null ? storePrice - other : null; + const diffVsOtherPct = + storePrice !== null && other !== null && other > 0 + ? ((storePrice - other) / other) * 100 + : null; - return { - ...it, - _exclusive: exclusive, - _lastStock: lastStock, - _storePrice: storePrice, - _bestAll: bestAll, - _bestOther: other, - _isBest: isBest, - _diffVsOther: diffVsOther, - _diffVsBest: diffVsBest, - }; - }) - .sort((a, b) => { - const aSpecial = !!(a._exclusive || a._lastStock); - const bSpecial = !!(b._exclusive || b._lastStock); - if (aSpecial !== bSpecial) return aSpecial ? -1 : 1; + const diffVsBestDollar = storePrice !== null && bestAll !== null ? storePrice - bestAll : null; + const diffVsBestPct = + storePrice !== null && bestAll !== null && bestAll > 0 + ? ((storePrice - bestAll) / bestAll) * 100 + : null; - const da = a._diffVsOther; - const db = b._diffVsOther; - const sa = da === null ? 999999 : da; - const sb = db === null ? 999999 : db; - if (sa !== sb) return sa - sb; + const firstSeenMs = firstSeenBySku.get(sku); + const firstSeen = firstSeenMs !== undefined ? firstSeenMs : null; - return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku); - }); + return { + ...it, + _exclusive: exclusive, + _lastStock: lastStock, + _storePrice: storePrice, + _bestAll: bestAll, + _bestOther: other, + _isBest: isBest, + _diffVsOtherDollar: diffVsOtherDollar, + _diffVsOtherPct: diffVsOtherPct, + _diffVsBestDollar: diffVsBestDollar, + _diffVsBestPct: diffVsBestPct, + _firstSeenMs: firstSeen, + }; + }); // ---- Max price slider (exponential mapping + clicky rounding) ---- const MIN_PRICE = 25; @@ -383,10 +465,28 @@ export async function renderStore($app, storeLabelRaw) { return `$${p.toFixed(2)}`; } + function compareMode() { + return $cmpMode && $cmpMode.value === "percent" ? "percent" : "dollar"; + } + function priceBadgeHtml(it) { if (it._exclusive || it._lastStock) return ""; - const d = it._diffVsOther; + const mode = compareMode(); + + if (mode === "percent") { + const d = it._diffVsOtherPct; + if (d === null || !Number.isFinite(d)) return ""; + const abs = Math.abs(d); + if (abs <= 5) { + return `within 5%`; + } + const pct = Math.round(abs); + if (d < 0) return `${esc(pct)}% lower`; + return `${esc(pct)}% higher`; + } + + const d = it._diffVsOtherDollar; if (d === null || !Number.isFinite(d)) return ""; const abs = Math.abs(d); @@ -436,7 +536,7 @@ export async function renderStore($app, storeLabelRaw) { ${esc(price)} ${ href - ? `${esc(storeLabel)}` + ? `${esc(storeLabelShort)}` : `` } @@ -523,6 +623,48 @@ export async function renderStore($app, storeLabelRaw) { location.hash = `#/item/${encodeURIComponent(sku)}`; }); + function sortExclusiveInPlace(arr) { + const mode = String($exSort.value || "priceDesc"); + if (mode === "priceAsc" || mode === "priceDesc") { + arr.sort((a, b) => { + const ap = Number.isFinite(a._storePrice) ? a._storePrice : null; + const bp = Number.isFinite(b._storePrice) ? b._storePrice : null; + const aKey = ap === null ? (mode === "priceAsc" ? 9e15 : -9e15) : ap; + const bKey = bp === null ? (mode === "priceAsc" ? 9e15 : -9e15) : bp; + if (aKey !== bKey) return mode === "priceAsc" ? aKey - bKey : bKey - aKey; + return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku); + }); + return; + } + + if (mode === "dateAsc" || mode === "dateDesc") { + arr.sort((a, b) => { + const ad = Number.isFinite(a._firstSeenMs) ? a._firstSeenMs : null; + const bd = Number.isFinite(b._firstSeenMs) ? b._firstSeenMs : null; + const aKey = ad === null ? (mode === "dateAsc" ? 9e15 : -9e15) : ad; + const bKey = bd === null ? (mode === "dateAsc" ? 9e15 : -9e15) : bd; + if (aKey !== bKey) return mode === "dateAsc" ? aKey - bKey : bKey - aKey; + return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku); + }); + } + } + + function sortCompareInPlace(arr) { + const mode = compareMode(); + arr.sort((a, b) => { + const da = + mode === "percent" ? a._diffVsOtherPct : a._diffVsOtherDollar; + const db = + mode === "percent" ? b._diffVsOtherPct : b._diffVsOtherDollar; + + const sa = da === null || !Number.isFinite(da) ? 999999 : da; + const sb = db === null || !Number.isFinite(db) ? 999999 : db; + if (sa !== sb) return sa - sb; + + return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku); + }); + } + function applyFilter() { const raw = String($q.value || ""); localStorage.setItem(LS_KEY, raw); @@ -546,6 +688,9 @@ export async function renderStore($app, storeLabelRaw) { filteredExclusive = base.filter((it) => it._exclusive || it._lastStock); filteredCompare = base.filter((it) => !it._exclusive && !it._lastStock); + sortExclusiveInPlace(filteredExclusive); + sortCompareInPlace(filteredCompare); + setStatus(); renderNext(true); } @@ -569,6 +714,24 @@ export async function renderStore($app, storeLabelRaw) { t = setTimeout(applyFilter, 60); }); + $clearSearch.addEventListener("click", () => { + if (!$q.value) return; + $q.value = ""; + localStorage.setItem(LS_KEY, ""); + applyFilter(); + $q.focus(); + }); + + $exSort.addEventListener("change", () => { + localStorage.setItem(LS_EX_SORT, String($exSort.value || "")); + applyFilter(); + }); + + $cmpMode.addEventListener("change", () => { + localStorage.setItem(LS_CMP_MODE, String($cmpMode.value || "")); + applyFilter(); + }); + let tp = null; function setSelectedMaxPriceFromSlider() { const raw = getRawPriceFromSlider(); diff --git a/viz/style.css b/viz/style.css index 2c44555..f2fe3f3 100644 --- a/viz/style.css +++ b/viz/style.css @@ -228,6 +228,15 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; } text-align: center; } +.btnSm { + padding: 10px 12px; + color: var(--muted); +} + +.btnSm:hover { + color: var(--text); +} + .links { display: flex; gap: 10px; @@ -240,6 +249,30 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; } font-size: 12px; } +/* small subtle inline selects */ +.selectSmall { + border: 1px solid var(--border); + background: #0f1318; + color: var(--muted); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + line-height: 1; + outline: none; +} + +.selectSmall:hover { + border-color: #2f3a46; + color: var(--text); + cursor: pointer; +} + +.selectSmall:focus { + border-color: #37566b; + outline: 1px solid #37566b; + color: var(--text); +} + /* --- Store selector (top of search page) --- */ .storeBarWrap { margin-top: 12px; /* slightly more gap from the top text */