diff --git a/viz/app/store_page.js b/viz/app/store_page.js
index 481217c..bf23190 100644
--- a/viz/app/store_page.js
+++ b/viz/app/store_page.js
@@ -6,7 +6,7 @@ import {
keySkuForRow,
parsePriceToNumber,
} from "./sku.js";
-import { loadIndex } from "./state.js";
+import { loadIndex, loadRecent } from "./state.js";
import { aggregateBySku } from "./catalog.js";
import { loadSkuRules } from "./mapping.js";
@@ -127,6 +127,8 @@ export async function renderStore($app, storeLabelRaw) {
+
+
@@ -203,6 +205,84 @@ export async function renderStore($app, storeLabelRaw) {
rulesCache = await loadSkuRules();
const rules = rulesCache;
+ // --- Recent (7d), most-recent per canonicalSku + store ---
+ const recent = await loadRecent().catch(() => null);
+ const recentItems = Array.isArray(recent?.items) ? recent.items : [];
+
+ function eventMs(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 + "T00:00:00Z") : NaN;
+ return Number.isFinite(ms2) ? ms2 : 0;
+ }
+
+ const RECENT_DAYS = 7;
+ const nowMs = Date.now();
+ const cutoffMs = nowMs - RECENT_DAYS * 24 * 60 * 60 * 1000;
+
+ // canonicalSku -> storeNorm -> recentRow (latest)
+ const recentBySkuStore = new Map();
+
+ for (const r of recentItems) {
+ const ms = eventMs(r);
+ if (!(ms >= cutoffMs && ms <= nowMs)) continue;
+
+ const rawSku = String(r?.sku || "").trim();
+ if (!rawSku) continue;
+ const sku = String(rules.canonicalSku(rawSku) || rawSku);
+
+ const stNorm = normStoreLabel(r?.storeLabel || r?.store || "");
+ if (!stNorm) continue;
+
+ let sm = recentBySkuStore.get(sku);
+ if (!sm) recentBySkuStore.set(sku, (sm = new Map()));
+
+ const prev = sm.get(stNorm);
+ if (!prev || eventMs(prev) < ms) sm.set(stNorm, r);
+ }
+
+ function normalizeKindForPrice(r) {
+ let kind = String(r?.kind || "");
+ if (kind === "price_change") {
+ const o = parsePriceToNumber(r?.oldPrice || "");
+ const n = parsePriceToNumber(r?.newPrice || "");
+ if (Number.isFinite(o) && Number.isFinite(n)) {
+ if (n < o) kind = "price_down";
+ else if (n > o) kind = "price_up";
+ else kind = "price_change";
+ }
+ }
+ return kind;
+ }
+
+ function saleMetaFor(it) {
+ const sku = String(it?.sku || "");
+ const r = recentBySkuStore.get(sku)?.get(storeNorm) || null;
+ if (!r) return null;
+
+ const kind = normalizeKindForPrice(r);
+ if (kind !== "price_down" && kind !== "price_up" && kind !== "price_change")
+ return null;
+
+ const oldStr = String(r?.oldPrice || "").trim();
+ const newStr = String(r?.newPrice || "").trim();
+ const oldN = parsePriceToNumber(oldStr);
+ const newN = parsePriceToNumber(newStr);
+ if (!Number.isFinite(oldN) || !Number.isFinite(newN) || !(oldN > 0))
+ return null;
+
+ const delta = newN - oldN; // negative = down
+ const pct = Math.round(((newN - oldN) / oldN) * 100); // negative = down
+
+ return {
+ _saleDelta: Number.isFinite(delta) ? delta : 0,
+ _salePct: Number.isFinite(pct) ? pct : 0,
+ };
+ }
+
const listingsAll = Array.isArray(idx.items) ? idx.items : [];
const liveAll = listingsAll.filter((r) => r && !r.removed);
@@ -336,6 +416,8 @@ export async function renderStore($app, storeLabelRaw) {
const firstSeenMs = firstSeenBySkuInStore.get(sku);
const firstSeen = firstSeenMs !== undefined ? firstSeenMs : null;
+ const sm = saleMetaFor(it); // { _saleDelta, _salePct } or null
+
return {
...it,
_exclusive: exclusive,
@@ -349,6 +431,9 @@ export async function renderStore($app, storeLabelRaw) {
_diffVsBestDollar: diffVsBestDollar,
_diffVsBestPct: diffVsBestPct,
_firstSeenMs: firstSeen,
+ _saleDelta: sm ? sm._saleDelta : 0,
+ _salePct: sm ? sm._salePct : 0,
+ _hasSaleMeta: !!sm,
};
});
@@ -492,9 +577,44 @@ export async function renderStore($app, storeLabelRaw) {
return `$${esc(dollars)} higher`;
}
+ function exclusiveAnnotHtml(it) {
+ const mode = String($exSort.value || "priceDesc");
+
+ // If sorting by sale, annotate with sale change ($ / %). If unchanged, show nothing.
+ if (mode === "salePct") {
+ const p = Number.isFinite(it._salePct) ? it._salePct : 0;
+ if (!p) return "";
+ const abs = Math.abs(p);
+ if (p < 0) return `${esc(abs)}% off`;
+ return `+${esc(abs)}%`;
+ }
+
+ if (mode === "saleAbs") {
+ const d = Number.isFinite(it._saleDelta) ? it._saleDelta : 0;
+ if (!d) return "";
+ const abs = Math.round(Math.abs(d));
+ if (!abs) return "";
+ if (d < 0) return `$${esc(abs)} off`;
+ return `+$${esc(abs)}`;
+ }
+
+ // Otherwise: show % off vs best other store (only when actually cheaper).
+ const sp = it && Number.isFinite(it._storePrice) ? it._storePrice : null;
+ const other = it && Number.isFinite(it._bestOther) ? it._bestOther : null;
+ if (sp === null || other === null || !(other > 0)) return "";
+ if (!(sp < other - EPS)) return "";
+
+ const pct = Math.round(((other - sp) / other) * 100);
+ if (!Number.isFinite(pct) || pct <= 0) return "";
+ return `${esc(pct)}% off`;
+ }
+
function renderCard(it) {
const price = listingPriceStr(it);
- const href = String(it.sampleUrl || "").trim();
+
+ // Link the store badge consistently (respects SKU linking / canonical SKU)
+ const storeHref = readLinkHrefForSkuInStore(liveAll, String(it.sku || ""), storeNorm);
+ const href = storeHref || String(it.sampleUrl || "").trim();
const specialBadge = it._lastStock
? `Last Stock`
@@ -508,6 +628,7 @@ export async function renderStore($app, storeLabelRaw) {
: "";
const diffBadge = priceBadgeHtml(it);
+ const exAnnot = it._exclusive || it._lastStock ? exclusiveAnnotHtml(it) : "";
const skuLink = `#/link/?left=${encodeURIComponent(String(it.sku || ""))}`;
return `
@@ -526,6 +647,7 @@ export async function renderStore($app, storeLabelRaw) {
${specialBadge}
${bestBadge}
${diffBadge}
+ ${exAnnot}
${esc(price)}
${
href
@@ -630,6 +752,31 @@ export async function renderStore($app, storeLabelRaw) {
function sortExclusiveInPlace(arr) {
const mode = String($exSort.value || "priceDesc");
+
+ if (mode === "salePct") {
+ arr.sort((a, b) => {
+ const ap = Number.isFinite(a._salePct) ? a._salePct : 0; // negative = better
+ const bp = Number.isFinite(b._salePct) ? b._salePct : 0;
+ if (ap !== bp) return ap - bp; // best deal first
+ const an = (String(a.name) + a.sku).toLowerCase();
+ const bn = (String(b.name) + b.sku).toLowerCase();
+ return an.localeCompare(bn);
+ });
+ return;
+ }
+
+ if (mode === "saleAbs") {
+ arr.sort((a, b) => {
+ const ad = Number.isFinite(a._saleDelta) ? a._saleDelta : 0; // negative = better
+ const bd = Number.isFinite(b._saleDelta) ? b._saleDelta : 0;
+ if (ad !== bd) return ad - bd; // best deal first
+ const an = (String(a.name) + a.sku).toLowerCase();
+ const bn = (String(b.name) + b.sku).toLowerCase();
+ return an.localeCompare(bn);
+ });
+ return;
+ }
+
if (mode === "priceAsc" || mode === "priceDesc") {
arr.sort((a, b) => {
const ap = Number.isFinite(a._storePrice) ? a._storePrice : null;
@@ -638,7 +785,8 @@ export async function renderStore($app, storeLabelRaw) {
ap === null ? (mode === "priceAsc" ? 9e15 : -9e15) : ap;
const bKey =
bp === null ? (mode === "priceAsc" ? 9e15 : -9e15) : bp;
- if (aKey !== bKey) return mode === "priceAsc" ? aKey - bKey : bKey - aKey;
+ if (aKey !== bKey)
+ return mode === "priceAsc" ? aKey - bKey : bKey - aKey;
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
});
return;
@@ -652,7 +800,8 @@ export async function renderStore($app, storeLabelRaw) {
ad === null ? (mode === "dateAsc" ? 9e15 : -9e15) : ad;
const bKey =
bd === null ? (mode === "dateAsc" ? 9e15 : -9e15) : bd;
- if (aKey !== bKey) return mode === "dateAsc" ? aKey - bKey : bKey - aKey;
+ if (aKey !== bKey)
+ return mode === "dateAsc" ? aKey - bKey : bKey - aKey;
return (String(a.name) + a.sku).localeCompare(String(b.name) + b.sku);
});
}