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"; import { loadSkuRules } from "./mapping.js"; import { buildStoreColorMap, storeColor, datasetStrokeWidth, lighten } from "./storeColors.js"; /* ---------------- Chart lifecycle ---------------- */ let CHART = null; /* ---------------- Static marker lines ---------------- */ // --- Province store matching (robust to labels like "Vessel Liquor", "BCL", etc.) --- const BC_STORE_NAMES = new Set([ "bcl", "tudorhouse", "vesselliquor", "strathliquor", "gullliquor", "vintagespirits", "legacyliquor", "arc", ]); function normStoreLabel(s) { return String(s || "") .toLowerCase() .replace(/&/g, "and") .replace(/[^a-z0-9]+/g, " ") .trim() .replace(/\s+/g, ""); } function isBcStoreLabel(label) { const n = normStoreLabel(label); if (BC_STORE_NAMES.has(n)) return true; // extra fuzzy contains for safety if (n.includes("vessel")) return true; if (n.includes("tudor")) return true; if (n === "bcl") return true; if (n.includes("strath")) return true; if (n.includes("gull")) return true; if (n.includes("vintagespirits")) return true; if (n.includes("legacy")) return true; if (n.includes("arc")) return true; return false; } function weightedMeanByDuration(pointsMap, sortedDates) { // pointsMap: Map(dateStr -> number|null) // sortedDates: ["YYYY-MM-DD", ...] sorted asc let wsum = 0; let wtot = 0; for (let i = 0; i < sortedDates.length; i++) { const d0 = sortedDates[i]; const v = pointsMap.get(d0); if (!Number.isFinite(v)) continue; const t0 = Date.parse(d0 + "T00:00:00Z"); const d1 = sortedDates[i + 1]; const t1 = d1 ? Date.parse(d1 + "T00:00:00Z") : t0 + 24 * 3600 * 1000; // weight in days (min 1 day) const w = Math.max(1, Math.round((t1 - t0) / (24 * 3600 * 1000))); wsum += v * w; wtot += w; } return wtot ? wsum / wtot : null; } function meanFinite(arr) { if (!Array.isArray(arr)) return null; let sum = 0, n = 0; for (const v of arr) { if (Number.isFinite(v)) { sum += v; n++; } } return n ? sum / n : null; } function minFinite(arr) { if (!Array.isArray(arr)) return null; let m = null; for (const v of arr) { if (Number.isFinite(v)) m = m === null ? v : Math.min(m, v); } return m; } function medianFinite(nums) { const a = (Array.isArray(nums) ? nums : []) .filter((v) => Number.isFinite(v)) .slice() .sort((x, y) => x - y); const n = a.length; if (!n) return null; const mid = Math.floor(n / 2); return n % 2 ? a[mid] : (a[mid - 1] + a[mid]) / 2; } const StaticMarkerLinesPlugin = { id: "staticMarkerLines", afterDraw(chart, _args, passedOpts) { const opts = (chart?.options?.plugins && chart.options.plugins.staticMarkerLines) || chart?.options?.staticMarkerLines || passedOpts || {}; const markers = Array.isArray(opts?.markers) ? opts.markers : []; if (!markers.length) return; // Find y-scale (v2/v3 tolerant) const scalesObj = chart?.scales || {}; const scales = Object.values(scalesObj); const y = scalesObj.y || scales.find((s) => s && s.axis === "y") || scales.find((s) => s && typeof s.getPixelForValue === "function" && s.isHorizontal === false) || scales.find( (s) => s && typeof s.getPixelForValue === "function" && String(s.id || "") .toLowerCase() .includes("y"), ); const area = chart?.chartArea; if (!y || !area) return; const { ctx } = chart; const { left, right, top, bottom } = area; const lineWidth = Number.isFinite(opts?.lineWidth) ? opts.lineWidth : 1.25; // thinner const baseAlpha = Number.isFinite(opts?.alpha) ? opts.alpha : 0.38; // brighter than before const strokeStyle = String(opts?.color || "#7f8da3"); // light grey-blue // "marker on Y axis" text const font = opts?.font || "600 11px system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif"; const labelColor = String(opts?.labelColor || "#556274"); const axisInset = Number.isFinite(opts?.axisInset) ? opts.axisInset : 2; ctx.save(); ctx.setLineDash([]); // SOLID ctx.lineWidth = lineWidth; ctx.font = font; ctx.fillStyle = labelColor; for (const m of markers) { const yVal = Number(m?.y); if (!Number.isFinite(yVal)) continue; const py = y.getPixelForValue(yVal); if (!Number.isFinite(py) || py < top || py > bottom) continue; // line ctx.globalAlpha = Number.isFinite(m?.alpha) ? m.alpha : baseAlpha; ctx.strokeStyle = String(m?.color || strokeStyle); ctx.beginPath(); ctx.moveTo(left, py); ctx.lineTo(right, py); ctx.stroke(); // tiny tick mark starting at axisX // y scale box: [y.left .. y.right], where y.right is usually chartArea.left const yLeft = Number.isFinite(y?.left) ? y.left : left; const yRight = Number.isFinite(y?.right) ? y.right : left; // center of the y-axis box (clamped) const axisCenterX = Math.max(0, Math.min((yLeft + yRight) / 2, chart.width)); // draw the little tick at the chart edge (right edge of y-axis box) ctx.beginPath(); ctx.moveTo(yRight, py); ctx.lineTo(yRight + 6, py); ctx.stroke(); // label centered in the y-axis box const text = String(m?.text || ""); if (text) { ctx.globalAlpha = 0.95; ctx.fillStyle = String(m?.labelColor || labelColor); ctx.textBaseline = "middle"; ctx.textAlign = "center"; ctx.fillText(text, axisCenterX, py); } } ctx.restore(); }, }; export function destroyChart() { if (CHART) { CHART.destroy(); CHART = null; } } /* ---------------- Small async helpers ---------------- */ async function mapLimit(list, limit, fn) { const out = new Array(list.length); let i = 0; const n = Math.max(1, Math.floor(limit || 1)); const workers = Array.from({ length: n }, async () => { while (true) { const idx = i++; if (idx >= list.length) return; out[idx] = await fn(list[idx], idx); } }); await Promise.all(workers); return out; } // Global limiter for aggressive network concurrency (shared across ALL stores/files) 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 findMinPricesForSkuGroupInDb(obj, wantRealSkus, skuKeys, storeLabel, wantUrls) { const items = Array.isArray(obj?.items) ? obj.items : []; let liveMin = null; let removedMin = null; const consider = (isRemoved, priceVal) => { const p = parsePriceToNumber(priceVal); if (p === null) return; if (!isRemoved) liveMin = liveMin === null ? p : Math.min(liveMin, p); else removedMin = removedMin === null ? p : Math.min(removedMin, p); }; const urlSet = wantUrls instanceof Set ? wantUrls : new Set(); const skuKeySet = new Set((Array.isArray(skuKeys) ? skuKeys : []).map((s) => String(s || ""))); for (const it of items) { if (!it) continue; const isRemoved = Boolean(it.removed); const url = String(it.url || ""); // 0) URL match (critical for u: keys when storeLabel changes over time) if (url && urlSet.size && urlSet.has(url)) { consider(isRemoved, it.price); continue; } // 1) Real SKU match (fast path) const real = String(it.sku || "").trim(); if (real && wantRealSkus && wantRealSkus.has(real)) { consider(isRemoved, it.price); continue; } // 2) Fallback: keySkuForRow match (still useful for real SKUs and stable labels) if (skuKeySet.size) { const row = { sku: real, url, storeLabel: storeLabel || "", store: "" }; const kk = keySkuForRow(row); if (skuKeySet.has(String(kk || ""))) { consider(isRemoved, it.price); } } } return { liveMin, removedMin }; } function lastFiniteFromEnd(arr) { if (!Array.isArray(arr)) return null; for (let i = arr.length - 1; i >= 0; i--) { const v = arr[i]; if (Number.isFinite(v)) return v; } return null; } function computeSuggestedY(values, minRange) { 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; } const range = max - min; const pad = range === 0 ? Math.max(1, min * 0.05) : range * 0.08; let suggestedMin = Math.max(0, min - pad); let suggestedMax = max + pad; if (Number.isFinite(minRange) && minRange > 0) { const span = suggestedMax - suggestedMin; if (span < minRange) { const mid = (suggestedMin + suggestedMax) / 2; suggestedMin = mid - minRange / 2; suggestedMax = mid + minRange / 2; if (suggestedMin < 0) { suggestedMax -= suggestedMin; suggestedMin = 0; } } } return { suggestedMin, suggestedMax }; } /* ---------------- Series cache (per dbFile + per skuKey) ---------------- */ function cacheKeySeries(sku, dbFile, cacheBust, variantKey) { return `stviz:v6:series:${cacheBust}:${sku}:${dbFile}:${variantKey || ""}`; } function loadSeriesCache(sku, dbFile, cacheBust, variantKey) { try { const raw = localStorage.getItem(cacheKeySeries(sku, dbFile, cacheBust, variantKey)); 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, variantKey, points) { try { localStorage.setItem( cacheKeySeries(sku, dbFile, cacheBust, variantKey), 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; } } function niceStepAtLeast(minStep, span, maxTicks) { if (!Number.isFinite(span) || span <= 0) return minStep; const target = span / Math.max(1, maxTicks - 1); // desired step to stay under maxTicks const raw = Math.max(minStep, target); // "nice" steps: 1/2/5 * 10^k, but never below minStep const pow = Math.pow(10, Math.floor(Math.log10(raw))); const m = raw / pow; const niceM = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10; return Math.max(minStep, niceM * pow); } function cacheBustForDbFile(manifest, dbFile, commits) { const arr = manifest?.files?.[dbFile]; if (Array.isArray(arr) && arr.length) { const sha = String(arr[arr.length - 1]?.sha || ""); if (sha) return sha; } if (Array.isArray(commits) && commits.length) { const sha = String(commits[commits.length - 1]?.sha || ""); if (sha) return sha; } return "no-sha"; } /* ---------------- Page ---------------- */ export async function renderItem($app, skuInput) { destroyChart(); const rules = await loadSkuRules(); const sku = rules.canonicalSku(String(skuInput || "")); $app.innerHTML = `
${esc(displaySku(sku))}
Loading…
`; document.getElementById("back").addEventListener("click", () => { const last = sessionStorage.getItem("viz:lastRoute"); if (last && last !== location.hash) location.hash = last; else 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 : []; // include toSku + all fromSkus mapped to it const skuGroup = rules.groupForCanonical(sku); // index.json includes removed rows too. Split live vs all. const allRows = all.filter((x) => skuGroup.has(String(keySkuForRow(x) || ""))); const liveRows = allRows.filter((x) => !Boolean(x?.removed)); if (!allRows.length) { $title.textContent = "Item not found"; $status.textContent = "No matching SKU in index."; if ($thumbBox) $thumbBox.innerHTML = `
`; return; } const isRemovedEverywhere = liveRows.length === 0; // pick bestName by most common across LIVE rows (fallback to allRows) const basisForName = liveRows.length ? liveRows : allRows; const nameCounts = new Map(); for (const r of basisForName) { const n = String(r.name || ""); if (!n) continue; nameCounts.set(n, (nameCounts.get(n) || 0) + 1); } let bestName = basisForName[0].name || `(SKU ${sku})`; let bestCount = -1; for (const [n, c] of nameCounts.entries()) { if (c > bestCount) { bestName = n; bestCount = c; } } $title.textContent = bestName; // choose thumbnail from cheapest LIVE listing (fallback: any matching name; fallback: any) let bestImg = ""; let bestPrice = null; const basisForThumb = liveRows.length ? liveRows : allRows; for (const r of basisForThumb) { const p = parsePriceToNumber(r.price); const img = String(r?.img || "").trim(); if (p !== null && img) { if (bestPrice === null || p < bestPrice) { bestPrice = p; bestImg = img; } } } if (!bestImg) { for (const r of basisForThumb) { if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) { bestImg = String(r.img).trim(); break; } } } if (!bestImg) { for (const r of basisForThumb) { if (String(r?.img || "").trim()) { bestImg = String(r.img).trim(); break; } } } $thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `
`; // Render store links: // - one link per store label (even if URL differs) // - pick most recent row for that store function rowMs(r) { const t = String(r?.ts || ""); const ms = t ? Date.parse(t) : NaN; if (Number.isFinite(ms)) return ms; const d = String(r?.date || ""); const ms2 = d ? Date.parse(d + "T23:59:59Z") : NaN; return Number.isFinite(ms2) ? ms2 : 0; } const bestByStore = new Map(); // storeLabel -> row for (const r of allRows) { const href = String(r?.url || "").trim(); if (!href) continue; const store = String(r?.storeLabel || r?.store || "Store").trim() || "Store"; const prev = bestByStore.get(store); if (!prev) { bestByStore.set(store, r); continue; } const a = rowMs(prev); const b = rowMs(r); if (b > a) bestByStore.set(store, r); else if (b === a) { // tie-break: prefer live over removed if (Boolean(prev?.removed) && !Boolean(r?.removed)) bestByStore.set(store, r); } } function rowMinPrice(r) { const p = parsePriceToNumber(r?.price); return p === null ? Infinity : p; } const linkRows = Array.from(bestByStore.entries()) .map(([store, r]) => ({ store, r })) .sort((A, B) => { // 1) cheapest current price first (Infinity sorts to end) const ap = rowMinPrice(A.r); const bp = rowMinPrice(B.r); if (ap !== bp) return ap - bp; // 2) live before removed const ar = Boolean(A.r?.removed) ? 1 : 0; const br = Boolean(B.r?.removed) ? 1 : 0; if (ar !== br) return ar - br; // 3) stable fallback return A.store.localeCompare(B.store); }); $links.innerHTML = linkRows .map(({ store, r }) => { const href = String(r.url || "").trim(); const suffix = Boolean(r?.removed) ? " (removed)" : ""; return `${esc(store + suffix)}`; }) .join(""); const gh = inferGithubOwnerRepo(); const owner = gh.owner; const repo = gh.repo; const branch = "data"; // Group DB files by historical presence (LIVE or REMOVED rows). const byDbFileAll = new Map(); for (const r of allRows) { if (!r.dbFile) continue; const k = String(r.dbFile); if (!byDbFileAll.has(k)) byDbFileAll.set(k, []); byDbFileAll.get(k).push(r); } const dbFiles = [...byDbFileAll.keys()].sort(); $status.textContent = isRemovedEverywhere ? `Item is removed everywhere (showing historical chart across ${dbFiles.length} store file(s))…` : `Loading history for ${dbFiles.length} store file(s)…`; const manifest = await loadDbCommitsManifest(); // Shared caches across all stores const fileJsonCache = new Map(); // ck(sha|path) -> parsed JSON const inflightFetch = new Map(); // ck -> Promise const today = dateOnly(idx.generatedAt || new Date().toISOString()); // Tuning knobs: // - keep compute modest: only a few stores processed simultaneously // - make network aggressive: many file-at-sha fetches in-flight globally const DBFILE_CONCURRENCY = 3; const NET_CONCURRENCY = 16; const limitNet = makeLimiter(NET_CONCURRENCY); const MAX_POINTS = 260; // process ONE dbFile, but return MULTIPLE series: one per skuKey that exists in this file async function processDbFile(dbFile) { const rowsAll = byDbFileAll.get(dbFile) || []; if (!rowsAll.length) return []; const storeLabel = String(rowsAll[0]?.storeLabel || rowsAll[0]?.store || dbFile); // Variant keys in this store/dbFile (e.g. ["805160","141495"]) const variantKeys = Array.from( new Set( rowsAll .map((r) => String(keySkuForRow(r) || "").trim()) .filter(Boolean) .filter((k) => skuGroup.has(k)), ), ).sort(); const wantUrlsByVar = new Map(); // vk -> Set(urls) for (const vk of variantKeys) wantUrlsByVar.set(vk, new Set()); for (const r of rowsAll) { const vk = String(keySkuForRow(r) || "").trim(); if (!vk || !wantUrlsByVar.has(vk)) continue; const u = String(r?.url || "").trim(); if (u) wantUrlsByVar.get(vk).add(u); } // Split rows by variant for "today" point const rowsLiveByVar = new Map(); for (const r of rowsAll) { const k = String(keySkuForRow(r) || "").trim(); if (!k || !variantKeys.includes(k)) continue; if (!Boolean(r?.removed)) { if (!rowsLiveByVar.has(k)) rowsLiveByVar.set(k, []); rowsLiveByVar.get(k).push(r); } } // Build commits list (prefer manifest) 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 = []; } } // Chronological sort commits = commits .slice() .filter((c) => c && c.date && c.sha) .sort((a, b) => { const da = String(a.date || ""); const db = String(b.date || ""); const ta = Date.parse(String(a.ts || "")) || (da ? Date.parse(da + "T00:00:00Z") : 0) || 0; const tb = Date.parse(String(b.ts || "")) || (db ? Date.parse(db + "T00:00:00Z") : 0) || 0; return ta - tb; }); const cacheBust = cacheBustForDbFile(manifest, dbFile, commits); // If all variants cached, return cached series const cachedOut = []; let missing = 0; for (const vk of variantKeys) { const cached = loadSeriesCache(sku, dbFile, cacheBust, vk); if (!cached?.points?.length) { missing++; continue; } const points = new Map(); const values = []; const dates = []; for (const p of cached.points) { const d = String(p.date || ""); const v = p.price === null ? null : Number(p.price); if (!d) continue; const vv = Number.isFinite(v) ? v : null; points.set(d, vv); if (vv !== null) values.push(vv); dates.push(d); } cachedOut.push({ label: storeLabel, variantKey: vk, points, values, dates }); } if (missing === 0) return cachedOut; // Group commits by day (keep ALL commits per day; needed for add+remove same day) const byDay = new Map(); // date -> commits[] for (const c of commits) { const d = String(c?.date || ""); if (!d) continue; let arr = byDay.get(d); if (!arr) byDay.set(d, (arr = [])); arr.push(c); } function commitMs(c) { const d = String(c?.date || ""); const t = Date.parse(String(c?.ts || "")); if (Number.isFinite(t)) return t; return d ? Date.parse(d + "T00:00:00Z") || 0 : 0; } let dayEntries = Array.from(byDay.entries()) .map(([date, arr]) => ({ date, commits: arr.slice().sort((a, b) => commitMs(a) - commitMs(b)) })) .sort((a, b) => (a.date < b.date ? -1 : 1)); if (dayEntries.length > MAX_POINTS) dayEntries = dayEntries.slice(dayEntries.length - MAX_POINTS); // Aggressive global network fetch (dedup + throttled) async function loadAtSha(sha) { const ck = `${sha}|${dbFile}`; const cachedObj = fileJsonCache.get(ck); if (cachedObj) return cachedObj; const prev = inflightFetch.get(ck); if (prev) return prev; const p = limitNet(async () => { const obj = await githubFetchFileAtSha({ owner, repo, sha, path: dbFile }); fileJsonCache.set(ck, obj); return obj; }).finally(() => { inflightFetch.delete(ck); }); inflightFetch.set(ck, p); return p; } // Prefetch the last sha for each day (these are always needed) { const shas = []; for (const day of dayEntries) { const arr = day.commits; if (!arr?.length) continue; const sha = String(arr[arr.length - 1]?.sha || ""); if (sha) shas.push(sha); } await Promise.all(shas.map((sha) => loadAtSha(sha).catch(() => null))); } // Build series for variants missing from cache const out = cachedOut.slice(); const state = new Map(); // vk -> { points, values, dates, compactPoints, removedStreak, prevLive } for (const vk of variantKeys) { if (out.some((s) => s.variantKey === vk)) continue; state.set(vk, { points: new Map(), values: [], dates: [], compactPoints: [], removedStreak: false, prevLive: null, }); } for (const day of dayEntries) { const d = String(day.date || ""); const dayCommits = Array.isArray(day.commits) ? day.commits : []; if (!d || !dayCommits.length) continue; const last = dayCommits[dayCommits.length - 1]; const lastSha = String(last?.sha || ""); if (!lastSha) continue; let objLast; try { objLast = await loadAtSha(lastSha); } catch { continue; } for (const [vk, st] of state.entries()) { const wantRealSkus = new Set([vk].filter((x) => x && !String(x).startsWith("u:"))); const skuKeysOne = [vk]; const wantUrls = wantUrlsByVar.get(vk) || new Set(); const lastMin = findMinPricesForSkuGroupInDb(objLast, wantRealSkus, skuKeysOne, storeLabel, wantUrls); const lastLive = lastMin.liveMin; const lastRemoved = lastMin.removedMin; // end-of-day removed state: no live but removed exists const endIsRemoved = lastLive === null && lastRemoved !== null; // If end-of-day is removed, find the LAST live price earlier the same day let sameDayLastLive = null; if (endIsRemoved && dayCommits.length > 1) { const firstSha = String(dayCommits[0]?.sha || ""); if (firstSha) { try { const objFirst = await loadAtSha(firstSha); const firstMin = findMinPricesForSkuGroupInDb(objFirst, wantRealSkus, skuKeysOne, storeLabel, wantUrls); if (firstMin.liveMin !== null) { const candidates = []; for (let i = 0; i < dayCommits.length - 1; i++) { const sha = String(dayCommits[i]?.sha || ""); if (sha) candidates.push(sha); } await Promise.all(candidates.map((sha) => loadAtSha(sha).catch(() => null))); for (let i = dayCommits.length - 2; i >= 0; i--) { const sha = String(dayCommits[i]?.sha || ""); if (!sha) continue; try { const obj = await loadAtSha(sha); const m = findMinPricesForSkuGroupInDb(obj, wantRealSkus, skuKeysOne, storeLabel, wantUrls); if (m.liveMin !== null) { sameDayLastLive = m.liveMin; break; } } catch {} } } } catch {} } } let v = null; if (lastLive !== null) { // live at end-of-day v = lastLive; st.removedStreak = false; st.prevLive = lastLive; } else if (endIsRemoved) { // first removed day => show dot (prefer removed price; else last live earlier that day; else prev live) if (!st.removedStreak) { v = lastRemoved !== null ? lastRemoved : sameDayLastLive !== null ? sameDayLastLive : st.prevLive; st.removedStreak = true; } else { v = null; // days AFTER removal: no dot } } else { v = null; } st.points.set(d, v); if (v !== null) st.values.push(v); st.compactPoints.push({ date: d, price: v }); st.dates.push(d); } } // Add "today" point per variant ONLY if listing currently exists for that variant in this store/dbFile for (const [vk, st] of state.entries()) { const rowsLive = rowsLiveByVar.get(vk) || []; if (rowsLive.length) { let curMin = null; for (const r of rowsLive) { const p = parsePriceToNumber(r.price); if (p !== null) curMin = curMin === null ? p : Math.min(curMin, p); } if (curMin !== null) { st.points.set(today, curMin); st.values.push(curMin); st.compactPoints.push({ date: today, price: curMin }); st.dates.push(today); } } saveSeriesCache(sku, dbFile, cacheBust, vk, st.compactPoints); out.push({ label: storeLabel, variantKey: vk, points: st.points, values: st.values, dates: st.dates }); } return out; } const results = await mapLimit(dbFiles, DBFILE_CONCURRENCY, async (dbFile) => { try { return await processDbFile(dbFile); // array } catch { return []; } }); const allDatesSet = new Set(); const series = []; // per (store, variant) for (const arr of results) { for (const r of Array.isArray(arr) ? arr : []) { if (!r) continue; series.push(r); for (const d of r.dates) allDatesSet.add(d); } } const labels = [...allDatesSet].sort(); if (!labels.length || !series.length) { $status.textContent = "No historical points found."; return; } // Group variants by store const variantsByStore = new Map(); // storeLabel -> series[] for (const s of series) { const k = String(s.label || "Store"); if (!variantsByStore.has(k)) variantsByStore.set(k, []); variantsByStore.get(k).push(s); } // Merge per-store (min across variants) for sorting + markers function mergeStorePoints(vars) { const points = new Map(); const values = []; for (const d of labels) { let v = null; for (const s of vars) { const vv = s.points.has(d) ? s.points.get(d) : null; if (Number.isFinite(vv)) v = v === null ? vv : Math.min(v, vv); } points.set(d, v); if (v !== null) values.push(v); } return { points, values }; } const todayKey = today; const storeSeries = Array.from(variantsByStore.entries()).map(([label, vars]) => { const merged = mergeStorePoints(vars); const todayVal = merged.points.has(todayKey) ? merged.points.get(todayKey) : null; const lastVal = todayVal !== null ? todayVal : lastFiniteFromEnd(labels.map((d) => merged.points.get(d))); return { label, vars, merged, sortVal: Number.isFinite(lastVal) ? lastVal : null }; }); const storeSeriesSorted = storeSeries .slice() .sort((a, b) => { const av = a.sortVal, bv = b.sortVal; if (av === null && bv === null) return a.label.localeCompare(b.label); if (av === null) return 1; if (bv === null) return -1; if (av !== bv) return av - bv; return a.label.localeCompare(b.label); }); const colorMap = buildStoreColorMap(storeSeriesSorted.map((x) => x.label)); // Build datasets: multiple lines per store, same label, same color, same stroke const datasets = []; for (const st of storeSeriesSorted) { const base = storeColor(st.label, colorMap); const stroke = lighten(base, 0.25); // stable ordering within store so colors don't flicker const vars = st.vars.slice().sort((a, b) => String(a.variantKey).localeCompare(String(b.variantKey))); for (const s of vars) { datasets.push({ label: st.label, // IMPORTANT: no SKU in label data: labels.map((d) => (s.points.has(d) ? s.points.get(d) : null)), spanGaps: false, tension: 0.15, backgroundColor: base, borderColor: stroke, pointBackgroundColor: base, pointBorderColor: stroke, borderWidth: datasetStrokeWidth(base), }); } } // --- Compute marker values (use merged per-store series) --- const storeMeans = storeSeriesSorted .map((st) => ({ label: st.label, mean: weightedMeanByDuration(st.merged.points, labels) })) .filter((x) => Number.isFinite(x.mean)); const bcMeans = storeMeans.filter((x) => isBcStoreLabel(x.label)); const abMeans = storeMeans.filter((x) => !isBcStoreLabel(x.label)); const markers = []; if (bcMeans.length >= 3) { const y = medianFinite(bcMeans.map((x) => x.mean)); if (Number.isFinite(y)) markers.push({ y: Math.round(y), text: "BC" }); } if (abMeans.length >= 3) { const y = medianFinite(abMeans.map((x) => x.mean)); if (Number.isFinite(y)) markers.push({ y: Math.round(y), text: "Alberta" }); } // Collect all finite values across ALL lines for y-scale + uniqueness check const allVals = []; for (const ds of datasets) { for (const v of ds.data) if (Number.isFinite(v)) allVals.push(v); } const ySug = computeSuggestedY(allVals); const MIN_STEP = 10; // never denser than $10 const MAX_TICKS = 12; // cap tick count when span is huge const span = (ySug.suggestedMax ?? 0) - (ySug.suggestedMin ?? 0); const step = niceStepAtLeast(MIN_STEP, span, MAX_TICKS); // Target price: pick 3 lowest per-store mins (distinct stores), then average (>=3 stores) // Only show if there are at least 6 total unique price points (finite) across the chart. const uniquePricePoints = new Set(allVals.filter((v) => Number.isFinite(v)).map((v) => Math.round(v * 100))); const hasEnoughUniquePoints = uniquePricePoints.size >= 6; const storeMins = storeSeriesSorted .map((st) => ({ label: st.label, min: minFinite(st.merged.values) })) .filter((x) => Number.isFinite(x.min)) .sort((a, b) => a.min - b.min); if (hasEnoughUniquePoints && storeMins.length >= 3) { const t = (storeMins[0].min + storeMins[1].min + storeMins[2].min) / 3; if (Number.isFinite(t)) markers.push({ y: Math.round(t), text: "Target" }); } const markerYs = markers.map((m) => Number(m.y)).filter(Number.isFinite); // helper: approximate font px size from a CSS font string (Chart uses one) function fontPx(font) { const m = String(font || "").match(/(\d+(?:\.\d+)?)px/); return m ? Number(m[1]) : 12; } const ctx = $canvas.getContext("2d"); CHART = new Chart(ctx, { type: "line", data: { labels, datasets }, plugins: [StaticMarkerLinesPlugin], options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "nearest", intersect: false }, // v2 fallback (plugin reads this) staticMarkerLines: { markers, color: "#7f8da3", alpha: 0.38, lineWidth: 1.25, labelColor: "#556274", axisInset: 2, }, plugins: { // v3+ (plugin reads this too) staticMarkerLines: { markers, color: "#7f8da3", alpha: 0.38, lineWidth: 1.25, labelColor: "#556274", axisInset: 2, }, // De-dupe legend items by label WITHOUT changing legend styling. legend: { display: true, labels: { generateLabels: (chart) => { const gen = Chart?.defaults?.plugins?.legend?.labels?.generateLabels; const items = typeof gen === "function" ? gen(chart) : []; const seen = new Map(); // text -> { item, idxs } for (const it of items) { const t = String(it.text || ""); if (!seen.has(t)) { seen.set(t, { item: { ...it, _group: [it.datasetIndex] } }); } else { seen.get(t).item._group.push(it.datasetIndex); } } // make "hidden" reflect ALL datasets in the group const out = []; for (const { item } of seen.values()) { const idxs = item._group || [item.datasetIndex]; const allHidden = idxs.every((j) => chart.getDatasetMeta(j).hidden === true); out.push({ ...item, hidden: allHidden, datasetIndex: idxs[0], _group: idxs }); } return out; }, }, onClick: (_e, legendItem, legend) => { const chart = legend.chart; const idxs = legendItem._group || [legendItem.datasetIndex]; // toggle all as a group const anyVisible = idxs.some((j) => chart.getDatasetMeta(j).hidden !== true); for (const j of idxs) { if (typeof chart.setDatasetVisibility === "function") chart.setDatasetVisibility(j, !anyVisible); else chart.getDatasetMeta(j).hidden = anyVisible ? true : null; } chart.update(); }, }, 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)}`; }, }, }, }, // layout: { // padding: { right: 18 } // }, scales: { x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } }, y: { ...ySug, ticks: { stepSize: step, maxTicksLimit: MAX_TICKS, padding: 10, callback: function (v) { const val = Number(v); if (!Number.isFinite(val)) return ""; // if no markers, normal label if (!markerYs.length || typeof this.getPixelForValue !== "function") { return `$${val.toFixed(0)}`; } const py = this.getPixelForValue(val); if (!Number.isFinite(py)) return `$${val.toFixed(0)}`; // derive a "collision window" from tick label height // Chart.js puts resolved font on ticks.font (v3+), otherwise fall back const tickFont = this?.options?.ticks?.font || this?.ctx?.font || "12px system-ui"; const h = fontPx( typeof tickFont === "string" ? tickFont : `${tickFont?.size || 12}px ${tickFont?.family || "system-ui"}`, ); // hide if within 55% of label height (tweak 0.45–0.75) const COLLIDE_PX = Math.max(6, h * 0.75); for (const my of markerYs) { const pmy = this.getPixelForValue(my); if (!Number.isFinite(pmy)) continue; if (pmy < this.top || pmy > this.bottom) continue; if (Math.abs(py - pmy) <= COLLIDE_PX) return ""; } return `$${val.toFixed(0)}`; }, }, }, }, }, }); const yScale = CHART.scales?.y; const tickCount = yScale?.ticks?.length || 0; if (tickCount >= 2) { const minRange = (tickCount - 1) * 10; // $10 per gap, same number of gaps as before const ySug2 = computeSuggestedY(allVals, minRange); CHART.options.scales.y.suggestedMin = ySug2.suggestedMin; CHART.options.scales.y.suggestedMax = ySug2.suggestedMax; CHART.options.scales.y.ticks.stepSize = 10; // lock spacing at $10 now CHART.update(); } $status.textContent = manifest ? isRemovedEverywhere ? `History loaded (removed everywhere). Source=prebuilt manifest. Points=${labels.length}.` : `History loaded from prebuilt manifest (multi-commit/day) + current run. Points=${labels.length}.` : isRemovedEverywhere ? `History loaded (removed everywhere). Source=GitHub API fallback. Points=${labels.length}.` : `History loaded (GitHub API fallback; multi-commit/day) + current run. Points=${labels.length}.`; }