diff --git a/.github/workflows/pages.yaml b/.github/workflows/pages.yaml index 1acc277..6939723 100644 --- a/.github/workflows/pages.yaml +++ b/.github/workflows/pages.yaml @@ -30,6 +30,25 @@ jobs: - name: Configure Pages uses: actions/configure-pages@v4 + # Make sku_links.json available to the static site without committing a second copy. + - name: Stage sku_links.json into site artifact + shell: bash + run: | + set -euo pipefail + mkdir -p viz/data + if [[ -f data/sku_links.json ]]; then + cp -f data/sku_links.json viz/data/sku_links.json + else + # keep site functional even if file is missing + cat > viz/data/sku_links.json <<'EOF' + { + "generatedAt": "", + "links": [], + "ignores": [] + } + EOF + fi + - name: Upload site artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/viz/app/api.js b/viz/app/api.js index 817bfe4..8c41374 100644 --- a/viz/app/api.js +++ b/viz/app/api.js @@ -1,74 +1,114 @@ export 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(); + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return await res.json(); +} + +export 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(); +} + +export 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 }; } - - export 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(); + return { owner: "brennanwilkes", repo: "spirit-tracker" }; +} + +export function isLocalWriteMode() { + const h = String(location.hostname || "").toLowerCase(); + return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); +} + +/* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */ + +export async function apiReadSkuMetaFromLocalServer() { + const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; +} + +export async function apiWriteSkuLink(fromSku, toSku) { + const res = await fetch("/__stviz/sku-links", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ fromSku, toSku }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); +} + +export async function apiWriteSkuIgnore(skuA, skuB) { + const res = await fetch("/__stviz/sku-ignores", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ skuA, skuB }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); +} + +/** + * Best-effort read of sku meta: + * - On GitHub Pages: expects file at viz/data/sku_links.json + * - On local server: reads via /__stviz/sku-links (disk) + */ +export async function loadSkuMetaBestEffort() { + // 1) GitHub Pages / static deploy inside viz/ + try { + const j = await fetchJson("./data/sku_links.json"); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; + } catch {} + + // 2) alternate static path (in case you later serve viz under a subpath) + try { + const j = await fetchJson("/data/sku_links.json"); + return { + links: Array.isArray(j?.links) ? j.links : [], + ignores: Array.isArray(j?.ignores) ? j.ignores : [], + }; + } catch {} + + // 3) Local server API (disk) + try { + return await apiReadSkuMetaFromLocalServer(); + } catch {} + + return { links: [], ignores: [] }; +} + +/* ---- GitHub history helpers ---- */ + +export 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 : [])]; } - - export 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" }; - } - - export function isLocalWriteMode() { - const h = String(location.hostname || "").toLowerCase(); - return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost"); - } - - /* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */ - - export async function loadSkuLinksBestEffort() { - try { - const r = await fetch("/__stviz/sku-links", { cache: "no-store" }); - if (!r.ok) return []; - const j = await r.json(); - return Array.isArray(j?.links) ? j.links : []; - } catch { - return []; - } - } - - export async function apiWriteSkuLink(fromSku, toSku) { - const res = await fetch("/__stviz/sku-links", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ fromSku, toSku }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return await res.json(); - } - - /* ---- GitHub history helpers ---- */ - - export 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 : []; - } - - export 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); - } - \ No newline at end of file + + return Array.isArray(page1) ? page1 : []; +} + +export 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); +} diff --git a/viz/app/catalog.js b/viz/app/catalog.js index 53f5236..859529e 100644 --- a/viz/app/catalog.js +++ b/viz/app/catalog.js @@ -1,25 +1,29 @@ import { normImg } from "./dom.js"; import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js"; -// Build one row per SKU + combined searchable text across all listings of that SKU -export function aggregateBySku(listings) { +// Build one row per *canonical* SKU (after applying sku map) + combined searchable text +export function aggregateBySku(listings, canonicalizeSkuFn) { + const canon = typeof canonicalizeSkuFn === "function" ? canonicalizeSkuFn : (x) => x; + const bySku = new Map(); for (const r of listings) { - const sku = keySkuForRow(r); + const rawSku = keySkuForRow(r); + const sku = canon(rawSku); 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, + sku, // canonical sku name: name || "", img: "", cheapestPriceStr: pStr || "", @@ -52,7 +56,7 @@ export function aggregateBySku(listings) { if (name) agg._imgByName.set(name, img); } - // cheapest + // cheapest (across all merged rows) if (pNum !== null) { if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) { agg.cheapestPriceNum = pNum; @@ -61,8 +65,9 @@ export function aggregateBySku(listings) { } } - // search parts + // search parts: include canonical + raw sku so searching either works agg._searchParts.push(sku); + if (rawSku && rawSku !== sku) agg._searchParts.push(rawSku); if (name) agg._searchParts.push(name); if (url) agg._searchParts.push(url); if (storeLabel) agg._searchParts.push(storeLabel); diff --git a/viz/app/item_page.js b/viz/app/item_page.js index 6255900..642fa8d 100644 --- a/viz/app/item_page.js +++ b/viz/app/item_page.js @@ -2,6 +2,7 @@ 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"; /* ---------------- Chart lifecycle ---------------- */ @@ -16,22 +17,44 @@ export function destroyChart() { /* ---------------- History helpers ---------------- */ -function findItemBySkuInDb(obj, skuKey, storeLabel) { +function findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel) { const items = Array.isArray(obj?.items) ? obj.items : []; + let best = null; + + // Build quick lookup for real sku entries (cheap) + const want = new Set(); + for (const s of skuKeys) { + const x = String(s || "").trim(); + if (x) want.add(x); + } + for (const it of items) { if (!it || it.removed) continue; const real = String(it.sku || "").trim(); - if (real && real === skuKey) return it; + if (real && want.has(real)) { + const p = parsePriceToNumber(it.price); + if (p !== null) best = best === null ? p : Math.min(best, p); + continue; + } - // synthetic match for blank sku items: hash storeLabel|url - if (!real && String(skuKey || "").startsWith("u:")) { - const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" }; - const k = keySkuForRow(row); - if (k === skuKey) return it; + // synthetic match (only relevant if a caller passes u: keys) + if (!real) { + // if any skuKey is synthetic, match by hashing storeLabel|url + for (const skuKey of skuKeys) { + const k = String(skuKey || ""); + if (!k.startsWith("u:")) continue; + const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" }; + const kk = keySkuForRow(row); + if (kk === k) { + const p = parsePriceToNumber(it.price); + if (p !== null) best = best === null ? p : Math.min(best, p); + } + } } } - return null; + + return best; } function computeSuggestedY(values) { @@ -62,7 +85,7 @@ function collapseCommitsToDaily(commits) { } function cacheKeySeries(sku, dbFile, cacheBust) { - return `stviz:v2:series:${cacheBust}:${sku}:${dbFile}`; + return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`; } function loadSeriesCache(sku, dbFile, cacheBust) { @@ -100,9 +123,11 @@ async function loadDbCommitsManifest() { /* ---------------- Page ---------------- */ -export async function renderItem($app, sku) { +export async function renderItem($app, skuInput) { destroyChart(); - console.log("[renderItem] skuKey=", sku); + + const rules = await loadSkuRules(); + const sku = rules.canonicalSku(String(skuInput || "")); $app.innerHTML = `