import { esc } from "./dom.js"; import { fetchJson, inferGithubOwnerRepo, githubFetchFileAtSha, githubListCommits, } from "./api.js"; import { buildStoreColorMap, storeColor, datasetStrokeWidth, lighten } from "./storeColors.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", }; function displayStoreName(storeKey) { const k = String(storeKey || "").toLowerCase(); return STORE_LABELS[k] || storeKey; } export function destroyStatsChart() { try { if (_chart) _chart.destroy(); } catch {} _chart = null; } function ensureChartJs() { if (window.Chart) return Promise.resolve(window.Chart); 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.async = true; s.onload = () => resolve(window.Chart); s.onerror = () => reject(new Error("Failed to load Chart.js")); document.head.appendChild(s); }); } /* ---------------- helpers ---------------- */ function dateOnly(iso) { const m = String(iso ?? "").match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : ""; } function medianOfSorted(nums) { const n = nums.length; if (!n) return null; const mid = Math.floor(n / 2); if (n % 2 === 1) return nums[mid]; return (nums[mid - 1] + nums[mid]) / 2; } function isFinitePos(n) { return Number.isFinite(n) && n > 0; } function makeLimiter(max) { let active = 0; const q = []; const runNext = () => { while (active < max && q.length) { active++; const { fn, resolve, reject } = q.shift(); Promise.resolve() .then(fn) .then(resolve, reject) .finally(() => { active--; runNext(); }); } }; return (fn) => new Promise((resolve, reject) => { q.push({ fn, resolve, reject }); runNext(); }); } function tokenizeQuery(q) { return String(q || "") .toLowerCase() .split(/\s+/) .map((s) => s.trim()) .filter(Boolean); } 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 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(); } // 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-ish, but faster low-end) ---------------- */ // faster low-end: coarser step sizes early so you jump past $10/$20 quickly function stepForPrice(p, boundMax) { const x = Number.isFinite(p) ? p : boundMax; if (x < 50) return 10; if (x < 120) return 25; if (x < 250) return 25; if (x < 600) return 50; 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, 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(); for (const s of stores) { sum.set(s, 0); cnt.set(s, 0); } let usedRows = 0; for (const r of rows) { 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); // "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 = []; for (const s of stores) { const p = sp[s]; if (Number.isFinite(p)) prices.push(p); } prices.sort((a, b) => a - b); const med = medianOfSorted(prices); if (!isFinitePos(med)) continue; usedRows++; for (const s of stores) { const p = sp[s]; if (!Number.isFinite(p)) continue; const pct = ((p - med) / med) * 100; sum.set(s, (sum.get(s) || 0) + pct); cnt.set(s, (cnt.get(s) || 0) + 1); } } const out = {}; for (const s of stores) { const c = cnt.get(s) || 0; out[s] = c > 0 ? (sum.get(s) || 0) / c : null; } return { stores, valuesByStore: out, usedRows, totalRows: rows.length }; } function relReportPath(group, size) { return `reports/common_listings_${group}_top${size}.json`; } /* ---------------- commits manifest ---------------- */ let COMMON_COMMITS = null; async function loadCommonCommitsManifest() { if (COMMON_COMMITS) return COMMON_COMMITS; try { COMMON_COMMITS = await fetchJson("./data/common_listings_commits.json"); return COMMON_COMMITS; } catch { COMMON_COMMITS = null; return null; } } // Fallback: GitHub API commits for a path, collapsed to one commit per day (newest that day), // returned oldest -> newest, same shape as manifest entries. async function loadCommitsFallback({ owner, repo, branch, relPath }) { let apiCommits = await githubListCommits({ owner, repo, branch, path: relPath, }); apiCommits = Array.isArray(apiCommits) ? apiCommits : []; const byDate = new Map(); for (const c of apiCommits) { const sha = String(c?.sha || ""); const ts = String( c?.commit?.committer?.date || c?.commit?.author?.date || "" ); const d = dateOnly(ts); if (!sha || !d) continue; if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts }); } return [...byDate.values()].reverse(); } /* ---------------- raw series cache ---------------- */ 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; const repo = gh.repo; const branch = "data"; const manifest = await loadCommonCommitsManifest(); let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null; if (!commits || !commits.length) { if (typeof onStatus === "function") onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`); commits = await loadCommitsFallback({ owner, repo, branch, relPath: rel }); } if (!commits || !commits.length) throw new Error(`No commits tracked for ${rel}`); const latest = commits[commits.length - 1]; const latestSha = String(latest?.sha || ""); if (!latestSha) throw new Error(`Invalid latest sha for ${rel}`); 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 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 shaByIdx = commits.map((c) => String(c.sha || "")); if (typeof onStatus === "function") onStatus(`Loading ${labels.length} day(s)…`); const reportsByIdx = new Array(shaByIdx.length).fill(null); let done = 0; await Promise.all( shaByIdx.map((sha, idx) => limitNet(async () => { try { reportsByIdx[idx] = await githubFetchFileAtSha({ owner, repo, sha, path: rel, }); } catch { reportsByIdx[idx] = null; } finally { done++; if ( typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length) ) { onStatus(`Loading ${labels.length} day(s)… ${done}/${labels.length}`); } } }) ) ); const out = { latestSha, labels, stores, commits, reportsByIdx }; RAW_SERIES_CACHE.set(cacheKey, out); return out; } 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); 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 }; } /* ---------------- y-axis bounds ---------------- */ function computeYBounds(seriesByStore, minSpan = 6, pad = 1) { let mn = Infinity, mx = -Infinity; for (const arr of Object.values(seriesByStore || {})) { if (!Array.isArray(arr)) continue; for (const v of arr) { if (!Number.isFinite(v)) continue; mn = Math.min(mn, v); mx = Math.max(mx, v); } } if (mn === Infinity) return { min: -minSpan / 2, max: minSpan / 2 }; // pad a bit so lines aren't glued to edges mn = Math.floor(mn - pad); mx = Math.ceil(mx + pad); // enforce a minimum visible range so it doesn't get *too* tight const span = mx - mn; if (span < minSpan) { const mid = (mn + mx) / 2; mn = Math.floor(mid - minSpan / 2); mx = Math.ceil(mid + minSpan / 2); } return { min: mn, max: mx }; } /* ---------------- 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"; try { group = String(localStorage.getItem(LS_GROUP) || "all"); size = String(localStorage.getItem(LS_SIZE) || "250"); } catch {} group = group === "bc" || group === "ab" || group === "all" ? group : "all"; size = size === "50" || size === "250" || size === "1000" ? size : "250"; return { group, size: Number(size) }; } function savePrefs(group, size) { try { localStorage.setItem(LS_GROUP, String(group)); localStorage.setItem(LS_SIZE, String(size)); } catch {} } /* ---------------- render ---------------- */ export async function renderStats($app) { destroyStatsChart(); const pref = loadPrefs(); $app.innerHTML = `