diff --git a/viz/app.js b/viz/app.js deleted file mode 100644 index 631b26a..0000000 --- a/viz/app.js +++ /dev/null @@ -1,1426 +0,0 @@ -"use strict"; - -/** - * Hash routes: - * #/ search - * #/item/ detail - * #/link sku linker (local-write only) - */ - -const $app = document.getElementById("app"); - -function esc(s) { - return String(s ?? "").replace( - /[&<>"']/g, - (c) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[c]) - ); -} - -function parsePriceToNumber(v) { - const s = String(v ?? "").replace(/[^0-9.]/g, ""); - const n = Number(s); - return Number.isFinite(n) ? n : null; -} - -function dateOnly(iso) { - const m = String(iso ?? "").match(/^(\d{4}-\d{2}-\d{2})/); - return m ? m[1] : ""; -} - -function prettyTs(iso) { - const s = String(iso || ""); - if (!s) return ""; - return s.replace("T", " "); -} - -function makeUnknownSku(r) { - const store = String(r?.storeLabel || r?.store || "store").toLowerCase().replace(/[^a-z0-9]+/g, "-"); - const url = String(r?.url || ""); - const h = url ? btoa(unescape(encodeURIComponent(url))).replace(/=+$/g, "").slice(0, 16) : "no-url"; - return `unknown:${store}:${h}`; -} - -function fnv1a32(str) { - let h = 0x811c9dc5; // offset basis - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h = Math.imul(h, 0x01000193); // FNV prime - } - return (h >>> 0).toString(16).padStart(8, "0"); -} - -function makeSyntheticSku(r) { - const store = String(r?.storeLabel || r?.store || "store"); - const url = String(r?.url || ""); - const key = `${store}|${url}`; - return `u:${fnv1a32(key)}`; // stable per store+url -} - -function keySkuForRow(r) { - const real = String(r?.sku || "").trim(); - return real ? real : makeSyntheticSku(r); -} - -function displaySku(key) { - return String(key || "").startsWith("u:") ? "unknown" : String(key || ""); -} - -function isUnknownSkuKey(key) { - return String(key || "").startsWith("u:"); -} - -// Normalize for search: lowercase, punctuation -> space, collapse spaces -function normSearchText(s) { - return String(s ?? "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function tokenizeQuery(q) { - const n = normSearchText(q); - return n ? n.split(" ").filter(Boolean) : []; -} - -function inferGithubOwnerRepo() { - const host = location.hostname || ""; - const m = host.match(/^([a-z0-9-]+)\.github\.io$/i); - if (m) { - const owner = m[1]; - const parts = (location.pathname || "/").split("/").filter(Boolean); - const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`; - return { owner, repo }; - } - return { owner: "brennanwilkes", repo: "spirit-tracker" }; -} - -async function fetchJson(url) { - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); - return await res.json(); -} - -async function fetchText(url) { - const res = await fetch(url, { cache: "no-store" }); - if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); - return await res.text(); -} - -function route() { - const h = location.hash || "#/"; - const parts = h.replace(/^#\/?/, "").split("/").filter(Boolean); - if (parts.length === 0) return renderSearch(); - if (parts[0] === "item" && parts[1]) return renderItem(decodeURIComponent(parts[1])); - if (parts[0] === "link") return renderSkuLinker(); - return renderSearch(); -} - -/* ---------------- Search ---------------- */ - -let INDEX = null; -let RECENT = null; - -// persist search box value across navigation -const Q_LS_KEY = "stviz:v1:search:q"; -function loadSavedQuery() { - try { - return localStorage.getItem(Q_LS_KEY) || ""; - } catch { - return ""; - } -} -function saveQuery(v) { - try { - localStorage.setItem(Q_LS_KEY, String(v ?? "")); - } catch {} -} - -async function loadIndex() { - if (INDEX) return INDEX; - INDEX = await fetchJson("./data/index.json"); - return INDEX; -} - -async function loadRecent() { - if (RECENT) return RECENT; - try { - RECENT = await fetchJson("./data/recent.json"); - } catch { - RECENT = { count: 0, items: [] }; - } - return RECENT; -} - -function normImg(s) { - const v = String(s || "").trim(); - if (!v) return ""; - if (/^data:/i.test(v)) return ""; - return v; -} - -// Build one row per SKU + combined searchable text across all listings of that SKU -function aggregateBySku(listings) { - const bySku = new Map(); - - for (const r of listings) { - const sku = keySkuForRow(r); - - const name = String(r?.name || ""); - const url = String(r?.url || ""); - const storeLabel = String(r?.storeLabel || r?.store || ""); - - const img = normImg(r?.img || r?.image || r?.thumb || ""); - - const pNum = parsePriceToNumber(r?.price); - const pStr = String(r?.price || ""); - - let agg = bySku.get(sku); - if (!agg) { - agg = { - sku, - name: name || "", - img: "", - cheapestPriceStr: pStr || "", - cheapestPriceNum: pNum, - cheapestStoreLabel: storeLabel || "", - stores: new Set(), - sampleUrl: url || "", - _searchParts: [], - searchText: "", // normalized blob - - _imgByName: new Map(), // name -> img - _imgAny: "", - }; - bySku.set(sku, agg); - } - - if (storeLabel) agg.stores.add(storeLabel); - if (!agg.sampleUrl && url) agg.sampleUrl = url; - - // Keep the first non-empty name (existing behavior), but make sure img matches that chosen name - if (!agg.name && name) { - agg.name = name; - if (img) agg.img = img; - } else if (agg.name && name === agg.name && img && !agg.img) { - agg.img = img; - } - - if (img) { - if (!agg._imgAny) agg._imgAny = img; - if (name) agg._imgByName.set(name, img); - } - - // cheapest - if (pNum !== null) { - if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) { - agg.cheapestPriceNum = pNum; - agg.cheapestPriceStr = pStr || ""; - agg.cheapestStoreLabel = storeLabel || agg.cheapestStoreLabel; - } - } - - // search parts (include everything we might want to match) - agg._searchParts.push(sku); - if (name) agg._searchParts.push(name); - if (url) agg._searchParts.push(url); - if (storeLabel) agg._searchParts.push(storeLabel); - } - - const out = [...bySku.values()]; - - for (const it of out) { - // Ensure thumbnail matches chosen name when possible - if (!it.img) { - const m = it._imgByName; - if (it.name && m && m.has(it.name)) it.img = m.get(it.name) || ""; - else it.img = it._imgAny || ""; - } - - delete it._imgByName; - delete it._imgAny; - - // Ensure at least these are in the blob even if index rows are already aggregated - it._searchParts.push(it.sku); - it._searchParts.push(it.name || ""); - it._searchParts.push(it.sampleUrl || ""); - it._searchParts.push(it.cheapestStoreLabel || ""); - - it.searchText = normSearchText(it._searchParts.join(" | ")); - delete it._searchParts; - } - - out.sort((a, b) => (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku)); - return out; -} - -function matchesAllTokens(hayNorm, tokens) { - if (!tokens.length) return true; - for (const t of tokens) { - if (!hayNorm.includes(t)) return false; - } - return true; -} - -function renderThumbHtml(imgUrl, cls = "thumb") { - const img = normImg(imgUrl); - if (!img) return `
`; - return ``; -} - -function renderSearch() { - $app.innerHTML = ` -
-
-
-

Spirit Tracker Viz

-
Search name / url / sku (word AND)
-
- Link SKUs -
- -
- -
-
-
- `; - - const $q = document.getElementById("q"); - const $results = document.getElementById("results"); - - $q.value = loadSavedQuery(); - - let aggBySku = new Map(); - - function renderAggregates(items) { - if (!items.length) { - $results.innerHTML = `
No matches.
`; - return; - } - - const limited = items.slice(0, 80); - $results.innerHTML = limited - .map((it) => { - 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"); - - return ` -
-
-
- ${renderThumbHtml(it.img)} -
-
-
-
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))} -
-
- ${esc(price)} - ${esc(store)}${esc(plus)} -
-
- ${esc(it.sampleUrl || "")} -
-
-
-
- `; - }) - .join(""); - - for (const el of Array.from($results.querySelectorAll(".item"))) { - el.addEventListener("click", () => { - const sku = el.getAttribute("data-sku") || ""; - if (!sku) return; - console.log("[nav] skuKey=", sku, "hash=", `#/item/${encodeURIComponent(sku)}`); - saveQuery($q.value); - location.hash = `#/item/${encodeURIComponent(sku)}`; - }); - } - } - - function renderRecent(recent) { - const items = Array.isArray(recent?.items) ? recent.items : []; - if (!items.length) { - $results.innerHTML = `
Type to search…
`; - return; - } - - const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3; - const limited = items.slice(0, 140); - - $results.innerHTML = - `
Recently changed (last ${esc(days)} day(s)):
` + - limited - .map((r) => { - const kind = - r.kind === "new" - ? "NEW" - : r.kind === "restored" - ? "RESTORED" - : r.kind === "removed" - ? "REMOVED" - : r.kind === "price_down" - ? "PRICE ↓" - : r.kind === "price_up" - ? "PRICE ↑" - : r.kind === "price_change" - ? "PRICE" - : "CHANGE"; - - const priceLine = - r.kind === "new" || r.kind === "restored" || r.kind === "removed" - ? `${esc(r.price || "")}` - : `${esc(r.oldPrice || "")} → ${esc(r.newPrice || "")}`; - - const when = r.ts ? prettyTs(r.ts) : r.date || ""; - - const sku = String(r.sku || ""); - const img = aggBySku.get(sku)?.img || ""; - - return ` -
-
-
- ${renderThumbHtml(img)} -
-
-
-
${esc(r.name || "(no name)")}
- ${esc(displaySku(sku))} -
-
- ${esc(kind)} - ${esc(r.storeLabel || "")} - ${esc(priceLine)} -
-
- ${esc(when)} -
-
- ${esc(r.url || "")} -
-
-
-
- `; - }) - .join(""); - - for (const el of Array.from($results.querySelectorAll(".item"))) { - el.addEventListener("click", () => { - const sku = el.getAttribute("data-sku") || ""; - if (!sku) return; - saveQuery($q.value); - location.hash = `#/item/${encodeURIComponent(sku)}`; - }); - } - } - - let allAgg = []; - let indexReady = false; - - function applySearch() { - if (!indexReady) return; - - const tokens = tokenizeQuery($q.value); - if (!tokens.length) { - loadRecent() - .then(renderRecent) - .catch(() => { - $results.innerHTML = `
Type to search…
`; - }); - return; - } - - const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens)); - renderAggregates(matches); - } - - $results.innerHTML = `
Loading index…
`; - - loadIndex() - .then((idx) => { - const listings = Array.isArray(idx.items) ? idx.items : []; - allAgg = aggregateBySku(listings); - aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); - indexReady = true; - $q.focus(); - applySearch(); - return loadRecent(); - }) - .then((recent) => { - if (!tokenizeQuery($q.value).length) renderRecent(recent); - }) - .catch((e) => { - $results.innerHTML = `
Failed to load: ${esc(e.message)}
`; - }); - - let t = null; - $q.addEventListener("input", () => { - saveQuery($q.value); - - if (t) clearTimeout(t); - t = setTimeout(applySearch, 50); - }); -} - -/* ---------------- SKU Linker ---------------- */ - -function isLocalWriteMode() { - const h = String(location.hostname || "").toLowerCase(); - return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); -} - -function levenshtein(a, b) { - a = String(a || ""); - b = String(b || ""); - const n = a.length, - m = b.length; - if (!n) return m; - if (!m) return n; - const dp = new Array(m + 1); - for (let j = 0; j <= m; j++) dp[j] = j; - - for (let i = 1; i <= n; i++) { - let prev = dp[0]; - dp[0] = i; - const ca = a.charCodeAt(i - 1); - for (let j = 1; j <= m; j++) { - const tmp = dp[j]; - const cost = ca === b.charCodeAt(j - 1) ? 0 : 1; - dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost); - prev = tmp; - } - } - return dp[m]; -} - -function similarityScore(aName, bName) { - const a = normSearchText(aName); - const b = normSearchText(bName); - if (!a || !b) return 0; - - const A = new Set(tokenizeQuery(a)); - const B = new Set(tokenizeQuery(b)); - let inter = 0; - for (const w of A) if (B.has(w)) inter++; - const denom = Math.max(1, Math.max(A.size, B.size)); - const overlap = inter / denom; // 0..1 - - // expensive; used sparingly - const d = levenshtein(a, b); - const maxLen = Math.max(1, Math.max(a.length, b.length)); - const levSim = 1 - d / maxLen; // ~0..1 - - return overlap * 2.2 + levSim * 1.0; -} - -// fast & cheap score: shared token ratio + prefix hint. used for initial pairing only. -function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) { - if (!aTokens.length || !bTokens.length) return 0; - - // count intersection (tokens are small) - let inter = 0; - const bSet = new Set(bTokens); - for (const t of aTokens) if (bSet.has(t)) inter++; - - const denom = Math.max(1, Math.max(aTokens.length, bTokens.length)); - const overlap = inter / denom; - - // small prefix bonus if starts similarly (cheap) - const a = String(aNormName || ""); - const b = String(bNormName || ""); - const aPref = a.slice(0, 10); - const bPref = b.slice(0, 10); - const pref = aPref && bPref && aPref === bPref ? 0.2 : 0; - - return overlap * 2.0 + pref; -} - -function isBCStoreLabel(label) { - const s = String(label || "").toLowerCase(); - 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; - const lab = String(r.storeLabel || r.store || ""); - if (isBCStoreLabel(lab)) return true; - } - return false; -} - -async function loadSkuLinksBestEffort() { - // Works on local server (viz/serve.js). On GH pages this will fail -> empty. - try { - const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); - if (!r.ok) return []; - const j = await r.json(); - return Array.isArray(j?.links) ? j.links : []; - } catch { - return []; - } -} - -function buildMappedSkuSet(links) { - const s = new Set(); - for (const x of Array.isArray(links) ? links : []) { - const a = String(x?.fromSku || "").trim(); - const b = String(x?.toSku || "").trim(); - if (a) s.add(a); - if (b) s.add(b); - } - return s; -} - -function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { - const scored = []; - for (const it of allAgg) { - if (!it) continue; - if (isUnknownSkuKey(it.sku)) continue; - if (mappedSkus && mappedSkus.has(String(it.sku))) continue; - if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; - - const stores = it.stores ? it.stores.size : 0; - const hasPrice = it.cheapestPriceNum !== null ? 1 : 0; - const hasName = it.name ? 1 : 0; - scored.push({ it, s: stores * 2 + hasPrice * 1.2 + hasName * 1.0 }); - } - scored.sort((a, b) => b.s - a.s); - return scored.slice(0, limit).map((x) => x.it); -} - -function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) { - if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); - - const base = String(pinned.name || ""); - const scored = []; - for (const it of allAgg) { - if (!it) continue; - if (isUnknownSkuKey(it.sku)) continue; - if (mappedSkus && mappedSkus.has(String(it.sku))) continue; - if (it.sku === pinned.sku) continue; - if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; - - // keep this reasonably cheap (recommend list sizes are capped) - const s = similarityScore(base, it.name || ""); - if (s > 0) scored.push({ it, s }); - } - scored.sort((a, b) => b.s - a.s); - return scored.slice(0, limit).map((x) => x.it); -} - -// FAST initial pairing: avoids global O(n^2). Token index with caps + small candidate set + cheap scoring. -function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) { - const items = allAgg.filter((it) => { - if (!it) return false; - if (isUnknownSkuKey(it.sku)) return false; - if (mappedSkus && mappedSkus.has(String(it.sku))) return false; - return true; - }); - - // pick a small seed set (fast + good enough) - const seeds = topSuggestions(items, Math.min(220, items.length), "", mappedSkus); - - // token index with per-token cap to prevent huge buckets - const TOKEN_BUCKET_CAP = 180; - const tokMap = new Map(); // token -> item[] - const itemTokens = new Map(); // sku -> tokens[] - const itemNormName = new Map(); // sku -> norm name - - for (const it of items) { - 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) { - let arr = tokMap.get(t); - if (!arr) tokMap.set(t, (arr = [])); - if (arr.length < TOKEN_BUCKET_CAP) arr.push(it); - } - } - - const bestByPair = new Map(); // canonical "a|b" -> {a,b,score} - const MAX_CAND_TOTAL = 90; - const MAX_FINE = 6; // only run expensive score on top few - - for (const a of seeds) { - const aSku = String(a.sku || ""); - const aToks = itemTokens.get(aSku) || []; - if (!aSku || !aToks.length) continue; - - const cand = new Map(); // sku -> item - for (const t of aToks) { - const arr = tokMap.get(t); - if (!arr) continue; - - // grab only a slice from each bucket - for (let i = 0; i < arr.length && cand.size < MAX_CAND_TOTAL; i++) { - const b = arr[i]; - if (!b) continue; - const bSku = String(b.sku || ""); - if (!bSku || bSku === aSku) continue; - if (mappedSkus && mappedSkus.has(bSku)) continue; - if (isUnknownSkuKey(bSku)) continue; - cand.set(bSku, b); - } - if (cand.size >= MAX_CAND_TOTAL) break; - } - - if (!cand.size) continue; - - // cheap rank by token overlap - const aNameN = itemNormName.get(aSku) || ""; - const cheap = []; - for (const b of cand.values()) { - const bSku = String(b.sku || ""); - const bToks = itemTokens.get(bSku) || []; - const bNameN = itemNormName.get(bSku) || ""; - const s = fastSimilarityScore(aToks, bToks, aNameN, bNameN); - if (s > 0) cheap.push({ b, s }); - } - if (!cheap.length) continue; - cheap.sort((x, y) => y.s - x.s); - - // refine top few with full score (levenshtein) - let bestB = null; - let bestS = 0; - const top = cheap.slice(0, MAX_FINE); - for (const x of top) { - const s = similarityScore(a.name || "", x.b.name || ""); - if (s > bestS) { - bestS = s; - bestB = x.b; - } - } - - // threshold to avoid garbage; keep moderate - if (!bestB || bestS < 0.6) continue; - - const bSku = String(bestB.sku || ""); - const key = aSku < bSku ? `${aSku}|${bSku}` : `${bSku}|${aSku}`; - const prev = bestByPair.get(key); - if (!prev || bestS > prev.score) bestByPair.set(key, { a, b: bestB, score: bestS }); - } - - const pairs = Array.from(bestByPair.values()); - pairs.sort((x, y) => y.score - x.score); - - // avoid reusing skus across initial pairs - const used = new Set(); - const out = []; - for (const p of pairs) { - const aSku = String(p.a.sku || ""); - const bSku = String(p.b.sku || ""); - if (!aSku || !bSku || aSku === bSku) continue; - if (used.has(aSku) || used.has(bSku)) continue; - used.add(aSku); - used.add(bSku); - out.push({ a: p.a, b: p.b, score: p.score }); - if (out.length >= limitPairs) break; - } - return out; -} - -async function apiWriteSkuLink(fromSku, toSku) { - const res = await fetch("/__stviz/sku-links", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ fromSku, toSku }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return await res.json(); -} - -async function renderSkuLinker() { - destroyChart(); - - const localWrite = isLocalWriteMode(); - - $app.innerHTML = ` -
-
- -
- SKU Linker - ${esc(localWrite ? "LOCAL WRITE" : "READ-ONLY")} -
- -
-
- Unknown SKUs are hidden. Existing mapped SKUs are excluded. With both pinned, LINK SKU writes to sku_links.json (local only). -
- -
-
-
Left
- -
-
- -
-
Right
- -
-
-
-
- - -
- `; - - document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); - - const $qL = document.getElementById("qL"); - const $qR = document.getElementById("qR"); - const $listL = document.getElementById("listL"); - const $listR = document.getElementById("listR"); - const $linkBtn = document.getElementById("linkBtn"); - const $status = document.getElementById("status"); - - $listL.innerHTML = `
Loading index…
`; - $listR.innerHTML = `
Loading index…
`; - - const idx = await loadIndex(); - const allRows = Array.isArray(idx.items) ? idx.items : []; - - // candidates for this page (hide unknown u: entirely) - const allAgg = aggregateBySku(allRows).filter((it) => !isUnknownSkuKey(it.sku)); - - // mapped skus (local best-effort) - const existingLinks = await loadSkuLinksBestEffort(); - const mappedSkus = buildMappedSkuSet(existingLinks); - - // FAST initial suggestions: pair similar names into left/right lists (unmapped, different sku) - // This is intentionally approximate to keep page snappy. - const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28); - - let pinnedL = null; - let pinnedR = null; - - function openLinkHtml(url) { - const u = String(url || "").trim(); - if (!u) return ""; - return `open`; - } - - function renderCard(it, pinned) { - 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 open = openLinkHtml(it.sampleUrl || ""); - return ` -
-
-
${renderThumbHtml(it.img)}
-
-
-
${esc(it.name || "(no name)")}
- ${esc(displaySku(it.sku))} -
-
- ${esc(price)} - ${esc(store)}${esc(plus)} - ${open} -
-
${esc(it.sampleUrl || "")}
- ${pinned ? `
Pinned (click again to unpin)
` : ``} -
-
-
- `; - } - - function sideItems(side, query, otherPinned) { - const tokens = tokenizeQuery(query); - const otherSku = otherPinned ? String(otherPinned.sku || "") : ""; - - // Search mode (still independent lists) - if (tokens.length) { - return allAgg - .filter( - (it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens) - ) - .slice(0, 80); - } - - // If other side pinned: recommend similar to pinned - if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus); - - // Neither pinned + no search: paired initial suggestions - if (initialPairs && initialPairs.length) { - const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b); - return list.filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku))); - } - - // Fallback - return topSuggestions(allAgg, 60, otherSku, mappedSkus); - } - - function attachHandlers($root, side) { - for (const el of Array.from($root.querySelectorAll(".item"))) { - el.addEventListener("click", () => { - const skuKey = el.getAttribute("data-sku") || ""; - const it = allAgg.find((x) => String(x.sku || "") === skuKey); - if (!it) return; - - if (isUnknownSkuKey(it.sku)) return; - - // exclude already mapped from being pinned/linked - if (mappedSkus.has(String(it.sku))) { - $status.textContent = "This SKU is already mapped; choose an unmapped SKU."; - return; - } - - const other = side === "L" ? pinnedR : pinnedL; - if (other && String(other.sku || "") === String(it.sku || "")) { - $status.textContent = "Not allowed: both sides cannot be the same SKU."; - return; - } - - if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it; - else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it; - - updateAll(); - }); - } - } - - function renderSide(side) { - const pinned = side === "L" ? pinnedL : pinnedR; - const other = side === "L" ? pinnedR : pinnedL; - const query = side === "L" ? $qL.value : $qR.value; - const $list = side === "L" ? $listL : $listR; - - if (pinned) { - $list.innerHTML = renderCard(pinned, true); - attachHandlers($list, side); - return; - } - - const items = sideItems(side, query, other); - $list.innerHTML = items.length ? items.map((it) => renderCard(it, false)).join("") : `
No matches.
`; - attachHandlers($list, side); - } - - function updateButton() { - if (!localWrite) { - $linkBtn.disabled = true; - $status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1."; - return; - } - if (!(pinnedL && pinnedR)) { - $linkBtn.disabled = true; - if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking."; - return; - } - if (String(pinnedL.sku || "") === String(pinnedR.sku || "")) { - $linkBtn.disabled = true; - $status.textContent = "Not allowed: both sides cannot be the same SKU."; - return; - } - if (mappedSkus.has(String(pinnedL.sku)) || mappedSkus.has(String(pinnedR.sku))) { - $linkBtn.disabled = true; - $status.textContent = "Not allowed: one of these SKUs is already mapped."; - return; - } - $linkBtn.disabled = false; - if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = ""; - } - - function updateAll() { - renderSide("L"); - renderSide("R"); - updateButton(); - } - - let tL = null, - tR = null; - $qL.addEventListener("input", () => { - if (tL) clearTimeout(tL); - tL = setTimeout(() => { - $status.textContent = ""; - updateAll(); - }, 60); - }); - $qR.addEventListener("input", () => { - if (tR) clearTimeout(tR); - tR = setTimeout(() => { - $status.textContent = ""; - updateAll(); - }, 60); - }); - - $linkBtn.addEventListener("click", async () => { - if (!(pinnedL && pinnedR) || !localWrite) return; - - const a = String(pinnedL.sku || ""); - const b = String(pinnedR.sku || ""); - - if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) { - $status.textContent = "Not allowed: unknown SKUs cannot be linked."; - return; - } - if (a === b) { - $status.textContent = "Not allowed: both sides cannot be the same SKU."; - return; - } - if (mappedSkus.has(a) || mappedSkus.has(b)) { - $status.textContent = "Not allowed: one of these SKUs is already mapped."; - return; - } - - // Direction: if either is BC-based (BCL/Strath appears), 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; - } - - $status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`; - - try { - const out = await apiWriteSkuLink(fromSku, toSku); - mappedSkus.add(fromSku); - mappedSkus.add(toSku); - $status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}).`; - pinnedL = null; - pinnedR = null; - updateAll(); - } catch (e) { - $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; - } - }); - - updateAll(); -} - -/* ---------------- Detail (chart) ---------------- */ - -let CHART = null; - -function destroyChart() { - if (CHART) { - CHART.destroy(); - CHART = null; - } -} - -async function githubListCommits({ owner, repo, branch, path }) { - const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; - const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`; - const page1 = await fetchJson(u1); - - if (Array.isArray(page1) && page1.length === 100) { - const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`; - const page2 = await fetchJson(u2); - return [...page1, ...(Array.isArray(page2) ? page2 : [])]; - } - - return Array.isArray(page1) ? page1 : []; -} - -async function githubFetchFileAtSha({ owner, repo, sha, path }) { - const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent( - sha - )}/${path}`; - const txt = await fetchText(raw); - return JSON.parse(txt); -} - -function findItemBySkuInDb(obj, skuKey, dbFile, storeLabel) { - const items = Array.isArray(obj?.items) ? obj.items : []; - for (const it of items) { - if (!it || it.removed) continue; - - const real = String(it.sku || "").trim(); - if (real && real === skuKey) return it; - - // synthetic match for blank sku items: hash storeLabel|url - if (!real && String(skuKey || "").startsWith("u:")) { - const row = { - sku: "", - url: String(it.url || ""), - storeLabel: storeLabel || "", - store: "", - }; - const k = keySkuForRow(row); - if (k === skuKey) return it; - } - } - return null; -} - -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]; - 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 }; -} - -// Collapse commit list down to 1 commit per day (keep the most recent commit for that day) -function collapseCommitsToDaily(commits) { - // commits should be oldest -> newest. - const byDate = new Map(); - for (const c of commits) { - const d = String(c?.date || ""); - const sha = String(c?.sha || ""); - if (!d || !sha) continue; - byDate.set(d, { sha, date: d, ts: String(c?.ts || "") }); - } - return [...byDate.values()]; -} - -function cacheKeySeries(sku, dbFile, cacheBust) { - return `stviz:v2:series:${cacheBust}:${sku}:${dbFile}`; -} - -function loadSeriesCache(sku, dbFile, cacheBust) { - try { - const raw = localStorage.getItem(cacheKeySeries(sku, dbFile, cacheBust)); - if (!raw) return null; - const obj = JSON.parse(raw); - if (!obj || !Array.isArray(obj.points)) return null; - const savedAt = Number(obj.savedAt || 0); - if (!Number.isFinite(savedAt) || Date.now() - savedAt > 7 * 24 * 3600 * 1000) return null; - return obj; - } catch { - return null; - } -} - -function saveSeriesCache(sku, dbFile, cacheBust, points) { - try { - localStorage.setItem(cacheKeySeries(sku, dbFile, cacheBust), JSON.stringify({ savedAt: Date.now(), points })); - } catch {} -} - -let DB_COMMITS = null; - -async function loadDbCommitsManifest() { - if (DB_COMMITS) return DB_COMMITS; - try { - DB_COMMITS = await fetchJson("./data/db_commits.json"); - return DB_COMMITS; - } catch { - DB_COMMITS = null; - return null; - } -} - -async function renderItem(sku) { - destroyChart(); - console.log("[renderItem] skuKey=", sku); - - $app.innerHTML = ` -
-
- - ${esc(displaySku(sku))} -
- -
-
-
-
-
Loading…
- -
-
-
- -
- -
-
-
- `; - - document.getElementById("back").addEventListener("click", () => { - location.hash = "#/"; - }); - - const $title = document.getElementById("title"); - const $links = document.getElementById("links"); - const $status = document.getElementById("status"); - const $canvas = document.getElementById("chart"); - const $thumbBox = document.getElementById("thumbBox"); - - const idx = await loadIndex(); - const all = Array.isArray(idx.items) ? idx.items : []; - const want = String(sku || ""); - let cur = all.filter((x) => keySkuForRow(x) === want); - - if (!cur.length) { - // debug: show some Keg N Cork synthetic keys to see what we're actually generating - const knc = all.filter( - (x) => String(x.storeLabel || x.store || "").toLowerCase().includes("keg") && !String(x.sku || "").trim() - ); - - console.log("[renderItem] NOT FOUND. want=", want, "totalRows=", all.length, "kncBlankSkuRows=", knc.length); - - console.log( - "[renderItem] sample KNC computed keys:", - knc.slice(0, 20).map((x) => ({ - key: keySkuForRow(x), - storeLabel: x.storeLabel, - url: x.url, - name: x.name, - })) - ); - } - if (!cur.length) { - $title.textContent = "Item not found in current index"; - $status.textContent = "Tip: index.json only includes current (non-removed) items."; - if ($thumbBox) $thumbBox.innerHTML = `
`; - return; - } - - const nameCounts = new Map(); - for (const r of cur) { - const n = String(r.name || ""); - if (!n) continue; - nameCounts.set(n, (nameCounts.get(n) || 0) + 1); - } - let bestName = cur[0].name || `(SKU ${sku})`; - let bestCount = -1; - for (const [n, c] of nameCounts.entries()) { - if (c > bestCount) { - bestName = n; - bestCount = c; - } - } - $title.textContent = bestName; - - // Pick image that matches the picked name (fallback: any) - let bestImg = ""; - for (const r of cur) { - if (String(r?.name || "") === String(bestName || "") && normImg(r?.img)) { - bestImg = normImg(r.img); - break; - } - } - if (!bestImg) { - for (const r of cur) { - if (normImg(r?.img)) { - bestImg = normImg(r.img); - break; - } - } - } - if ($thumbBox) { - $thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `
`; - } - - $links.innerHTML = cur - .slice() - .sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || ""))) - .map( - (r) => - `${esc(r.storeLabel || r.store || "Store")}` - ) - .join(""); - - const gh = inferGithubOwnerRepo(); - const owner = gh.owner; - const repo = gh.repo; - const branch = "data"; - - const byDbFile = new Map(); - for (const r of cur) { - if (!r.dbFile) continue; - if (!byDbFile.has(r.dbFile)) byDbFile.set(r.dbFile, r); - } - const dbFiles = [...byDbFile.keys()].sort(); - - $status.textContent = `Loading history for ${dbFiles.length} store file(s)…`; - - const manifest = await loadDbCommitsManifest(); - - const allDatesSet = new Set(); - const series = []; - - const fileJsonCache = new Map(); - - const cacheBust = String(idx.generatedAt || new Date().toISOString()); - const today = dateOnly(idx.generatedAt || new Date().toISOString()); - - for (const dbFile of dbFiles) { - const row = byDbFile.get(dbFile); - const storeLabel = String(row.storeLabel || row.store || dbFile); - - const cached = loadSeriesCache(sku, dbFile, cacheBust); - if (cached && Array.isArray(cached.points) && cached.points.length) { - const points = new Map(); - const values = []; - for (const p of cached.points) { - const d = String(p.date || ""); - const v = p.price === null ? null : Number(p.price); - if (!d) continue; - points.set(d, Number.isFinite(v) ? v : null); - if (Number.isFinite(v)) values.push(v); - allDatesSet.add(d); - } - series.push({ label: storeLabel, points, values }); - continue; - } - - let commits = []; - if (manifest && manifest.files && Array.isArray(manifest.files[dbFile])) { - commits = manifest.files[dbFile]; - } else { - try { - let apiCommits = await githubListCommits({ owner, repo, branch, path: dbFile }); - apiCommits = apiCommits.slice().reverse(); // oldest -> newest - commits = apiCommits - .map((c) => { - const sha = String(c?.sha || ""); - const dIso = c?.commit?.committer?.date || c?.commit?.author?.date || ""; - const d = dateOnly(dIso); - return sha && d ? { sha, date: d, ts: String(dIso || "") } : null; - }) - .filter(Boolean); - } catch { - commits = []; - } - } - - commits = collapseCommitsToDaily(commits); - - const points = new Map(); - const values = []; - const compactPoints = []; - - const MAX_POINTS = 260; // daily points (~8-9 months) - if (commits.length > MAX_POINTS) commits = commits.slice(commits.length - MAX_POINTS); - - for (const c of commits) { - const sha = String(c.sha || ""); - const d = String(c.date || ""); - if (!sha || !d) continue; - - const ck = `${sha}|${dbFile}`; - let obj = fileJsonCache.get(ck) || null; - if (!obj) { - try { - obj = await githubFetchFileAtSha({ owner, repo, sha, path: dbFile }); - fileJsonCache.set(ck, obj); - } catch { - continue; - } - } - - const it = findItemBySkuInDb(obj, sku, dbFile, storeLabel); - const pNum = it ? parsePriceToNumber(it.price) : null; - - points.set(d, pNum); - if (pNum !== null) values.push(pNum); - allDatesSet.add(d); - - compactPoints.push({ date: d, price: pNum }); - } - - // Always add "today" from the current index - const curP = parsePriceToNumber(row.price); - if (curP !== null) { - points.set(today, curP); - values.push(curP); - allDatesSet.add(today); - compactPoints.push({ date: today, price: curP }); - } - - saveSeriesCache(sku, dbFile, cacheBust, compactPoints); - series.push({ label: storeLabel, points, values }); - } - - const labels = [...allDatesSet].sort(); - if (!labels.length) { - $status.textContent = "No historical points found."; - return; - } - - const allVals = []; - for (const s of series) for (const v of s.values) allVals.push(v); - const ySug = computeSuggestedY(allVals); - - const datasets = series.map((s) => ({ - label: s.label, - data: labels.map((d) => (s.points.has(d) ? s.points.get(d) : null)), - spanGaps: false, - tension: 0.15, - })); - - const ctx = $canvas.getContext("2d"); - CHART = new Chart(ctx, { - type: "line", - data: { labels, datasets }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "nearest", intersect: false }, - plugins: { - legend: { display: true }, - tooltip: { - callbacks: { - label: (ctx) => { - const v = ctx.parsed?.y; - if (!Number.isFinite(v)) return `${ctx.dataset.label}: (no data)`; - return `${ctx.dataset.label}: $${v.toFixed(2)}`; - }, - }, - }, - }, - scales: { - x: { - ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, - grid: { display: false }, - }, - y: { - ...ySug, - ticks: { callback: (v) => `$${Number(v).toFixed(0)}` }, - }, - }, - }, - }); - - $status.textContent = manifest - ? `History loaded from prebuilt manifest (1 point/day) + current run. Points=${labels.length}.` - : `History loaded (GitHub API fallback; 1 point/day) + current run. Points=${labels.length}.`; -} - -/* ---------------- boot ---------------- */ - -window.addEventListener("hashchange", route); -route(); diff --git a/viz/app/api.js b/viz/app/api.js new file mode 100644 index 0000000..817bfe4 --- /dev/null +++ b/viz/app/api.js @@ -0,0 +1,74 @@ +export async function fetchJson(url) { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return await res.json(); + } + + export async function fetchText(url) { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return await res.text(); + } + + export function inferGithubOwnerRepo() { + const host = location.hostname || ""; + const m = host.match(/^([a-z0-9-]+)\.github\.io$/i); + if (m) { + const owner = m[1]; + const parts = (location.pathname || "/").split("/").filter(Boolean); + const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`; + return { owner, repo }; + } + return { owner: "brennanwilkes", repo: "spirit-tracker" }; + } + + export function isLocalWriteMode() { + const h = String(location.hostname || "").toLowerCase(); + return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); + } + + /* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */ + + export async function loadSkuLinksBestEffort() { + try { + const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); + if (!r.ok) return []; + const j = await r.json(); + return Array.isArray(j?.links) ? j.links : []; + } catch { + return []; + } + } + + export async function apiWriteSkuLink(fromSku, toSku) { + const res = await fetch("/__stviz/sku-links", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fromSku, toSku }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } + + /* ---- GitHub history helpers ---- */ + + export async function githubListCommits({ owner, repo, branch, path }) { + const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`; + const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`; + const page1 = await fetchJson(u1); + + if (Array.isArray(page1) && page1.length === 100) { + const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`; + const page2 = await fetchJson(u2); + return [...page1, ...(Array.isArray(page2) ? page2 : [])]; + } + + return Array.isArray(page1) ? page1 : []; + } + + export async function githubFetchFileAtSha({ owner, repo, sha, path }) { + const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(sha)}/${path}`; + const txt = await fetchText(raw); + return JSON.parse(txt); + } + \ No newline at end of file diff --git a/viz/app/catalog.js b/viz/app/catalog.js new file mode 100644 index 0000000..53f5236 --- /dev/null +++ b/viz/app/catalog.js @@ -0,0 +1,94 @@ +import { normImg } from "./dom.js"; +import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js"; + +// Build one row per SKU + combined searchable text across all listings of that SKU +export function aggregateBySku(listings) { + const bySku = new Map(); + + for (const r of listings) { + const sku = keySkuForRow(r); + + const name = String(r?.name || ""); + const url = String(r?.url || ""); + const storeLabel = String(r?.storeLabel || r?.store || ""); + + const img = normImg(r?.img || r?.image || r?.thumb || ""); + const pNum = parsePriceToNumber(r?.price); + const pStr = String(r?.price || ""); + + let agg = bySku.get(sku); + if (!agg) { + agg = { + sku, + name: name || "", + img: "", + cheapestPriceStr: pStr || "", + cheapestPriceNum: pNum, + cheapestStoreLabel: storeLabel || "", + stores: new Set(), + sampleUrl: url || "", + _searchParts: [], + searchText: "", + + _imgByName: new Map(), // name -> img + _imgAny: "", + }; + bySku.set(sku, agg); + } + + if (storeLabel) agg.stores.add(storeLabel); + if (!agg.sampleUrl && url) agg.sampleUrl = url; + + // Keep first non-empty name, but keep thumbnail aligned to chosen name + if (!agg.name && name) { + agg.name = name; + if (img) agg.img = img; + } else if (agg.name && name === agg.name && img && !agg.img) { + agg.img = img; + } + + if (img) { + if (!agg._imgAny) agg._imgAny = img; + if (name) agg._imgByName.set(name, img); + } + + // cheapest + if (pNum !== null) { + if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) { + agg.cheapestPriceNum = pNum; + agg.cheapestPriceStr = pStr || ""; + agg.cheapestStoreLabel = storeLabel || agg.cheapestStoreLabel; + } + } + + // search parts + agg._searchParts.push(sku); + if (name) agg._searchParts.push(name); + if (url) agg._searchParts.push(url); + if (storeLabel) agg._searchParts.push(storeLabel); + } + + const out = [...bySku.values()]; + + for (const it of out) { + if (!it.img) { + const m = it._imgByName; + if (it.name && m && m.has(it.name)) it.img = m.get(it.name) || ""; + else it.img = it._imgAny || ""; + } + + delete it._imgByName; + delete it._imgAny; + + it._searchParts.push(it.sku); + it._searchParts.push(it.name || ""); + it._searchParts.push(it.sampleUrl || ""); + it._searchParts.push(it.cheapestStoreLabel || ""); + + it.searchText = normSearchText(it._searchParts.join(" | ")); + delete it._searchParts; + } + + out.sort((a, b) => (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku)); + return out; +} diff --git a/viz/app/dom.js b/viz/app/dom.js new file mode 100644 index 0000000..eff8d4d --- /dev/null +++ b/viz/app/dom.js @@ -0,0 +1,28 @@ +export function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + } + + export function normImg(s) { + const v = String(s || "").trim(); + if (!v) return ""; + if (/^data:/i.test(v)) return ""; + return v; + } + + export function dateOnly(iso) { + const m = String(iso ?? "").match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : ""; + } + + export function prettyTs(iso) { + const s = String(iso || ""); + if (!s) return ""; + return s.replace("T", " "); + } + + export function renderThumbHtml(imgUrl, cls = "thumb") { + const img = normImg(imgUrl); + if (!img) return `
`; + return ``; + } + \ No newline at end of file diff --git a/viz/app/item_page.js b/viz/app/item_page.js new file mode 100644 index 0000000..6255900 --- /dev/null +++ b/viz/app/item_page.js @@ -0,0 +1,349 @@ +import { esc, renderThumbHtml, dateOnly } from "./dom.js"; +import { parsePriceToNumber, keySkuForRow, displaySku } from "./sku.js"; +import { loadIndex } from "./state.js"; +import { inferGithubOwnerRepo, githubListCommits, githubFetchFileAtSha, fetchJson } from "./api.js"; + +/* ---------------- Chart lifecycle ---------------- */ + +let CHART = null; + +export function destroyChart() { + if (CHART) { + CHART.destroy(); + CHART = null; + } +} + +/* ---------------- History helpers ---------------- */ + +function findItemBySkuInDb(obj, skuKey, storeLabel) { + const items = Array.isArray(obj?.items) ? obj.items : []; + for (const it of items) { + if (!it || it.removed) continue; + + const real = String(it.sku || "").trim(); + if (real && real === skuKey) return it; + + // synthetic match for blank sku items: hash storeLabel|url + if (!real && String(skuKey || "").startsWith("u:")) { + const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" }; + const k = keySkuForRow(row); + if (k === skuKey) return it; + } + } + return null; +} + +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]; + 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 }; +} + +// Collapse commit list down to 1 commit per day (keep most recent commit for that day) +function collapseCommitsToDaily(commits) { + const byDate = new Map(); + for (const c of commits) { + const d = String(c?.date || ""); + const sha = String(c?.sha || ""); + if (!d || !sha) continue; + byDate.set(d, { sha, date: d, ts: String(c?.ts || "") }); + } + return [...byDate.values()]; +} + +function cacheKeySeries(sku, dbFile, cacheBust) { + return `stviz:v2:series:${cacheBust}:${sku}:${dbFile}`; +} + +function loadSeriesCache(sku, dbFile, cacheBust) { + try { + const raw = localStorage.getItem(cacheKeySeries(sku, dbFile, cacheBust)); + if (!raw) return null; + const obj = JSON.parse(raw); + if (!obj || !Array.isArray(obj.points)) return null; + const savedAt = Number(obj.savedAt || 0); + if (!Number.isFinite(savedAt) || Date.now() - savedAt > 7 * 24 * 3600 * 1000) return null; + return obj; + } catch { + return null; + } +} + +function saveSeriesCache(sku, dbFile, cacheBust, points) { + try { + localStorage.setItem(cacheKeySeries(sku, dbFile, cacheBust), JSON.stringify({ savedAt: Date.now(), points })); + } catch {} +} + +let DB_COMMITS = null; + +async function loadDbCommitsManifest() { + if (DB_COMMITS) return DB_COMMITS; + try { + DB_COMMITS = await fetchJson("./data/db_commits.json"); + return DB_COMMITS; + } catch { + DB_COMMITS = null; + return null; + } +} + +/* ---------------- Page ---------------- */ + +export async function renderItem($app, sku) { + destroyChart(); + console.log("[renderItem] skuKey=", sku); + + $app.innerHTML = ` +
+
+ + ${esc(displaySku(sku))} +
+ +
+
+
+
+
Loading…
+ +
+
+
+ +
+ +
+
+
+ `; + + document.getElementById("back").addEventListener("click", () => { + location.hash = "#/"; + }); + + const $title = document.getElementById("title"); + const $links = document.getElementById("links"); + const $status = document.getElementById("status"); + const $canvas = document.getElementById("chart"); + const $thumbBox = document.getElementById("thumbBox"); + + const idx = await loadIndex(); + const all = Array.isArray(idx.items) ? idx.items : []; + const want = String(sku || ""); + const cur = all.filter((x) => keySkuForRow(x) === want); + + if (!cur.length) { + $title.textContent = "Item not found in current index"; + $status.textContent = "Tip: index.json only includes current (non-removed) items."; + if ($thumbBox) $thumbBox.innerHTML = `
`; + return; + } + + const nameCounts = new Map(); + for (const r of cur) { + const n = String(r.name || ""); + if (!n) continue; + nameCounts.set(n, (nameCounts.get(n) || 0) + 1); + } + + let bestName = cur[0].name || `(SKU ${sku})`; + let bestCount = -1; + for (const [n, c] of nameCounts.entries()) { + if (c > bestCount) { + bestName = n; + bestCount = c; + } + } + $title.textContent = bestName; + + // pick image that matches bestName (fallback any) + let bestImg = ""; + for (const r of cur) { + if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) { + bestImg = String(r.img).trim(); + break; + } + } + if (!bestImg) { + for (const r of cur) { + if (String(r?.img || "").trim()) { + bestImg = String(r.img).trim(); + break; + } + } + } + $thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `
`; + + $links.innerHTML = cur + .slice() + .sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || ""))) + .map((r) => `${esc(r.storeLabel || r.store || "Store")}`) + .join(""); + + const gh = inferGithubOwnerRepo(); + const owner = gh.owner; + const repo = gh.repo; + const branch = "data"; + + const byDbFile = new Map(); + for (const r of cur) if (r.dbFile && !byDbFile.has(r.dbFile)) byDbFile.set(r.dbFile, r); + const dbFiles = [...byDbFile.keys()].sort(); + + $status.textContent = `Loading history for ${dbFiles.length} store file(s)…`; + + const manifest = await loadDbCommitsManifest(); + const allDatesSet = new Set(); + const series = []; + const fileJsonCache = new Map(); + + const cacheBust = String(idx.generatedAt || new Date().toISOString()); + const today = dateOnly(idx.generatedAt || new Date().toISOString()); + + for (const dbFile of dbFiles) { + const row = byDbFile.get(dbFile); + const storeLabel = String(row.storeLabel || row.store || dbFile); + + const cached = loadSeriesCache(sku, dbFile, cacheBust); + if (cached && Array.isArray(cached.points) && cached.points.length) { + const points = new Map(); + const values = []; + for (const p of cached.points) { + const d = String(p.date || ""); + const v = p.price === null ? null : Number(p.price); + if (!d) continue; + points.set(d, Number.isFinite(v) ? v : null); + if (Number.isFinite(v)) values.push(v); + allDatesSet.add(d); + } + series.push({ label: storeLabel, points, values }); + continue; + } + + let commits = []; + if (manifest && manifest.files && Array.isArray(manifest.files[dbFile])) { + commits = manifest.files[dbFile]; + } else { + try { + let apiCommits = await githubListCommits({ owner, repo, branch, path: dbFile }); + apiCommits = apiCommits.slice().reverse(); + commits = apiCommits + .map((c) => { + const sha = String(c?.sha || ""); + const dIso = c?.commit?.committer?.date || c?.commit?.author?.date || ""; + const d = dateOnly(dIso); + return sha && d ? { sha, date: d, ts: String(dIso || "") } : null; + }) + .filter(Boolean); + } catch { + commits = []; + } + } + + commits = collapseCommitsToDaily(commits); + + const points = new Map(); + const values = []; + const compactPoints = []; + + const MAX_POINTS = 260; + if (commits.length > MAX_POINTS) commits = commits.slice(commits.length - MAX_POINTS); + + for (const c of commits) { + const sha = String(c.sha || ""); + const d = String(c.date || ""); + if (!sha || !d) continue; + + const ck = `${sha}|${dbFile}`; + let obj = fileJsonCache.get(ck) || null; + if (!obj) { + try { + obj = await githubFetchFileAtSha({ owner, repo, sha, path: dbFile }); + fileJsonCache.set(ck, obj); + } catch { + continue; + } + } + + const it = findItemBySkuInDb(obj, sku, storeLabel); + const pNum = it ? parsePriceToNumber(it.price) : null; + + points.set(d, pNum); + if (pNum !== null) values.push(pNum); + allDatesSet.add(d); + + compactPoints.push({ date: d, price: pNum }); + } + + // Always add "today" from current index row + const curP = parsePriceToNumber(row.price); + if (curP !== null) { + points.set(today, curP); + values.push(curP); + allDatesSet.add(today); + compactPoints.push({ date: today, price: curP }); + } + + saveSeriesCache(sku, dbFile, cacheBust, compactPoints); + series.push({ label: storeLabel, points, values }); + } + + const labels = [...allDatesSet].sort(); + if (!labels.length) { + $status.textContent = "No historical points found."; + return; + } + + const allVals = []; + for (const s of series) for (const v of s.values) allVals.push(v); + const ySug = computeSuggestedY(allVals); + + const datasets = series.map((s) => ({ + label: s.label, + data: labels.map((d) => (s.points.has(d) ? s.points.get(d) : null)), + spanGaps: false, + tension: 0.15, + })); + + const ctx = $canvas.getContext("2d"); + // Chart is global from the UMD script include + CHART = new Chart(ctx, { + type: "line", + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "nearest", intersect: false }, + plugins: { + legend: { display: true }, + tooltip: { + callbacks: { + label: (ctx) => { + const v = ctx.parsed?.y; + if (!Number.isFinite(v)) return `${ctx.dataset.label}: (no data)`; + return `${ctx.dataset.label}: $${v.toFixed(2)}`; + }, + }, + }, + }, + scales: { + x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } }, + y: { ...ySug, ticks: { callback: (v) => `$${Number(v).toFixed(0)}` } }, + }, + }, + }); + + $status.textContent = manifest + ? `History loaded from prebuilt manifest (1 point/day) + current run. Points=${labels.length}.` + : `History loaded (GitHub API fallback; 1 point/day) + current run. Points=${labels.length}.`; +} diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js new file mode 100644 index 0000000..2ce1cfd --- /dev/null +++ b/viz/app/linker_page.js @@ -0,0 +1,494 @@ +import { esc, renderThumbHtml } from "./dom.js"; +import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js"; +import { loadIndex } from "./state.js"; +import { aggregateBySku } from "./catalog.js"; +import { isLocalWriteMode, loadSkuLinksBestEffort, apiWriteSkuLink } from "./api.js"; + +/* ---------------- Similarity helpers ---------------- */ + +function levenshtein(a, b) { + a = String(a || ""); + b = String(b || ""); + const n = a.length, m = b.length; + if (!n) return m; + if (!m) return n; + + const dp = new Array(m + 1); + for (let j = 0; j <= m; j++) dp[j] = j; + + for (let i = 1; i <= n; i++) { + let prev = dp[0]; + dp[0] = i; + const ca = a.charCodeAt(i - 1); + for (let j = 1; j <= m; j++) { + const tmp = dp[j]; + const cost = ca === b.charCodeAt(j - 1) ? 0 : 1; + dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost); + prev = tmp; + } + } + return dp[m]; +} + +function similarityScore(aName, bName) { + const a = normSearchText(aName); + const b = normSearchText(bName); + if (!a || !b) return 0; + + const A = new Set(tokenizeQuery(a)); + const B = new Set(tokenizeQuery(b)); + let inter = 0; + for (const w of A) if (B.has(w)) inter++; + const denom = Math.max(1, Math.max(A.size, B.size)); + const overlap = inter / denom; + + const d = levenshtein(a, b); + const maxLen = Math.max(1, Math.max(a.length, b.length)); + const levSim = 1 - d / maxLen; + + return overlap * 2.2 + levSim * 1.0; +} + +function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) { + if (!aTokens.length || !bTokens.length) return 0; + + let inter = 0; + const bSet = new Set(bTokens); + for (const t of aTokens) if (bSet.has(t)) inter++; + + const denom = Math.max(1, Math.max(aTokens.length, bTokens.length)); + const overlap = inter / denom; + + const a = String(aNormName || ""); + const b = String(bNormName || ""); + const pref = a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10) ? 0.2 : 0; + + return overlap * 2.0 + pref; +} + +/* ---------------- Mapping helpers ---------------- */ + +function buildMappedSkuSet(links) { + const s = new Set(); + for (const x of Array.isArray(links) ? links : []) { + const a = String(x?.fromSku || "").trim(); + const b = String(x?.toSku || "").trim(); + if (a) s.add(a); + if (b) s.add(b); + } + return s; +} + +function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { + const scored = []; + for (const it of allAgg) { + if (!it) continue; + if (isUnknownSkuKey(it.sku)) continue; + if (mappedSkus && mappedSkus.has(String(it.sku))) continue; + if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; + + const stores = it.stores ? it.stores.size : 0; + const hasPrice = it.cheapestPriceNum !== null ? 1 : 0; + const hasName = it.name ? 1 : 0; + scored.push({ it, s: stores * 2 + hasPrice * 1.2 + hasName * 1.0 }); + } + scored.sort((a, b) => b.s - a.s); + return scored.slice(0, limit).map((x) => x.it); +} + +function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) { + if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); + + const base = String(pinned.name || ""); + const scored = []; + for (const it of allAgg) { + if (!it) continue; + if (isUnknownSkuKey(it.sku)) continue; + if (mappedSkus && mappedSkus.has(String(it.sku))) continue; + if (it.sku === pinned.sku) continue; + if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; + + const s = similarityScore(base, it.name || ""); + if (s > 0) scored.push({ it, s }); + } + scored.sort((a, b) => b.s - a.s); + return scored.slice(0, limit).map((x) => x.it); +} + +// FAST initial pairing (approx) +function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) { + const items = allAgg.filter((it) => { + if (!it) return false; + if (isUnknownSkuKey(it.sku)) return false; + if (mappedSkus && mappedSkus.has(String(it.sku))) return false; + return true; + }); + + const seeds = topSuggestions(items, Math.min(220, items.length), "", mappedSkus); + + const TOKEN_BUCKET_CAP = 180; + const tokMap = new Map(); + const itemTokens = new Map(); + const itemNormName = new Map(); + + for (const it of items) { + 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) { + let arr = tokMap.get(t); + if (!arr) tokMap.set(t, (arr = [])); + if (arr.length < TOKEN_BUCKET_CAP) arr.push(it); + } + } + + const bestByPair = new Map(); + const MAX_CAND_TOTAL = 90; + const MAX_FINE = 6; + + for (const a of seeds) { + const aSku = String(a.sku || ""); + const aToks = itemTokens.get(aSku) || []; + if (!aSku || !aToks.length) continue; + + const cand = new Map(); + for (const t of aToks) { + const arr = tokMap.get(t); + if (!arr) continue; + for (let i = 0; i < arr.length && cand.size < MAX_CAND_TOTAL; i++) { + const b = arr[i]; + if (!b) continue; + const bSku = String(b.sku || ""); + if (!bSku || bSku === aSku) continue; + if (mappedSkus && mappedSkus.has(bSku)) continue; + if (isUnknownSkuKey(bSku)) continue; + cand.set(bSku, b); + } + if (cand.size >= MAX_CAND_TOTAL) break; + } + if (!cand.size) continue; + + const aNameN = itemNormName.get(aSku) || ""; + const cheap = []; + for (const b of cand.values()) { + const bSku = String(b.sku || ""); + const bToks = itemTokens.get(bSku) || []; + const bNameN = itemNormName.get(bSku) || ""; + const s = fastSimilarityScore(aToks, bToks, aNameN, bNameN); + if (s > 0) cheap.push({ b, s }); + } + if (!cheap.length) continue; + cheap.sort((x, y) => y.s - x.s); + + let bestB = null; + let bestS = 0; + for (const x of cheap.slice(0, MAX_FINE)) { + const s = similarityScore(a.name || "", x.b.name || ""); + if (s > bestS) { + bestS = s; + bestB = x.b; + } + } + + if (!bestB || bestS < 0.6) continue; + + const bSku = String(bestB.sku || ""); + const key = aSku < bSku ? `${aSku}|${bSku}` : `${bSku}|${aSku}`; + const prev = bestByPair.get(key); + if (!prev || bestS > prev.score) bestByPair.set(key, { a, b: bestB, score: bestS }); + } + + const pairs = Array.from(bestByPair.values()); + pairs.sort((x, y) => y.score - x.score); + + const used = new Set(); + const out = []; + for (const p of pairs) { + const aSku = String(p.a.sku || ""); + const bSku = String(p.b.sku || ""); + if (!aSku || !bSku || aSku === bSku) continue; + if (used.has(aSku) || used.has(bSku)) continue; + used.add(aSku); + used.add(bSku); + out.push({ a: p.a, b: p.b, score: p.score }); + if (out.length >= limitPairs) break; + } + return out; +} + +function openLinkHtml(url) { + const u = String(url || "").trim(); + if (!u) return ""; + return `open`; +} + +function isBCStoreLabel(label) { + const s = String(label || "").toLowerCase(); + 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; + const lab = String(r.storeLabel || r.store || ""); + if (isBCStoreLabel(lab)) return true; + } + return false; +} + +/* ---------------- Page ---------------- */ + +export async function renderSkuLinker($app) { + const localWrite = isLocalWriteMode(); + + $app.innerHTML = ` +
+
+ +
+ SKU Linker + ${esc(localWrite ? "LOCAL WRITE" : "READ-ONLY")} +
+ +
+
+ Unknown SKUs are hidden. Existing mapped SKUs are excluded. With both pinned, LINK SKU writes to sku_links.json (local only). +
+ +
+
+
Left
+ +
+
+ +
+
Right
+ +
+
+
+
+ + +
+ `; + + document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); + + const $qL = document.getElementById("qL"); + const $qR = document.getElementById("qR"); + const $listL = document.getElementById("listL"); + const $listR = document.getElementById("listR"); + const $linkBtn = document.getElementById("linkBtn"); + const $status = document.getElementById("status"); + + $listL.innerHTML = `
Loading index…
`; + $listR.innerHTML = `
Loading index…
`; + + const idx = await loadIndex(); + const allRows = Array.isArray(idx.items) ? idx.items : []; + + const allAgg = aggregateBySku(allRows).filter((it) => !isUnknownSkuKey(it.sku)); + + const existingLinks = await loadSkuLinksBestEffort(); + const mappedSkus = buildMappedSkuSet(existingLinks); + + const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28); + + let pinnedL = null; + let pinnedR = null; + + function renderCard(it, pinned) { + 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 open = openLinkHtml(it.sampleUrl || ""); + return ` +
+
+
${renderThumbHtml(it.img)}
+
+
+
${esc(it.name || "(no name)")}
+ ${esc(displaySku(it.sku))} +
+
+ ${esc(price)} + ${esc(store)}${esc(plus)} + ${open} +
+
${esc(it.sampleUrl || "")}
+ ${pinned ? `
Pinned (click again to unpin)
` : ``} +
+
+
+ `; + } + + function sideItems(side, query, otherPinned) { + const tokens = tokenizeQuery(query); + const otherSku = otherPinned ? String(otherPinned.sku || "") : ""; + + if (tokens.length) { + return allAgg + .filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens)) + .slice(0, 80); + } + + if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus); + + if (initialPairs && initialPairs.length) { + const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b); + return list.filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku))); + } + + return topSuggestions(allAgg, 60, otherSku, mappedSkus); + } + + function attachHandlers($root, side) { + for (const el of Array.from($root.querySelectorAll(".item"))) { + el.addEventListener("click", () => { + const skuKey = el.getAttribute("data-sku") || ""; + const it = allAgg.find((x) => String(x.sku || "") === skuKey); + if (!it) return; + + if (isUnknownSkuKey(it.sku)) return; + + if (mappedSkus.has(String(it.sku))) { + $status.textContent = "This SKU is already mapped; choose an unmapped SKU."; + return; + } + + const other = side === "L" ? pinnedR : pinnedL; + if (other && String(other.sku || "") === String(it.sku || "")) { + $status.textContent = "Not allowed: both sides cannot be the same SKU."; + return; + } + + if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it; + else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it; + + updateAll(); + }); + } + } + + function renderSide(side) { + const pinned = side === "L" ? pinnedL : pinnedR; + const other = side === "L" ? pinnedR : pinnedL; + const query = side === "L" ? $qL.value : $qR.value; + const $list = side === "L" ? $listL : $listR; + + if (pinned) { + $list.innerHTML = renderCard(pinned, true); + attachHandlers($list, side); + return; + } + + const items = sideItems(side, query, other); + $list.innerHTML = items.length ? items.map((it) => renderCard(it, false)).join("") : `
No matches.
`; + attachHandlers($list, side); + } + + function updateButton() { + if (!localWrite) { + $linkBtn.disabled = true; + $status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1."; + return; + } + if (!(pinnedL && pinnedR)) { + $linkBtn.disabled = true; + if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking."; + return; + } + if (String(pinnedL.sku || "") === String(pinnedR.sku || "")) { + $linkBtn.disabled = true; + $status.textContent = "Not allowed: both sides cannot be the same SKU."; + return; + } + if (mappedSkus.has(String(pinnedL.sku)) || mappedSkus.has(String(pinnedR.sku))) { + $linkBtn.disabled = true; + $status.textContent = "Not allowed: one of these SKUs is already mapped."; + return; + } + $linkBtn.disabled = false; + if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = ""; + } + + function updateAll() { + renderSide("L"); + renderSide("R"); + updateButton(); + } + + let tL = null, tR = null; + $qL.addEventListener("input", () => { + if (tL) clearTimeout(tL); + tL = setTimeout(() => { + $status.textContent = ""; + updateAll(); + }, 60); + }); + $qR.addEventListener("input", () => { + if (tR) clearTimeout(tR); + tR = setTimeout(() => { + $status.textContent = ""; + updateAll(); + }, 60); + }); + + $linkBtn.addEventListener("click", async () => { + if (!(pinnedL && pinnedR) || !localWrite) return; + + const a = String(pinnedL.sku || ""); + const b = String(pinnedR.sku || ""); + + if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) { + $status.textContent = "Not allowed: unknown SKUs cannot be linked."; + return; + } + if (a === b) { + $status.textContent = "Not allowed: both sides cannot be the same SKU."; + return; + } + if (mappedSkus.has(a) || mappedSkus.has(b)) { + $status.textContent = "Not allowed: one of these SKUs is already mapped."; + 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; + } + + $status.textContent = `Writing: ${displaySku(fromSku)} → ${displaySku(toSku)} …`; + + try { + const out = await apiWriteSkuLink(fromSku, toSku); + mappedSkus.add(fromSku); + mappedSkus.add(toSku); + $status.textContent = `Saved: ${displaySku(fromSku)} → ${displaySku(toSku)} (links=${out.count}).`; + pinnedL = null; + pinnedR = null; + updateAll(); + } catch (e) { + $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; + } + }); + + updateAll(); +} diff --git a/viz/app/main.js b/viz/app/main.js new file mode 100644 index 0000000..9b3d51a --- /dev/null +++ b/viz/app/main.js @@ -0,0 +1,31 @@ +/** + * Hash routes: + * #/ search + * #/item/ detail + * #/link sku linker (local-write 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"; + +function route() { + const $app = document.getElementById("app"); + if (!$app) return; + + // always clean up chart when navigating + destroyChart(); + + const h = location.hash || "#/"; + const parts = h.replace(/^#\/?/, "").split("/").filter(Boolean); + + 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); + + return renderSearch($app); +} + +window.addEventListener("hashchange", route); +route(); diff --git a/viz/app/search_page.js b/viz/app/search_page.js new file mode 100644 index 0000000..03eca14 --- /dev/null +++ b/viz/app/search_page.js @@ -0,0 +1,202 @@ +import { esc, renderThumbHtml, prettyTs } from "./dom.js"; +import { tokenizeQuery, matchesAllTokens, displaySku } from "./sku.js"; +import { loadIndex, loadRecent, loadSavedQuery, saveQuery } from "./state.js"; +import { aggregateBySku } from "./catalog.js"; + +export function renderSearch($app) { + $app.innerHTML = ` +
+
+
+

Spirit Tracker Viz

+
Search name / url / sku (word AND)
+
+ Link SKUs +
+ +
+ +
+
+
+ `; + + const $q = document.getElementById("q"); + const $results = document.getElementById("results"); + + $q.value = loadSavedQuery(); + + let aggBySku = new Map(); + let allAgg = []; + let indexReady = false; + + function renderAggregates(items) { + if (!items.length) { + $results.innerHTML = `
No matches.
`; + return; + } + + const limited = items.slice(0, 80); + $results.innerHTML = limited + .map((it) => { + 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"); + + return ` +
+
+
+ ${renderThumbHtml(it.img)} +
+
+
+
${esc(it.name || "(no name)")}
+ ${esc(displaySku(it.sku))} +
+
+ ${esc(price)} + ${esc(store)}${esc(plus)} +
+
+ ${esc(it.sampleUrl || "")} +
+
+
+
+ `; + }) + .join(""); + + for (const el of Array.from($results.querySelectorAll(".item"))) { + el.addEventListener("click", () => { + const sku = el.getAttribute("data-sku") || ""; + if (!sku) return; + saveQuery($q.value); + location.hash = `#/item/${encodeURIComponent(sku)}`; + }); + } + } + + function renderRecent(recent) { + const items = Array.isArray(recent?.items) ? recent.items : []; + if (!items.length) { + $results.innerHTML = `
Type to search…
`; + return; + } + + const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3; + const limited = items.slice(0, 140); + + $results.innerHTML = + `
Recently changed (last ${esc(days)} day(s)):
` + + limited + .map((r) => { + const kind = + r.kind === "new" + ? "NEW" + : r.kind === "restored" + ? "RESTORED" + : r.kind === "removed" + ? "REMOVED" + : r.kind === "price_down" + ? "PRICE ↓" + : r.kind === "price_up" + ? "PRICE ↑" + : r.kind === "price_change" + ? "PRICE" + : "CHANGE"; + + const priceLine = + r.kind === "new" || r.kind === "restored" || r.kind === "removed" + ? `${esc(r.price || "")}` + : `${esc(r.oldPrice || "")} → ${esc(r.newPrice || "")}`; + + const when = r.ts ? prettyTs(r.ts) : r.date || ""; + + const sku = String(r.sku || ""); + const img = aggBySku.get(sku)?.img || ""; + + return ` +
+
+
+ ${renderThumbHtml(img)} +
+
+
+
${esc(r.name || "(no name)")}
+ ${esc(displaySku(sku))} +
+
+ ${esc(kind)} + ${esc(r.storeLabel || "")} + ${esc(priceLine)} +
+
+ ${esc(when)} +
+
+ ${esc(r.url || "")} +
+
+
+
+ `; + }) + .join(""); + + for (const el of Array.from($results.querySelectorAll(".item"))) { + el.addEventListener("click", () => { + const sku = el.getAttribute("data-sku") || ""; + if (!sku) return; + saveQuery($q.value); + location.hash = `#/item/${encodeURIComponent(sku)}`; + }); + } + } + + function applySearch() { + if (!indexReady) return; + + const tokens = tokenizeQuery($q.value); + if (!tokens.length) { + loadRecent() + .then(renderRecent) + .catch(() => { + $results.innerHTML = `
Type to search…
`; + }); + return; + } + + const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens)); + renderAggregates(matches); + } + + $results.innerHTML = `
Loading index…
`; + + loadIndex() + .then((idx) => { + const listings = Array.isArray(idx.items) ? idx.items : []; + allAgg = aggregateBySku(listings); + aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x])); + indexReady = true; + $q.focus(); + applySearch(); + return loadRecent(); + }) + .then((recent) => { + if (!tokenizeQuery($q.value).length) renderRecent(recent); + }) + .catch((e) => { + $results.innerHTML = `
Failed to load: ${esc(e.message)}
`; + }); + + let t = null; + $q.addEventListener("input", () => { + saveQuery($q.value); + if (t) clearTimeout(t); + t = setTimeout(applySearch, 50); + }); +} diff --git a/viz/app/sku.js b/viz/app/sku.js new file mode 100644 index 0000000..aedfd10 --- /dev/null +++ b/viz/app/sku.js @@ -0,0 +1,55 @@ +export function parsePriceToNumber(v) { + const s = String(v ?? "").replace(/[^0-9.]/g, ""); + const n = Number(s); + return Number.isFinite(n) ? n : null; + } + + export function fnv1a32(str) { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16).padStart(8, "0"); + } + + export function makeSyntheticSku(r) { + const store = String(r?.storeLabel || r?.store || "store"); + const url = String(r?.url || ""); + const key = `${store}|${url}`; + return `u:${fnv1a32(key)}`; + } + + export function keySkuForRow(r) { + const real = String(r?.sku || "").trim(); + return real ? real : makeSyntheticSku(r); + } + + export function displaySku(key) { + return String(key || "").startsWith("u:") ? "unknown" : String(key || ""); + } + + export function isUnknownSkuKey(key) { + return String(key || "").startsWith("u:"); + } + + // Normalize for search: lowercase, punctuation -> space, collapse spaces + export function normSearchText(s) { + return String(s ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + export function tokenizeQuery(q) { + const n = normSearchText(q); + return n ? n.split(" ").filter(Boolean) : []; + } + + export function matchesAllTokens(hayNorm, tokens) { + if (!tokens.length) return true; + for (const t of tokens) if (!hayNorm.includes(t)) return false; + return true; + } + \ No newline at end of file diff --git a/viz/app/state.js b/viz/app/state.js new file mode 100644 index 0000000..1c03546 --- /dev/null +++ b/viz/app/state.js @@ -0,0 +1,37 @@ +import { fetchJson } from "./api.js"; + +let INDEX = null; +let RECENT = null; + +export async function loadIndex() { + if (INDEX) return INDEX; + INDEX = await fetchJson("./data/index.json"); + return INDEX; +} + +export async function loadRecent() { + if (RECENT) return RECENT; + try { + RECENT = await fetchJson("./data/recent.json"); + } catch { + RECENT = { count: 0, items: [] }; + } + return RECENT; +} + +// persist search box value across navigation +const Q_LS_KEY = "stviz:v1:search:q"; + +export function loadSavedQuery() { + try { + return localStorage.getItem(Q_LS_KEY) || ""; + } catch { + return ""; + } +} + +export function saveQuery(v) { + try { + localStorage.setItem(Q_LS_KEY, String(v ?? "")); + } catch {} +} diff --git a/viz/index.html b/viz/index.html index 6b49329..3978ac4 100644 --- a/viz/index.html +++ b/viz/index.html @@ -11,6 +11,8 @@ - + + +