spirit-tracker/viz/app.js
Brennan Wilkes (Text Groove) 470eb8ca56 chore: initial code
2026-01-19 20:00:40 -08:00

841 lines
24 KiB
JavaScript

"use strict";
/**
* Hash routes:
* #/ search
* #/item/<sku> detail
*/
const $app = document.getElementById("app");
function esc(s) {
return String(s ?? "").replace(
/[&<>"']/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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
}
// unsigned -> 8 hex chars
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 || "");
}
// 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(parts[1]);
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 `<div class="thumbPlaceholder"></div>`;
return `<img class="${esc(cls)}" src="${esc(img)}" alt="" loading="lazy" onerror="this.style.display='none'" />`;
}
function renderSearch() {
$app.innerHTML = `
<div class="container">
<div class="header">
<h1 class="h1">Spirit Tracker Viz</h1>
<div class="small">Search name / url / sku (word AND)</div>
</div>
<div class="card">
<input id="q" class="input" placeholder="e.g. bowmore sherry, 303821, sierrasprings..." autocomplete="off" />
<div id="results" class="list"></div>
</div>
</div>
`;
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 = `<div class="small">No matches.</div>`;
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 `
<div class="item" data-sku="${esc(it.sku)}">
<div class="itemRow">
<div class="thumbBox">
${renderThumbHtml(it.img)}
</div>
<div class="itemBody">
<div class="itemTop">
<div class="itemName">${esc(it.name || "(no name)")}</div>
<span class="badge mono">${esc(displaySku(it.sku))}</span>
</div>
<div class="meta">
<span class="mono">${esc(price)}</span>
<span class="badge">${esc(store)}${esc(plus)}</span>
</div>
<div class="meta">
<span class="mono">${esc(it.sampleUrl || "")}</span>
</div>
</div>
</div>
</div>
`;
})
.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 = `<div class="small">Type to search…</div>`;
return;
}
const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3;
const limited = items.slice(0, 140);
$results.innerHTML =
`<div class="small">Recently changed (last ${esc(days)} day(s)):</div>` +
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 `
<div class="item" data-sku="${esc(sku)}">
<div class="itemRow">
<div class="thumbBox">
${renderThumbHtml(img)}
</div>
<div class="itemBody">
<div class="itemTop">
<div class="itemName">${esc(r.name || "(no name)")}</div>
<span class="badge mono">${esc(displaySku(it.sku))}</span>
</div>
<div class="meta">
<span class="badge">${esc(kind)}</span>
<span class="badge">${esc(r.storeLabel || "")}</span>
<span class="mono">${esc(priceLine)}</span>
</div>
<div class="meta">
<span class="mono">${esc(when)}</span>
</div>
<div class="meta">
<span class="mono">${esc(r.url || "")}</span>
</div>
</div>
</div>
</div>
`;
})
.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 = `<div class="small">Type to search…</div>`;
});
return;
}
const matches = allAgg.filter((it) => matchesAllTokens(it.searchText, tokens));
renderAggregates(matches);
}
$results.innerHTML = `<div class="small">Loading index…</div>`;
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 = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
});
let t = null;
$q.addEventListener("input", () => {
saveQuery($q.value);
if (t) clearTimeout(t);
t = setTimeout(applySearch, 50);
});
}
/* ---------------- 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, sku) {
const items = Array.isArray(obj?.items) ? obj.items : [];
for (const it of items) {
if (!it || it.removed) continue;
const s = String(it.sku || "");
if (s === sku) 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();
$app.innerHTML = `
<div class="container">
<div class="topbar">
<button id="back" class="btn">← Back</button>
<span class="badge mono">${esc(displaySku(it.sku))}</span>
</div>
<div class="card detailCard">
<div class="detailHeader">
<div id="thumbBox" class="detailThumbBox"></div>
<div class="detailHeaderText">
<div id="title" class="h1">Loading…</div>
<div id="links" class="links"></div>
<div class="small" id="status"></div>
</div>
</div>
<div class="chartBox">
<canvas id="chart"></canvas>
</div>
</div>
</div>
`;
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 cur = all.filter((x) => (String(x.sku || "").trim() || makeUnknownSku(x)) === String(sku || ""));
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 = `<div class="thumbPlaceholder"></div>`;
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") : `<div class="thumbPlaceholder"></div>`;
}
$links.innerHTML = cur
.slice()
.sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || "")))
.map(
(r) =>
`<a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.storeLabel || r.store || "Store")}</a>`
)
.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);
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();