import { esc } from "./dom.js"; import { fetchJson, inferGithubOwnerRepo, githubFetchFileAtSha, githubListCommits } from "./api.js"; let _chart = null; 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 statsCacheKey(group, size, latestSha) { return `stviz:v1:stats:common:${group}:${size}:${latestSha}`; } 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 saveStatsCache(group, size, latestSha, payload) { try { localStorage.setItem( statsCacheKey(group, size, latestSha), JSON.stringify({ savedAt: Date.now(), ...payload }) ); } catch {} } function relReportPath(group, size) { return `reports/common_listings_${group}_top${size}.json`; } // 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) : []; const rows = Array.isArray(report?.rows) ? report.rows : []; const sum = new Map(); const cnt = new Map(); for (const s of stores) { sum.set(s, 0); cnt.set(s, 0); } for (const r of rows) { const sp = r && typeof r === "object" ? r.storePrices : null; 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; 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 }; } /* ---------------- 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 : []; // 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 || ""); 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(); } async function buildStatsSeries({ 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; // Fallback if manifest missing/empty 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 cached = loadStatsCache(group, size, latestSha); if (cached) return { latestSha, labels: cached.labels, stores: cached.stores, seriesByStore: cached.seriesByStore }; 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 seriesByStore = {}; for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null); 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; } 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; } } catch { // leave nulls } finally { done++; if (typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length)) { onStatus(`Loading ${labels.length} day(s)… ${done}/${labels.length}`); } } }) ) ); saveStatsCache(group, size, latestSha, { labels, stores, seriesByStore }); return { latestSha, labels, stores, seriesByStore }; } /* ---------------- render ---------------- */ const LS_GROUP = "stviz:v1:stats:group"; const LS_SIZE = "stviz:v1:stats: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 {} } export async function renderStats($app) { destroyStatsChart(); const pref = loadPrefs(); $app.innerHTML = `

Store Price Index

Loading…
`; const $status = document.getElementById("statsStatus"); const $group = document.getElementById("statsGroup"); const $size = document.getElementById("statsSize"); if ($group) $group.value = pref.group; if ($size) $size.value = String(pref.size); const onStatus = (msg) => { if ($status) $status.textContent = String(msg || ""); }; document.getElementById("back")?.addEventListener("click", () => { const last = sessionStorage.getItem("viz:lastRoute"); if (last && last !== location.hash) location.hash = last; else location.hash = "#/"; }); const rerender = async () => { destroyStatsChart(); const group = String($group?.value || "all"); const size = Number($size?.value || 250); savePrefs(group, size); try { onStatus("Loading chart…"); const Chart = await ensureChartJs(); const canvas = document.getElementById("statsChart"); if (!canvas) return; const { labels, stores, seriesByStore, latestSha } = await buildStatsSeries({ group, size, onStatus, }); const datasets = stores.map((s) => ({ label: s, data: Array.isArray(seriesByStore[s]) ? seriesByStore[s] : labels.map(() => 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, animation: false, interaction: { mode: "nearest", intersect: false }, plugins: { legend: { display: true }, tooltip: { callbacks: { label: (ctx2) => { const v = ctx2.parsed?.y; if (!Number.isFinite(v)) return `${ctx2.dataset.label}: (no data)`; return `${ctx2.dataset.label}: ${v.toFixed(2)}%`; }, }, }, }, scales: { x: { title: { display: true, text: "Date" }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } }, y: { title: { display: true, text: "Avg % vs per-SKU median" }, ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 }, }, }, }, }); onStatus(`Loaded ${labels.length} day(s). Source=${esc(relReportPath(group, size))} @ ${esc(latestSha.slice(0, 7))}`); } catch (e) { const msg = esc(e?.message || String(e)); onStatus(`Error: ${msg}`); const card = $app.querySelector(".card"); if (card) card.innerHTML = `
Chart unavailable: ${msg}
`; } }; $group?.addEventListener("change", () => rerender()); $size?.addEventListener("change", () => rerender()); await rerender(); }