mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
feat: UI Improvements
This commit is contained in:
parent
838241ac13
commit
3bedd13bab
3 changed files with 101 additions and 28 deletions
|
|
@ -67,18 +67,25 @@ function computeSuggestedY(values) {
|
||||||
const nums = values.filter((v) => Number.isFinite(v));
|
const nums = values.filter((v) => Number.isFinite(v));
|
||||||
if (!nums.length) return { suggestedMin: undefined, suggestedMax: undefined };
|
if (!nums.length) return { suggestedMin: undefined, suggestedMax: undefined };
|
||||||
|
|
||||||
let min = nums[0],
|
let min = nums[0], max = nums[0];
|
||||||
max = nums[0];
|
|
||||||
for (const n of nums) {
|
for (const n of nums) {
|
||||||
if (n < min) min = n;
|
if (n < min) min = n;
|
||||||
if (n > max) max = n;
|
if (n > max) max = n;
|
||||||
}
|
}
|
||||||
if (min === max) return { suggestedMin: min * 0.95, suggestedMax: max * 1.05 };
|
|
||||||
|
|
||||||
const pad = (max - min) * 0.08;
|
const range = max - min;
|
||||||
return { suggestedMin: Math.max(0, min - pad), suggestedMax: max + pad };
|
const pad = range === 0 ? Math.max(1, min * 0.05) : range * 0.08;
|
||||||
|
|
||||||
|
const rawMin = Math.max(0, min - pad);
|
||||||
|
const rawMax = max + pad;
|
||||||
|
|
||||||
|
const suggestedMin = Math.floor(rawMin / 10) * 10;
|
||||||
|
const suggestedMax = Math.ceil(rawMax / 10) * 10;
|
||||||
|
|
||||||
|
return { suggestedMin, suggestedMax };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function cacheKeySeries(sku, dbFile, cacheBust) {
|
function cacheKeySeries(sku, dbFile, cacheBust) {
|
||||||
return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`;
|
return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`;
|
||||||
}
|
}
|
||||||
|
|
@ -534,7 +541,13 @@ export async function renderItem($app, skuInput) {
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } },
|
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 }, grid: { display: false } },
|
||||||
y: { ...ySug, ticks: { callback: (v) => `$${Number(v).toFixed(0)}` } },
|
y: {
|
||||||
|
...ySug,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 10,
|
||||||
|
callback: (v) => `$${Number(v).toFixed(0)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
addPendingIgnore,
|
addPendingIgnore,
|
||||||
pendingCounts,
|
pendingCounts,
|
||||||
movePendingToSubmitted,
|
movePendingToSubmitted,
|
||||||
|
clearPendingEdits,
|
||||||
} from "./pending.js";
|
} from "./pending.js";
|
||||||
|
|
||||||
/* ---------------- Similarity helpers ---------------- */
|
/* ---------------- Similarity helpers ---------------- */
|
||||||
|
|
@ -51,7 +52,6 @@ function smwsKeyFromName(name) {
|
||||||
return m ? m[1] : "";
|
return m ? m[1] : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function isNumberToken(t) {
|
function isNumberToken(t) {
|
||||||
return /^\d+$/.test(String(t || ""));
|
return /^\d+$/.test(String(t || ""));
|
||||||
}
|
}
|
||||||
|
|
@ -131,10 +131,9 @@ function similarityScore(aName, bName) {
|
||||||
const gate = firstMatch ? 1.0 : 0.12;
|
const gate = firstMatch ? 1.0 : 0.12;
|
||||||
const numGate = numberMismatchPenalty(aToks, bToks);
|
const numGate = numberMismatchPenalty(aToks, bToks);
|
||||||
|
|
||||||
return numGate * (
|
return (
|
||||||
firstMatch * 3.0 +
|
numGate *
|
||||||
overlapTail * 2.2 * gate +
|
(firstMatch * 3.0 + overlapTail * 2.2 * gate + levSim * (firstMatch ? 1.0 : 0.15))
|
||||||
levSim * (firstMatch ? 1.0 : 0.15)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,7 +197,7 @@ function buildMappedSkuSet(links) {
|
||||||
|
|
||||||
function isBCStoreLabel(label) {
|
function isBCStoreLabel(label) {
|
||||||
const s = String(label || "").toLowerCase();
|
const s = String(label || "").toLowerCase();
|
||||||
return s.includes("bcl") || s.includes("strath")|| s.includes("gull")|| s.includes("legacy");
|
return s.includes("bcl") || s.includes("strath") || s.includes("gull") || s.includes("legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
function skuIsBC(allRows, skuKey) {
|
function skuIsBC(allRows, skuKey) {
|
||||||
|
|
@ -304,8 +303,7 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) {
|
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) {
|
||||||
if (!pinned || !pinned.name)
|
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
||||||
return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
|
||||||
|
|
||||||
const base = String(pinned.name || "");
|
const base = String(pinned.name || "");
|
||||||
const pinnedSku = String(pinned.sku || "");
|
const pinnedSku = String(pinned.sku || "");
|
||||||
|
|
@ -319,7 +317,10 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI
|
||||||
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
if (storesOverlap(pinned, it)) continue;
|
if (storesOverlap(pinned, it)) continue;
|
||||||
|
|
||||||
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || "")))
|
if (
|
||||||
|
typeof isIgnoredPairFn === "function" &&
|
||||||
|
isIgnoredPairFn(pinnedSku, String(it.sku || ""))
|
||||||
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// SMWS exact NUM.NUM match => force to top (requires SMWS + code match)
|
// SMWS exact NUM.NUM match => force to top (requires SMWS + code match)
|
||||||
|
|
@ -348,7 +349,6 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
||||||
const itemsAll = allAgg.filter((it) => !!it);
|
const itemsAll = allAgg.filter((it) => !!it);
|
||||||
|
|
||||||
|
|
@ -396,7 +396,10 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
if (!arr0 || arr0.length < 2) continue;
|
if (!arr0 || arr0.length < 2) continue;
|
||||||
|
|
||||||
// Bound bucket size
|
// Bound bucket size
|
||||||
const arr = arr0.slice().sort((a, b) => itemRank(b) - itemRank(a)).slice(0, 80);
|
const arr = arr0
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => itemRank(b) - itemRank(a))
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
const mapped = [];
|
const mapped = [];
|
||||||
const unmapped = [];
|
const unmapped = [];
|
||||||
|
|
@ -407,8 +410,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick best anchor (prefer mapped if available)
|
// Pick best anchor (prefer mapped if available)
|
||||||
const anchor =
|
const anchor = (mapped.length ? mapped : unmapped)
|
||||||
(mapped.length ? mapped : unmapped).slice().sort((a, b) => itemRank(b) - itemRank(a))[0];
|
.slice()
|
||||||
|
.sort((a, b) => itemRank(b) - itemRank(a))[0];
|
||||||
|
|
||||||
if (!anchor) continue;
|
if (!anchor) continue;
|
||||||
|
|
||||||
|
|
@ -479,7 +483,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
const itemNormName = new Map();
|
const itemNormName = new Map();
|
||||||
|
|
||||||
for (const it of work) {
|
for (const it of work) {
|
||||||
const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10);
|
const toks = Array.from(new Set(tokenizeQuery(it.name || "")))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 10);
|
||||||
itemTokens.set(it.sku, toks);
|
itemTokens.set(it.sku, toks);
|
||||||
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
itemNormName.set(it.sku, normSearchText(it.name || ""));
|
||||||
for (const t of toks) {
|
for (const t of toks) {
|
||||||
|
|
@ -573,7 +579,6 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
|
||||||
return out.slice(0, limitPairs);
|
return out.slice(0, limitPairs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------------- Page ---------------- */
|
/* ---------------- Page ---------------- */
|
||||||
|
|
||||||
export async function renderSkuLinker($app) {
|
export async function renderSkuLinker($app) {
|
||||||
|
|
@ -585,6 +590,12 @@ export async function renderSkuLinker($app) {
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button id="back" class="btn">← Back</button>
|
<button id="back" class="btn">← Back</button>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
|
${!localWrite ? `<span id="pendingTop" class="badge mono" style="display:none;"></span>` : ``}
|
||||||
|
${
|
||||||
|
!localWrite
|
||||||
|
? `<button id="clearPendingBtn" class="btn" style="padding:6px 10px; display:none;">Clear</button>`
|
||||||
|
: ``
|
||||||
|
}
|
||||||
<span class="badge">SKU Linker</span>
|
<span class="badge">SKU Linker</span>
|
||||||
${
|
${
|
||||||
localWrite
|
localWrite
|
||||||
|
|
@ -630,6 +641,8 @@ export async function renderSkuLinker($app) {
|
||||||
const $linkBtn = document.getElementById("linkBtn");
|
const $linkBtn = document.getElementById("linkBtn");
|
||||||
const $ignoreBtn = document.getElementById("ignoreBtn");
|
const $ignoreBtn = document.getElementById("ignoreBtn");
|
||||||
const $status = document.getElementById("status");
|
const $status = document.getElementById("status");
|
||||||
|
const $pendingTop = !localWrite ? document.getElementById("pendingTop") : null;
|
||||||
|
const $clearPendingBtn = !localWrite ? document.getElementById("clearPendingBtn") : null;
|
||||||
|
|
||||||
$listL.innerHTML = `<div class="small">Loading index…</div>`;
|
$listL.innerHTML = `<div class="small">Loading index…</div>`;
|
||||||
$listR.innerHTML = `<div class="small">Loading index…</div>`;
|
$listR.innerHTML = `<div class="small">Loading index…</div>`;
|
||||||
|
|
@ -676,7 +689,7 @@ export async function renderSkuLinker($app) {
|
||||||
const storeCount = it.stores.size || 0;
|
const storeCount = it.stores.size || 0;
|
||||||
const plus = storeCount > 1 ? ` +${storeCount - 1}` : "";
|
const plus = storeCount > 1 ? ` +${storeCount - 1}` : "";
|
||||||
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)";
|
||||||
const store = it.cheapestStoreLabel || ([...it.stores][0] || "Store");
|
const store = it.cheapestStoreLabel || [...it.stores][0] || "Store";
|
||||||
|
|
||||||
const href =
|
const href =
|
||||||
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) ||
|
URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) ||
|
||||||
|
|
@ -684,7 +697,9 @@ export async function renderSkuLinker($app) {
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const storeBadge = href
|
const storeBadge = href
|
||||||
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(store)}${esc(plus)}</a>`
|
? `<a class="badge" href="${esc(href)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(
|
||||||
|
store
|
||||||
|
)}${esc(plus)}</a>`
|
||||||
: `<span class="badge">${esc(store)}${esc(plus)}</span>`;
|
: `<span class="badge">${esc(store)}${esc(plus)}</span>`;
|
||||||
|
|
||||||
const pinnedBadge = pinned ? `<span class="badge">PINNED</span>` : ``;
|
const pinnedBadge = pinned ? `<span class="badge">PINNED</span>` : ``;
|
||||||
|
|
@ -731,8 +746,7 @@ export async function renderSkuLinker($app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto-suggestions: never include mapped skus
|
// auto-suggestions: never include mapped skus
|
||||||
if (otherPinned)
|
if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair);
|
||||||
return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair);
|
|
||||||
|
|
||||||
if (initialPairs && initialPairs.length) {
|
if (initialPairs && initialPairs.length) {
|
||||||
const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b);
|
const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b);
|
||||||
|
|
@ -807,6 +821,14 @@ export async function renderSkuLinker($app) {
|
||||||
$pr.disabled = c0.total === 0;
|
$pr.disabled = c0.total === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($pendingTop && $clearPendingBtn) {
|
||||||
|
const c0 = pendingCounts();
|
||||||
|
$pendingTop.textContent = c0.total ? `Pending: ${c0.total}` : "";
|
||||||
|
$pendingTop.style.display = c0.total ? "inline-flex" : "none";
|
||||||
|
$clearPendingBtn.style.display = c0.total ? "inline-flex" : "none";
|
||||||
|
$clearPendingBtn.disabled = c0.total === 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(pinnedL && pinnedR)) {
|
if (!(pinnedL && pinnedR)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$ignoreBtn.disabled = true;
|
$ignoreBtn.disabled = true;
|
||||||
|
|
@ -817,7 +839,8 @@ export async function renderSkuLinker($app) {
|
||||||
? `Pending changes: ${c.links} link(s), ${c.ignores} ignore(s). Create PR when ready.`
|
? `Pending changes: ${c.links} link(s), ${c.ignores} ignore(s). Create PR when ready.`
|
||||||
: "Pin one item on each side to enable linking / ignoring.";
|
: "Pin one item on each side to enable linking / ignoring.";
|
||||||
} else {
|
} else {
|
||||||
if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring.";
|
if (!$status.textContent)
|
||||||
|
$status.textContent = "Pin one item on each side to enable linking / ignoring.";
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -861,6 +884,39 @@ export async function renderSkuLinker($app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($clearPendingBtn) {
|
||||||
|
$clearPendingBtn.addEventListener("click", async () => {
|
||||||
|
const c0 = pendingCounts();
|
||||||
|
if (c0.total === 0) return;
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Clear ${c0.total} pending change(s)? This only clears local staged edits.`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
clearPendingEdits();
|
||||||
|
|
||||||
|
clearSkuRulesCache();
|
||||||
|
rules = await loadSkuRules();
|
||||||
|
ignoreSet = rules.ignoreSet;
|
||||||
|
|
||||||
|
const rebuilt = buildMappedSkuSet(rules.links || []);
|
||||||
|
mappedSkus.clear();
|
||||||
|
for (const x of rebuilt) mappedSkus.add(x);
|
||||||
|
|
||||||
|
const $pr = document.getElementById("createPrBtn");
|
||||||
|
if ($pr) {
|
||||||
|
const c = pendingCounts();
|
||||||
|
$pr.disabled = c.total === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pinnedL = null;
|
||||||
|
pinnedR = null;
|
||||||
|
$status.textContent = "Cleared pending staged edits.";
|
||||||
|
updateAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const $createPrBtn = document.getElementById("createPrBtn");
|
const $createPrBtn = document.getElementById("createPrBtn");
|
||||||
if ($createPrBtn) {
|
if ($createPrBtn) {
|
||||||
$createPrBtn.addEventListener("click", async () => {
|
$createPrBtn.addEventListener("click", async () => {
|
||||||
|
|
@ -1025,7 +1081,9 @@ export async function renderSkuLinker($app) {
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < uniq.length; i++) {
|
for (let i = 0; i < uniq.length; i++) {
|
||||||
const w = uniq[i];
|
const w = uniq[i];
|
||||||
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku(w.toSku)} …`;
|
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)} → ${displaySku(
|
||||||
|
w.toSku
|
||||||
|
)} …`;
|
||||||
await apiWriteSkuLink(w.fromSku, w.toSku);
|
await apiWriteSkuLink(w.fromSku, w.toSku);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,9 @@ export function applyPendingToMeta(meta) {
|
||||||
|
|
||||||
// merge links (dedupe by from→to)
|
// merge links (dedupe by from→to)
|
||||||
const seenL = new Set(
|
const seenL = new Set(
|
||||||
base.links.map((x) => linkKey(String(x?.fromSku || "").trim(), String(x?.toSku || "").trim())).filter(Boolean)
|
base.links
|
||||||
|
.map((x) => linkKey(String(x?.fromSku || "").trim(), String(x?.toSku || "").trim()))
|
||||||
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
for (const x of overlay.links) {
|
for (const x of overlay.links) {
|
||||||
const k = linkKey(x.fromSku, x.toSku);
|
const k = linkKey(x.fromSku, x.toSku);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue