mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
UX Improvements
This commit is contained in:
parent
05f14464b8
commit
c3702b0ba2
1 changed files with 46 additions and 55 deletions
|
|
@ -154,13 +154,15 @@ function rowPriceNum(r, stores) {
|
||||||
return Number.isFinite(med) ? med : null;
|
return Number.isFinite(med) ? med : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- price slider mapping (store-page style) ---------------- */
|
/* ---------------- price slider mapping (store-page-ish, but faster low-end) ---------------- */
|
||||||
|
|
||||||
|
// faster low-end: coarser step sizes early so you jump past $10/$20 quickly
|
||||||
function stepForPrice(p, boundMax) {
|
function stepForPrice(p, boundMax) {
|
||||||
const x = Number.isFinite(p) ? p : boundMax;
|
const x = Number.isFinite(p) ? p : boundMax;
|
||||||
if (x < 120) return 5;
|
if (x < 50) return 10;
|
||||||
if (x < 250) return 10;
|
if (x < 120) return 25;
|
||||||
if (x < 600) return 25;
|
if (x < 250) return 25;
|
||||||
|
if (x < 600) return 50;
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
function roundToStep(p, boundMax) {
|
function roundToStep(p, boundMax) {
|
||||||
|
|
@ -279,7 +281,6 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) {
|
||||||
});
|
});
|
||||||
apiCommits = Array.isArray(apiCommits) ? apiCommits : [];
|
apiCommits = Array.isArray(apiCommits) ? apiCommits : [];
|
||||||
|
|
||||||
// newest -> oldest from API; we want newest-per-day then oldest -> newest
|
|
||||||
const byDate = new Map();
|
const byDate = new Map();
|
||||||
for (const c of apiCommits) {
|
for (const c of apiCommits) {
|
||||||
const sha = String(c?.sha || "");
|
const sha = String(c?.sha || "");
|
||||||
|
|
@ -294,7 +295,7 @@ async function loadCommitsFallback({ owner, repo, branch, relPath }) {
|
||||||
return [...byDate.values()].reverse();
|
return [...byDate.values()].reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- raw series cache (network only once per group/size) ---------------- */
|
/* ---------------- raw series cache ---------------- */
|
||||||
|
|
||||||
const RAW_SERIES_CACHE = new Map(); // key: `${group}:${size}` -> { latestSha, labels, stores, commits, reportsByIdx }
|
const RAW_SERIES_CACHE = new Map(); // key: `${group}:${size}` -> { latestSha, labels, stores, commits, reportsByIdx }
|
||||||
|
|
||||||
|
|
@ -307,9 +308,10 @@ async function loadRawSeries({ group, size, onStatus }) {
|
||||||
|
|
||||||
const manifest = await loadCommonCommitsManifest();
|
const manifest = await loadCommonCommitsManifest();
|
||||||
|
|
||||||
let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null;
|
let commits = Array.isArray(manifest?.files?.[rel])
|
||||||
|
? manifest.files[rel]
|
||||||
|
: null;
|
||||||
|
|
||||||
// Fallback if manifest missing/empty
|
|
||||||
if (!commits || !commits.length) {
|
if (!commits || !commits.length) {
|
||||||
if (typeof onStatus === "function")
|
if (typeof onStatus === "function")
|
||||||
onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`);
|
onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`);
|
||||||
|
|
@ -407,7 +409,6 @@ function computeSeriesFromRaw(raw, filter) {
|
||||||
const seriesByStore = {};
|
const seriesByStore = {};
|
||||||
for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null);
|
for (const s of stores) seriesByStore[s] = new Array(labels.length).fill(null);
|
||||||
|
|
||||||
// counts based on newest day only (for status)
|
|
||||||
let newestUsed = 0;
|
let newestUsed = 0;
|
||||||
let newestTotal = 0;
|
let newestTotal = 0;
|
||||||
|
|
||||||
|
|
@ -587,35 +588,42 @@ export async function renderStats($app) {
|
||||||
location.hash = "#/";
|
location.hash = "#/";
|
||||||
});
|
});
|
||||||
|
|
||||||
// current UI state (derived after load)
|
// allow 0 as the floor
|
||||||
let boundMin = 25;
|
let boundMin = 0;
|
||||||
let boundMax = 1000;
|
let boundMax = 1000;
|
||||||
|
|
||||||
let selectedMinPrice = boundMin;
|
let selectedMinPrice = boundMin;
|
||||||
let selectedMaxPrice = boundMax;
|
let selectedMaxPrice = boundMax;
|
||||||
|
|
||||||
|
// faster early ramp: use sqrt easing so early motion moves faster up the range
|
||||||
function priceFromT(t) {
|
function priceFromT(t) {
|
||||||
t = clamp(t, 0, 1);
|
t = clamp(t, 0, 1);
|
||||||
if (boundMax <= boundMin) return boundMin;
|
if (boundMax <= boundMin) return boundMin;
|
||||||
const ratio = boundMax / boundMin;
|
// sqrt easing (fast early)
|
||||||
return boundMin * Math.exp(Math.log(ratio) * t);
|
const te = Math.sqrt(t);
|
||||||
|
return boundMin + (boundMax - boundMin) * te;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tFromPrice(price) {
|
function tFromPrice(price) {
|
||||||
if (!Number.isFinite(price)) return 1;
|
if (!Number.isFinite(price)) return 1;
|
||||||
if (boundMax <= boundMin) return 1;
|
if (boundMax <= boundMin) return 1;
|
||||||
const p = clamp(price, boundMin, boundMax);
|
const p = clamp(price, boundMin, boundMax);
|
||||||
const ratio = boundMax / boundMin;
|
const lin = (p - boundMin) / (boundMax - boundMin);
|
||||||
return Math.log(p / boundMin) / Math.log(ratio);
|
// inverse of sqrt easing
|
||||||
|
return lin * lin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampAndRound(p) {
|
function clampAndRound(p) {
|
||||||
const c = clamp(p, boundMin, boundMax);
|
const c = clamp(p, boundMin, boundMax);
|
||||||
const r = roundToStep(c, boundMax);
|
const r = roundToStep(c, boundMax);
|
||||||
return clamp(r, boundMin, boundMax);
|
return clamp(r, boundMin, boundMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSliderFromPrice($el, price) {
|
function setSliderFromPrice($el, price) {
|
||||||
const t = tFromPrice(price);
|
const t = tFromPrice(price);
|
||||||
$el.value = String(Math.round(t * 1000));
|
$el.value = String(Math.round(t * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
function priceFromSlider($el) {
|
function priceFromSlider($el) {
|
||||||
const v = Number($el.value);
|
const v = Number($el.value);
|
||||||
const t = Number.isFinite(v) ? v / 1000 : 1;
|
const t = Number.isFinite(v) ? v / 1000 : 1;
|
||||||
|
|
@ -623,7 +631,6 @@ export async function renderStats($app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRangeZ() {
|
function updateRangeZ() {
|
||||||
// help when thumbs overlap
|
|
||||||
const a = Number($minR.value);
|
const a = Number($minR.value);
|
||||||
const b = Number($maxR.value);
|
const b = Number($maxR.value);
|
||||||
if (a >= b - 10) {
|
if (a >= b - 10) {
|
||||||
|
|
@ -637,7 +644,7 @@ export async function renderStats($app) {
|
||||||
|
|
||||||
function updateRangeFill() {
|
function updateRangeFill() {
|
||||||
if (!$fill) return;
|
if (!$fill) return;
|
||||||
const a = Number($minR.value) || 0; // 0..1000
|
const a = Number($minR.value) || 0;
|
||||||
const b = Number($maxR.value) || 1000;
|
const b = Number($maxR.value) || 1000;
|
||||||
const lo = Math.min(a, b) / 1000;
|
const lo = Math.min(a, b) / 1000;
|
||||||
const hi = Math.max(a, b) / 1000;
|
const hi = Math.max(a, b) / 1000;
|
||||||
|
|
@ -647,9 +654,9 @@ export async function renderStats($app) {
|
||||||
|
|
||||||
function updatePriceLabel() {
|
function updatePriceLabel() {
|
||||||
if (!$priceLabel) return;
|
if (!$priceLabel) return;
|
||||||
$priceLabel.textContent = `${formatDollars(
|
$priceLabel.textContent = `${formatDollars(selectedMinPrice)} – ${formatDollars(
|
||||||
selectedMinPrice
|
selectedMaxPrice
|
||||||
)} – ${formatDollars(selectedMaxPrice)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFilterPrefs(group, size) {
|
function saveFilterPrefs(group, size) {
|
||||||
|
|
@ -687,7 +694,9 @@ export async function renderStats($app) {
|
||||||
|
|
||||||
const datasets = stores.map((s) => ({
|
const datasets = stores.map((s) => ({
|
||||||
label: displayStoreName(s),
|
label: displayStoreName(s),
|
||||||
data: Array.isArray(seriesByStore[s]) ? seriesByStore[s] : labels.map(() => null),
|
data: Array.isArray(seriesByStore[s])
|
||||||
|
? seriesByStore[s]
|
||||||
|
: labels.map(() => null),
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
tension: 0.15,
|
tension: 0.15,
|
||||||
}));
|
}));
|
||||||
|
|
@ -733,14 +742,17 @@ export async function renderStats($app) {
|
||||||
min: yBounds?.min,
|
min: yBounds?.min,
|
||||||
max: yBounds?.max,
|
max: yBounds?.max,
|
||||||
title: { display: true, text: "Avg % vs per-SKU median" },
|
title: { display: true, text: "Avg % vs per-SKU median" },
|
||||||
ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 },
|
ticks: {
|
||||||
|
callback: (v) => `${Number(v).toFixed(0)}%`,
|
||||||
|
maxTicksLimit: 12,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = null; // loaded series (reports cached in-memory)
|
let raw = null;
|
||||||
let applyTimer = null;
|
let applyTimer = null;
|
||||||
|
|
||||||
async function rerender() {
|
async function rerender() {
|
||||||
|
|
@ -754,35 +766,23 @@ export async function renderStats($app) {
|
||||||
onStatus("Loading…");
|
onStatus("Loading…");
|
||||||
raw = await loadRawSeries({ group, size, onStatus });
|
raw = await loadRawSeries({ group, size, onStatus });
|
||||||
|
|
||||||
// bounds based on newest day report (last)
|
|
||||||
const newestReport = raw.reportsByIdx[raw.reportsByIdx.length - 1];
|
const newestReport = raw.reportsByIdx[raw.reportsByIdx.length - 1];
|
||||||
const b = computePriceBoundsFromReport(newestReport, raw.stores);
|
const b = computePriceBoundsFromReport(newestReport, raw.stores);
|
||||||
|
|
||||||
// dynamic floor: if we have a real min, use it (but keep >= 1); else default to 25
|
// floor is ALWAYS 0 now
|
||||||
const floor =
|
boundMin = 0;
|
||||||
Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25;
|
|
||||||
boundMin = floor;
|
|
||||||
boundMax =
|
boundMax =
|
||||||
Number.isFinite(b.max) && b.max > boundMin
|
Number.isFinite(b.max) && b.max > 0 ? Math.ceil(b.max) : 1000;
|
||||||
? Math.ceil(b.max)
|
|
||||||
: Math.max(boundMin, 1000);
|
|
||||||
|
|
||||||
// hydrate UI from prefs (and clamp to bounds)
|
|
||||||
const saved = loadFilterPrefs(group, size);
|
const saved = loadFilterPrefs(group, size);
|
||||||
if ($q) $q.value = saved.q || "";
|
if ($q) $q.value = saved.q || "";
|
||||||
|
|
||||||
// if no prices, disable slider
|
|
||||||
if (!Number.isFinite(b.max)) {
|
if (!Number.isFinite(b.max)) {
|
||||||
$minR.disabled = true;
|
$minR.disabled = true;
|
||||||
$maxR.disabled = true;
|
$maxR.disabled = true;
|
||||||
$priceWrap.title = "No priced items in this dataset.";
|
$priceWrap.title = "No priced items in this dataset.";
|
||||||
selectedMinPrice = boundMin;
|
selectedMinPrice = boundMin;
|
||||||
selectedMaxPrice = boundMax;
|
selectedMaxPrice = boundMax;
|
||||||
setSliderFromPrice($minR, boundMin);
|
|
||||||
setSliderFromPrice($maxR, boundMax);
|
|
||||||
updateRangeZ();
|
|
||||||
updateRangeFill();
|
|
||||||
updatePriceLabel();
|
|
||||||
} else {
|
} else {
|
||||||
$minR.disabled = false;
|
$minR.disabled = false;
|
||||||
$maxR.disabled = false;
|
$maxR.disabled = false;
|
||||||
|
|
@ -794,16 +794,16 @@ export async function renderStats($app) {
|
||||||
selectedMinPrice = clampAndRound(wantMin);
|
selectedMinPrice = clampAndRound(wantMin);
|
||||||
selectedMaxPrice = clampAndRound(wantMax);
|
selectedMaxPrice = clampAndRound(wantMax);
|
||||||
|
|
||||||
if (selectedMinPrice > selectedMaxPrice) selectedMinPrice = selectedMaxPrice;
|
if (selectedMinPrice > selectedMaxPrice)
|
||||||
|
selectedMinPrice = selectedMaxPrice;
|
||||||
setSliderFromPrice($minR, selectedMinPrice);
|
|
||||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
|
||||||
updateRangeZ();
|
|
||||||
updateRangeFill();
|
|
||||||
updatePriceLabel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply filters and draw
|
setSliderFromPrice($minR, selectedMinPrice);
|
||||||
|
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||||
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
|
updatePriceLabel();
|
||||||
|
|
||||||
const tokens = tokenizeQuery($q?.value || "");
|
const tokens = tokenizeQuery($q?.value || "");
|
||||||
const series = computeSeriesFromRaw(raw, {
|
const series = computeSeriesFromRaw(raw, {
|
||||||
tokens,
|
tokens,
|
||||||
|
|
@ -871,7 +871,6 @@ export async function renderStats($app) {
|
||||||
let nextMin = clampAndRound(rawMin);
|
let nextMin = clampAndRound(rawMin);
|
||||||
let nextMax = clampAndRound(rawMax);
|
let nextMax = clampAndRound(rawMax);
|
||||||
|
|
||||||
// prevent crossing (keep the one being dragged “authoritative”)
|
|
||||||
if (nextMin > nextMax) {
|
if (nextMin > nextMax) {
|
||||||
if (which === "min") nextMax = nextMin;
|
if (which === "min") nextMax = nextMin;
|
||||||
else nextMin = nextMax;
|
else nextMin = nextMax;
|
||||||
|
|
@ -880,7 +879,6 @@ export async function renderStats($app) {
|
||||||
selectedMinPrice = nextMin;
|
selectedMinPrice = nextMin;
|
||||||
selectedMaxPrice = nextMax;
|
selectedMaxPrice = nextMax;
|
||||||
|
|
||||||
// snap sliders to rounded values for stability
|
|
||||||
setSliderFromPrice($minR, selectedMinPrice);
|
setSliderFromPrice($minR, selectedMinPrice);
|
||||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||||
updateRangeZ();
|
updateRangeZ();
|
||||||
|
|
@ -888,10 +886,8 @@ export async function renderStats($app) {
|
||||||
updatePriceLabel();
|
updatePriceLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial load
|
|
||||||
await rerender();
|
await rerender();
|
||||||
|
|
||||||
// dropdowns: reload raw series + rehydrate filters (clamped)
|
|
||||||
$group?.addEventListener("change", async () => {
|
$group?.addEventListener("change", async () => {
|
||||||
onStatus("Loading…");
|
onStatus("Loading…");
|
||||||
await rerender();
|
await rerender();
|
||||||
|
|
@ -901,14 +897,12 @@ export async function renderStats($app) {
|
||||||
await rerender();
|
await rerender();
|
||||||
});
|
});
|
||||||
|
|
||||||
// search: realtime
|
|
||||||
let tq = null;
|
let tq = null;
|
||||||
$q?.addEventListener("input", () => {
|
$q?.addEventListener("input", () => {
|
||||||
if (tq) clearTimeout(tq);
|
if (tq) clearTimeout(tq);
|
||||||
tq = setTimeout(() => applyFiltersDebounced(0), 60);
|
tq = setTimeout(() => applyFiltersDebounced(0), 60);
|
||||||
});
|
});
|
||||||
|
|
||||||
// sliders: realtime
|
|
||||||
let tp = null;
|
let tp = null;
|
||||||
$minR?.addEventListener("input", () => {
|
$minR?.addEventListener("input", () => {
|
||||||
setSelectedRangeFromSliders("min");
|
setSelectedRangeFromSliders("min");
|
||||||
|
|
@ -921,7 +915,6 @@ export async function renderStats($app) {
|
||||||
tp = setTimeout(() => applyFiltersDebounced(0), 40);
|
tp = setTimeout(() => applyFiltersDebounced(0), 40);
|
||||||
});
|
});
|
||||||
|
|
||||||
// on change: snap and apply
|
|
||||||
$minR?.addEventListener("change", () => {
|
$minR?.addEventListener("change", () => {
|
||||||
setSelectedRangeFromSliders("min");
|
setSelectedRangeFromSliders("min");
|
||||||
applyFiltersDebounced(0);
|
applyFiltersDebounced(0);
|
||||||
|
|
@ -931,7 +924,6 @@ export async function renderStats($app) {
|
||||||
applyFiltersDebounced(0);
|
applyFiltersDebounced(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// clear: reset query + full range
|
|
||||||
$clear?.addEventListener("click", () => {
|
$clear?.addEventListener("click", () => {
|
||||||
if ($q) $q.value = "";
|
if ($q) $q.value = "";
|
||||||
|
|
||||||
|
|
@ -948,7 +940,6 @@ export async function renderStats($app) {
|
||||||
$q?.focus();
|
$q?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ensure fill is correct on first paint
|
|
||||||
updateRangeZ();
|
updateRangeZ();
|
||||||
updateRangeFill();
|
updateRangeFill();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue