mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
fix: Listing page adjustments
This commit is contained in:
parent
2ead75a8c8
commit
bec8930e76
1 changed files with 165 additions and 72 deletions
|
|
@ -17,9 +17,11 @@ export function destroyChart() {
|
|||
|
||||
/* ---------------- History helpers ---------------- */
|
||||
|
||||
function findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel) {
|
||||
// Returns BOTH mins, so we can show a dot on removal day using removed price.
|
||||
function findMinPricesForSkuGroupInDb(obj, skuKeys, storeLabel) {
|
||||
const items = Array.isArray(obj?.items) ? obj.items : [];
|
||||
let best = null;
|
||||
let liveMin = null;
|
||||
let removedMin = null;
|
||||
|
||||
// Build quick lookup for real sku entries (cheap)
|
||||
const want = new Set();
|
||||
|
|
@ -29,39 +31,44 @@ function findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel) {
|
|||
}
|
||||
|
||||
for (const it of items) {
|
||||
if (!it || it.removed) continue;
|
||||
if (!it) continue;
|
||||
|
||||
const isRemoved = Boolean(it.removed);
|
||||
|
||||
const consider = (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 real = String(it.sku || "").trim();
|
||||
if (real && want.has(real)) {
|
||||
const p = parsePriceToNumber(it.price);
|
||||
if (p !== null) best = best === null ? p : Math.min(best, p);
|
||||
consider(it.price);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (kk === k) consider(it.price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
return { liveMin, removedMin };
|
||||
}
|
||||
|
||||
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];
|
||||
let min = nums[0],
|
||||
max = nums[0];
|
||||
for (const n of nums) {
|
||||
if (n < min) min = n;
|
||||
if (n > max) max = n;
|
||||
|
|
@ -72,18 +79,6 @@ function computeSuggestedY(values) {
|
|||
return { suggestedMin: Math.max(0, min - pad), suggestedMax: max + pad };
|
||||
}
|
||||
|
||||
// Collapse commit list down to 1 commit per day (keep most recent commit for that day)
|
||||
function collapseCommitsToDaily(commits) {
|
||||
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:v3:series:${cacheBust}:${sku}:${dbFile}`;
|
||||
}
|
||||
|
|
@ -169,8 +164,7 @@ export async function renderItem($app, skuInput) {
|
|||
// include toSku + all fromSkus mapped to it
|
||||
const skuGroup = rules.groupForCanonical(sku);
|
||||
|
||||
// IMPORTANT CHANGE:
|
||||
// index.json now includes removed rows too. Split live vs all.
|
||||
// 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));
|
||||
|
||||
|
|
@ -239,34 +233,54 @@ export async function renderItem($app, skuInput) {
|
|||
$thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `<div class="thumbPlaceholder"></div>`;
|
||||
|
||||
// Render store links:
|
||||
// - LIVE stores first (normal)
|
||||
// - then removed-history stores with a "(removed)" suffix
|
||||
const seenLinks = new Set();
|
||||
const linkRows = allRows
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ar = Boolean(a?.removed) ? 1 : 0;
|
||||
const br = Boolean(b?.removed) ? 1 : 0;
|
||||
// - 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);
|
||||
}
|
||||
}
|
||||
|
||||
const linkRows = Array.from(bestByStore.entries())
|
||||
.map(([store, r]) => ({ store, r }))
|
||||
.sort((A, B) => {
|
||||
const ar = Boolean(A.r?.removed) ? 1 : 0;
|
||||
const br = Boolean(B.r?.removed) ? 1 : 0;
|
||||
if (ar !== br) return ar - br; // live first
|
||||
return String(a.storeLabel || "").localeCompare(String(b.storeLabel || ""));
|
||||
})
|
||||
.filter((r) => {
|
||||
const href = String(r?.url || "").trim();
|
||||
const text = String(r?.storeLabel || r?.store || "Store").trim();
|
||||
if (!href) return false;
|
||||
const suffix = Boolean(r?.removed) ? " (removed)" : "";
|
||||
const key = `${href}|${text}${suffix}`;
|
||||
if (seenLinks.has(key)) return false;
|
||||
seenLinks.add(key);
|
||||
return true;
|
||||
return A.store.localeCompare(B.store);
|
||||
});
|
||||
|
||||
$links.innerHTML = linkRows
|
||||
.map((r) => {
|
||||
.map(({ store, r }) => {
|
||||
const href = String(r.url || "").trim();
|
||||
const text = String(r.storeLabel || r.store || "Store").trim();
|
||||
const suffix = Boolean(r?.removed) ? " (removed)" : "";
|
||||
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(text + suffix)}</a>`;
|
||||
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer">${esc(store + suffix)}</a>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
|
|
@ -303,7 +317,6 @@ export async function renderItem($app, skuInput) {
|
|||
const rowsAll = byDbFileAll.get(dbFile) || [];
|
||||
|
||||
// Determine current LIVE rows for this dbFile:
|
||||
// (we don't want to add a "today" point if the listing is removed in this store now)
|
||||
const rowsLive = rowsAll.filter((r) => !Boolean(r?.removed));
|
||||
|
||||
const storeLabel = String(rowsAll[0]?.storeLabel || rowsAll[0]?.store || dbFile);
|
||||
|
|
@ -344,38 +357,118 @@ export async function renderItem($app, skuInput) {
|
|||
}
|
||||
}
|
||||
|
||||
commits = collapseCommitsToDaily(commits);
|
||||
// Ensure chronological
|
||||
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;
|
||||
});
|
||||
|
||||
// Group per day: keep first+last commit for that day (so add+remove same day still yields a dot)
|
||||
const byDay = new Map();
|
||||
for (const c of commits) {
|
||||
const d = String(c.date || "");
|
||||
if (!d) continue;
|
||||
|
||||
const t = Date.parse(String(c.ts || "")) || Date.parse(d + "T00:00:00Z") || 0;
|
||||
|
||||
let e = byDay.get(d);
|
||||
if (!e) {
|
||||
e = { date: d, first: c, last: c, firstT: t, lastT: t };
|
||||
byDay.set(d, e);
|
||||
} else {
|
||||
if (t < e.firstT) {
|
||||
e.first = c;
|
||||
e.firstT = t;
|
||||
}
|
||||
if (t > e.lastT) {
|
||||
e.last = c;
|
||||
e.lastT = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dayEntries = Array.from(byDay.values()).sort((a, b) => (a.date < b.date ? -1 : 1));
|
||||
|
||||
const points = new Map();
|
||||
const values = [];
|
||||
const compactPoints = [];
|
||||
|
||||
const MAX_POINTS = 260;
|
||||
if (commits.length > MAX_POINTS) commits = commits.slice(commits.length - MAX_POINTS);
|
||||
if (dayEntries.length > MAX_POINTS) dayEntries = dayEntries.slice(dayEntries.length - MAX_POINTS);
|
||||
|
||||
for (const c of commits) {
|
||||
const sha = String(c.sha || "");
|
||||
const d = String(c.date || "");
|
||||
if (!sha || !d) continue;
|
||||
let removedStreak = false;
|
||||
let prevLive = null;
|
||||
|
||||
async function loadAtSha(sha) {
|
||||
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;
|
||||
}
|
||||
obj = await githubFetchFileAtSha({ owner, repo, sha, path: dbFile });
|
||||
fileJsonCache.set(ck, obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
for (const day of dayEntries) {
|
||||
const d = String(day.date || "");
|
||||
const firstSha = String(day.first?.sha || "");
|
||||
const lastSha = String(day.last?.sha || "");
|
||||
if (!d || !lastSha) continue;
|
||||
|
||||
let objLast;
|
||||
try {
|
||||
objLast = await loadAtSha(lastSha);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// findMinPriceForSkuGroupInDb already ignores removed rows inside each DB snapshot.
|
||||
const pNum = findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel);
|
||||
const lastMin = findMinPricesForSkuGroupInDb(objLast, skuKeys, storeLabel);
|
||||
const lastLive = lastMin.liveMin;
|
||||
const lastRemoved = lastMin.removedMin;
|
||||
|
||||
points.set(d, pNum);
|
||||
if (pNum !== null) values.push(pNum);
|
||||
// "removed state" at end of day: no live price but removed price exists
|
||||
const isRemovedDayState = lastLive === null && lastRemoved !== null;
|
||||
|
||||
// If removed at end-of-day, try to find a live price earlier the same day
|
||||
let sameDayLive = null;
|
||||
if (isRemovedDayState && firstSha && firstSha !== lastSha) {
|
||||
try {
|
||||
const objFirst = await loadAtSha(firstSha);
|
||||
const firstMin = findMinPricesForSkuGroupInDb(objFirst, skuKeys, storeLabel);
|
||||
if (firstMin.liveMin !== null) sameDayLive = firstMin.liveMin;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let v = null;
|
||||
|
||||
if (lastLive !== null) {
|
||||
// live exists at end of day
|
||||
v = lastLive;
|
||||
removedStreak = false;
|
||||
prevLive = lastLive;
|
||||
} else if (isRemovedDayState) {
|
||||
// show a dot ONLY on the first day it becomes removed
|
||||
if (!removedStreak) {
|
||||
// Prefer removed snapshot price (price at removal time), else earlier same-day live, else last known live
|
||||
v = lastRemoved !== null ? lastRemoved : sameDayLive !== null ? sameDayLive : prevLive;
|
||||
removedStreak = true;
|
||||
} else {
|
||||
v = null; // days after removal: no dot
|
||||
}
|
||||
} else {
|
||||
v = null;
|
||||
}
|
||||
|
||||
points.set(d, v);
|
||||
if (v !== null) values.push(v);
|
||||
allDatesSet.add(d);
|
||||
compactPoints.push({ date: d, price: pNum });
|
||||
compactPoints.push({ date: d, price: v });
|
||||
}
|
||||
|
||||
// Add "today" point ONLY if listing currently exists in this store/dbFile (live rows present)
|
||||
|
|
@ -442,10 +535,10 @@ export async function renderItem($app, skuInput) {
|
|||
});
|
||||
|
||||
$status.textContent = manifest
|
||||
? (isRemovedEverywhere
|
||||
? `History loaded (removed everywhere). Source=prebuilt manifest. Points=${labels.length}.`
|
||||
: `History loaded from prebuilt manifest (1 point/day) + current run. Points=${labels.length}.`)
|
||||
: (isRemovedEverywhere
|
||||
? `History loaded (removed everywhere). Source=GitHub API fallback. Points=${labels.length}.`
|
||||
: `History loaded (GitHub API fallback; 1 point/day) + current run. Points=${labels.length}.`);
|
||||
? isRemovedEverywhere
|
||||
? `History loaded (removed everywhere). Source=prebuilt manifest. Points=${labels.length}.`
|
||||
: `History loaded from prebuilt manifest (1+ commit/day) + current run. Points=${labels.length}.`
|
||||
: isRemovedEverywhere
|
||||
? `History loaded (removed everywhere). Source=GitHub API fallback. Points=${labels.length}.`
|
||||
: `History loaded (GitHub API fallback; 1+ commit/day) + current run. Points=${labels.length}.`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue