diff --git a/viz/app/stats_page.js b/viz/app/stats_page.js index a839329..a3058ad 100644 --- a/viz/app/stats_page.js +++ b/viz/app/stats_page.js @@ -1,31 +1,35 @@ import { esc } from "./dom.js"; -import { fetchJson, inferGithubOwnerRepo, githubFetchFileAtSha, githubListCommits } from "./api.js"; +import { + fetchJson, + inferGithubOwnerRepo, + githubFetchFileAtSha, + githubListCommits, +} from "./api.js"; let _chart = null; - const STORE_LABELS = { - bcl: "BCL", - bsw: "BSW", - coop: "Co-op World of Whisky", - craftcellars: "Craft Cellars", - gull: "Gull Liquor", - kegncork: "Keg N Cork", - kwm: "Kensington Wine Market", - legacy: "Legacy Liquor", - legacyliquor: "Legacy Liquor", - maltsandgrains: "Malts & Grains", - sierrasprings: "Sierra Springs", - strath: "Strath Liquor", - tudor: "Tudor House", - vessel: "Vessel Liquor", - vintage: "Vintage Spirits", - willowpark: "Willow Park", - }; - + bcl: "BCL", + bsw: "BSW", + coop: "Co-op World of Whisky", + craftcellars: "Craft Cellars", + gull: "Gull Liquor", + kegncork: "Keg N Cork", + kwm: "Kensington Wine Market", + legacy: "Legacy Liquor", + legacyliquor: "Legacy Liquor", + maltsandgrains: "Malts & Grains", + sierrasprings: "Sierra Springs", + strath: "Strath Liquor", + tudor: "Tudor House", + vessel: "Vessel Liquor", + vintage: "Vintage Spirits", + willowpark: "Willow Park", +}; + function displayStoreName(storeKey) { -const k = String(storeKey || "").toLowerCase(); -return STORE_LABELS[k] || storeKey; + const k = String(storeKey || "").toLowerCase(); + return STORE_LABELS[k] || storeKey; } export function destroyStatsChart() { @@ -41,7 +45,8 @@ function ensureChartJs() { return new Promise((resolve, reject) => { const s = document.createElement("script"); // UMD build -> window.Chart - s.src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"; + s.src = + "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"; s.async = true; s.onload = () => resolve(window.Chart); s.onerror = () => reject(new Error("Failed to load Chart.js")); @@ -91,41 +96,101 @@ function makeLimiter(max) { }); } -function statsCacheKey(group, size, latestSha) { - return `stviz:v1:stats:common:${group}:${size}:${latestSha}`; +function tokenizeQuery(q) { + return String(q || "") + .toLowerCase() + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); } -function loadStatsCache(group, size, latestSha) { - try { - const raw = localStorage.getItem(statsCacheKey(group, size, latestSha)); - if (!raw) return null; - const obj = JSON.parse(raw); - if (!obj || !Array.isArray(obj.labels) || !Array.isArray(obj.stores) || typeof obj.seriesByStore !== "object") 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 matchesAllTokens(haystack, tokens) { + if (!tokens.length) return true; + const h = String(haystack || "").toLowerCase(); + for (const t of tokens) { + if (!h.includes(t)) return false; } + return true; } -function saveStatsCache(group, size, latestSha, payload) { - try { - localStorage.setItem( - statsCacheKey(group, size, latestSha), - JSON.stringify({ savedAt: Date.now(), ...payload }) - ); - } catch {} +function rowSearchText(r) { + const rep = r?.representative || {}; + return [ + r?.canonSku, + rep?.name, + rep?.skuRaw, + rep?.skuKey, + rep?.categoryLabel, + rep?.storeLabel, + rep?.storeKey, + ] + .map((x) => String(x || "").trim()) + .filter(Boolean) + .join(" | ") + .toLowerCase(); } -function relReportPath(group, size) { - return `reports/common_listings_${group}_top${size}.json`; +// Prefer representative.priceNum; else cheapest.priceNum; else median(storePrices) +function rowPriceNum(r, stores) { + const rep = r?.representative; + const ch = r?.cheapest; + + const a = rep && Number.isFinite(rep.priceNum) ? rep.priceNum : null; + if (a !== null) return a; + + const b = ch && Number.isFinite(ch.priceNum) ? ch.priceNum : null; + if (b !== null) return b; + + const sp = r && typeof r === "object" ? r.storePrices : null; + if (!sp || typeof sp !== "object") return null; + + const prices = []; + for (const s of stores) { + const p = sp[s]; + if (Number.isFinite(p)) prices.push(p); + } + prices.sort((x, y) => x - y); + const med = medianOfSorted(prices); + return Number.isFinite(med) ? med : null; } +/* ---------------- price slider mapping (store-page style) ---------------- */ + +function stepForPrice(p, boundMax) { + const x = Number.isFinite(p) ? p : boundMax; + if (x < 120) return 5; + if (x < 250) return 10; + if (x < 600) return 25; + return 100; +} +function roundToStep(p, boundMax) { + const step = stepForPrice(p, boundMax); + return Math.round(p / step) * step; +} + +function clamp(n, lo, hi) { + return Math.max(lo, Math.min(hi, n)); +} + +function formatDollars(p) { + if (!Number.isFinite(p)) return ""; + return `$${Math.round(p)}`; +} + +/* ---------------- report filtering + series ---------------- */ + // avg over SKUs that store has a price for: ((storePrice - medianPrice) / medianPrice) * 100 -function computeDailyStoreSeriesFromReport(report) { - const stores = Array.isArray(report?.stores) ? report.stores.map(String) : []; +function computeDailyStoreSeriesFromReport(report, filter) { + const stores = Array.isArray(filter?.stores) + ? filter.stores.map(String) + : Array.isArray(report?.stores) + ? report.stores.map(String) + : []; + const rows = Array.isArray(report?.rows) ? report.rows : []; + const tokens = Array.isArray(filter?.tokens) ? filter.tokens : []; + const minP = Number.isFinite(filter?.minPrice) ? filter.minPrice : null; + const maxP = Number.isFinite(filter?.maxPrice) ? filter.maxPrice : null; const sum = new Map(); const cnt = new Map(); @@ -134,8 +199,25 @@ function computeDailyStoreSeriesFromReport(report) { cnt.set(s, 0); } + let usedRows = 0; + for (const r of rows) { - const sp = r && typeof r === "object" ? r.storePrices : null; + if (!r || typeof r !== "object") continue; + + if (tokens.length) { + if (!matchesAllTokens(rowSearchText(r), tokens)) continue; + } + + if (minP !== null || maxP !== null) { + const rp = rowPriceNum(r, stores); + // store-page behavior: "no price" rows pass the filter (they won't contribute anyway) + if (rp !== null) { + if (minP !== null && rp < minP) continue; + if (maxP !== null && rp > maxP) continue; + } + } + + const sp = r.storePrices; if (!sp || typeof sp !== "object") continue; const prices = []; @@ -148,6 +230,8 @@ function computeDailyStoreSeriesFromReport(report) { const med = medianOfSorted(prices); if (!isFinitePos(med)) continue; + usedRows++; + for (const s of stores) { const p = sp[s]; if (!Number.isFinite(p)) continue; @@ -162,7 +246,11 @@ function computeDailyStoreSeriesFromReport(report) { const c = cnt.get(s) || 0; out[s] = c > 0 ? (sum.get(s) || 0) / c : null; } - return { stores, valuesByStore: out }; + return { stores, valuesByStore: out, usedRows, totalRows: rows.length }; +} + +function relReportPath(group, size) { + return `reports/common_listings_${group}_top${size}.json`; } /* ---------------- commits manifest ---------------- */ @@ -186,7 +274,6 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) { let apiCommits = await githubListCommits({ owner, repo, branch, path: relPath }); apiCommits = Array.isArray(apiCommits) ? apiCommits : []; - // newest -> oldest from API; we want newest-per-day then oldest -> newest const byDate = new Map(); for (const c of apiCommits) { const sha = String(c?.sha || ""); @@ -195,11 +282,14 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) { if (!sha || !d) continue; if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts }); } - return [...byDate.values()].reverse(); } -async function buildStatsSeries({ group, size, onStatus }) { +/* ---------------- raw series cache (network only once per group/size) ---------------- */ + +const RAW_SERIES_CACHE = new Map(); // key: `${group}:${size}` -> { latestSha, labels, stores, commits, reportsByIdx } + +async function loadRawSeries({ group, size, onStatus }) { const rel = relReportPath(group, size); const gh = inferGithubOwnerRepo(); const owner = gh.owner; @@ -207,12 +297,11 @@ async function buildStatsSeries({ group, size, onStatus }) { const branch = "data"; const manifest = await loadCommonCommitsManifest(); - let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null; - // Fallback if manifest missing/empty if (!commits || !commits.length) { - if (typeof onStatus === "function") onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`); + if (typeof onStatus === "function") + onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`); commits = await loadCommitsFallback({ owner, repo, branch, relPath: rel }); } @@ -222,49 +311,40 @@ async function buildStatsSeries({ group, size, onStatus }) { const latestSha = String(latest?.sha || ""); if (!latestSha) throw new Error(`Invalid latest sha for ${rel}`); - const cached = loadStatsCache(group, size, latestSha); - if (cached) return { latestSha, labels: cached.labels, stores: cached.stores, seriesByStore: cached.seriesByStore }; + const cacheKey = `${group}:${size}`; + const cached = RAW_SERIES_CACHE.get(cacheKey); + if (cached && cached.latestSha === latestSha && cached.labels?.length === commits.length) { + return cached; + } const NET_CONCURRENCY = 10; const limitNet = makeLimiter(NET_CONCURRENCY); if (typeof onStatus === "function") onStatus(`Loading stores…`); - const newestReport = await limitNet(() => githubFetchFileAtSha({ owner, repo, sha: latestSha, path: rel })); + const newestReport = await limitNet(() => + githubFetchFileAtSha({ owner, repo, sha: latestSha, path: rel }) + ); - const stores = Array.isArray(newestReport?.stores) ? newestReport.stores.map(String) : []; + const stores = Array.isArray(newestReport?.stores) + ? newestReport.stores.map(String) + : []; if (!stores.length) throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`); const labels = commits.map((c) => String(c.date || "")).filter(Boolean); - - const seriesByStore = {}; - for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null); + const shaByIdx = commits.map((c) => String(c.sha || "")); if (typeof onStatus === "function") onStatus(`Loading ${labels.length} day(s)…`); - const shaByIdx = commits.map((c) => String(c.sha || "")); - const fileJsonCache = new Map(); - - async function loadReportAtSha(sha) { - if (fileJsonCache.has(sha)) return fileJsonCache.get(sha); - const obj = await githubFetchFileAtSha({ owner, repo, sha, path: rel }); - fileJsonCache.set(sha, obj); - return obj; - } + const reportsByIdx = new Array(shaByIdx.length).fill(null); let done = 0; await Promise.all( shaByIdx.map((sha, idx) => limitNet(async () => { try { - const report = await loadReportAtSha(sha); - const { valuesByStore } = computeDailyStoreSeriesFromReport(report); - - for (const s of stores) { - const v = valuesByStore[s]; - seriesByStore[s][idx] = Number.isFinite(v) ? v : null; - } + reportsByIdx[idx] = await githubFetchFileAtSha({ owner, repo, sha, path: rel }); } catch { - // leave nulls + reportsByIdx[idx] = null; } finally { done++; if (typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length)) { @@ -275,15 +355,73 @@ async function buildStatsSeries({ group, size, onStatus }) { ) ); - saveStatsCache(group, size, latestSha, { labels, stores, seriesByStore }); - return { latestSha, labels, stores, seriesByStore }; + const out = { latestSha, labels, stores, commits, reportsByIdx }; + RAW_SERIES_CACHE.set(cacheKey, out); + return out; } -/* ---------------- render ---------------- */ +function computePriceBoundsFromReport(report, stores) { + const rows = Array.isArray(report?.rows) ? report.rows : []; + let mn = null; + let mx = null; + + for (const r of rows) { + const p = rowPriceNum(r, stores); + if (!Number.isFinite(p) || p <= 0) continue; + mn = mn === null ? p : Math.min(mn, p); + mx = mx === null ? p : Math.max(mx, p); + } + return { min: mn, max: mx }; +} + +function computeSeriesFromRaw(raw, filter) { + const labels = raw.labels; + const stores = raw.stores; + const reportsByIdx = raw.reportsByIdx; + + const seriesByStore = {}; + for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null); + + // counts based on newest day only (for status) + let newestUsed = 0; + let newestTotal = 0; + + for (let i = 0; i < reportsByIdx.length; i++) { + const rep = reportsByIdx[i]; + if (!rep) continue; + + const daily = computeDailyStoreSeriesFromReport(rep, { + ...filter, + stores, + }); + + for (const s of stores) { + const v = daily.valuesByStore[s]; + seriesByStore[s][i] = Number.isFinite(v) ? v : null; + } + + if (i === reportsByIdx.length - 1) { + newestUsed = daily.usedRows; + newestTotal = daily.totalRows; + } + } + + return { labels, stores, seriesByStore, newestUsed, newestTotal }; +} + +/* ---------------- prefs ---------------- */ const LS_GROUP = "stviz:v1:stats:group"; const LS_SIZE = "stviz:v1:stats:size"; +const LS_Q = "stviz:v1:stats:q"; +function lsMinKey(group, size) { + return `stviz:v1:stats:minPrice:${group}:${size}`; +} +function lsMaxKey(group, size) { + return `stviz:v1:stats:maxPrice:${group}:${size}`; +} + function loadPrefs() { let group = "all"; let size = "250"; @@ -303,6 +441,8 @@ function savePrefs(group, size) { } catch {} } +/* ---------------- render ---------------- */ + export async function renderStats($app) { destroyStatsChart(); @@ -310,55 +450,82 @@ export async function renderStats($app) { $app.innerHTML = `