spirit-tracker/viz/app/catalog.js
2026-01-24 11:48:58 -08:00

108 lines
3.3 KiB
JavaScript

import { normImg } from "./dom.js";
import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js";
// 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 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 removed = Boolean(r?.removed);
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, // canonical sku
name: name || "",
img: "",
cheapestPriceStr: pStr || "",
cheapestPriceNum: pNum,
cheapestStoreLabel: storeLabel || "",
stores: new Set(), // LIVE stores only
storesEver: new Set(), // live + removed presence (history)
sampleUrl: url || "",
_searchParts: [],
searchText: "",
_imgByName: new Map(),
_imgAny: "",
};
bySku.set(sku, agg);
}
if (storeLabel) {
agg.storesEver.add(storeLabel);
if (!removed) agg.stores.add(storeLabel);
}
if (!agg.sampleUrl && url) agg.sampleUrl = url;
// Keep first non-empty name, but keep thumbnail aligned to 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 across LIVE rows only (so removed history doesn't "win")
if (!removed && pNum !== null) {
if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) {
agg.cheapestPriceNum = pNum;
agg.cheapestPriceStr = pStr || "";
agg.cheapestStoreLabel = storeLabel || agg.cheapestStoreLabel;
}
}
// 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);
if (removed) agg._searchParts.push("removed");
}
const out = [...bySku.values()];
for (const it of out) {
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;
it.storeCount = it.stores.size;
it.storeCountEver = it.storesEver.size;
it.removedEverywhere = it.storeCount === 0;
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;
}