mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
UX Improvements
This commit is contained in:
parent
2c08206407
commit
05f14464b8
2 changed files with 161 additions and 38 deletions
|
|
@ -210,7 +210,7 @@ function computeDailyStoreSeriesFromReport(report, filter) {
|
|||
|
||||
if (minP !== null || maxP !== null) {
|
||||
const rp = rowPriceNum(r, stores);
|
||||
// store-page behavior: "no price" rows pass the filter (they won't contribute anyway)
|
||||
// "no price" rows pass the filter (they won't contribute anyway)
|
||||
if (rp !== null) {
|
||||
if (minP !== null && rp < minP) continue;
|
||||
if (maxP !== null && rp > maxP) continue;
|
||||
|
|
@ -271,17 +271,26 @@ async function loadCommonCommitsManifest() {
|
|||
// Fallback: GitHub API commits for a path, collapsed to one commit per day (newest that day),
|
||||
// returned oldest -> newest, same shape as manifest entries.
|
||||
async function loadCommitsFallback({ owner, repo, branch, relPath }) {
|
||||
let apiCommits = await githubListCommits({ owner, repo, branch, path: relPath });
|
||||
let apiCommits = await githubListCommits({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
path: relPath,
|
||||
});
|
||||
apiCommits = Array.isArray(apiCommits) ? apiCommits : [];
|
||||
|
||||
// newest -> oldest from API; we want newest-per-day then oldest -> newest
|
||||
const byDate = new Map();
|
||||
for (const c of apiCommits) {
|
||||
const sha = String(c?.sha || "");
|
||||
const ts = String(c?.commit?.committer?.date || c?.commit?.author?.date || "");
|
||||
const ts = String(
|
||||
c?.commit?.committer?.date || c?.commit?.author?.date || ""
|
||||
);
|
||||
const d = dateOnly(ts);
|
||||
if (!sha || !d) continue;
|
||||
if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts });
|
||||
}
|
||||
|
||||
return [...byDate.values()].reverse();
|
||||
}
|
||||
|
||||
|
|
@ -297,8 +306,10 @@ async function loadRawSeries({ group, size, onStatus }) {
|
|||
const branch = "data";
|
||||
|
||||
const manifest = await loadCommonCommitsManifest();
|
||||
|
||||
let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null;
|
||||
|
||||
// Fallback if manifest missing/empty
|
||||
if (!commits || !commits.length) {
|
||||
if (typeof onStatus === "function")
|
||||
onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`);
|
||||
|
|
@ -313,7 +324,11 @@ async function loadRawSeries({ group, size, onStatus }) {
|
|||
|
||||
const cacheKey = `${group}:${size}`;
|
||||
const cached = RAW_SERIES_CACHE.get(cacheKey);
|
||||
if (cached && cached.latestSha === latestSha && cached.labels?.length === commits.length) {
|
||||
if (
|
||||
cached &&
|
||||
cached.latestSha === latestSha &&
|
||||
cached.labels?.length === commits.length
|
||||
) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
|
@ -328,12 +343,14 @@ async function loadRawSeries({ group, size, onStatus }) {
|
|||
const stores = Array.isArray(newestReport?.stores)
|
||||
? newestReport.stores.map(String)
|
||||
: [];
|
||||
if (!stores.length) throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`);
|
||||
if (!stores.length)
|
||||
throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`);
|
||||
|
||||
const labels = commits.map((c) => String(c.date || "")).filter(Boolean);
|
||||
const shaByIdx = commits.map((c) => String(c.sha || ""));
|
||||
|
||||
if (typeof onStatus === "function") onStatus(`Loading ${labels.length} day(s)…`);
|
||||
if (typeof onStatus === "function")
|
||||
onStatus(`Loading ${labels.length} day(s)…`);
|
||||
|
||||
const reportsByIdx = new Array(shaByIdx.length).fill(null);
|
||||
|
||||
|
|
@ -342,12 +359,20 @@ async function loadRawSeries({ group, size, onStatus }) {
|
|||
shaByIdx.map((sha, idx) =>
|
||||
limitNet(async () => {
|
||||
try {
|
||||
reportsByIdx[idx] = await githubFetchFileAtSha({ owner, repo, sha, path: rel });
|
||||
reportsByIdx[idx] = await githubFetchFileAtSha({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
path: rel,
|
||||
});
|
||||
} catch {
|
||||
reportsByIdx[idx] = null;
|
||||
} finally {
|
||||
done++;
|
||||
if (typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length)) {
|
||||
if (
|
||||
typeof onStatus === "function" &&
|
||||
(done % 10 === 0 || done === shaByIdx.length)
|
||||
) {
|
||||
onStatus(`Loading ${labels.length} day(s)… ${done}/${labels.length}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -409,6 +434,28 @@ function computeSeriesFromRaw(raw, filter) {
|
|||
return { labels, stores, seriesByStore, newestUsed, newestTotal };
|
||||
}
|
||||
|
||||
/* ---------------- y-axis bounds ---------------- */
|
||||
|
||||
function computeYBounds(seriesByStore, defaultAbs) {
|
||||
let mn = Infinity;
|
||||
let mx = -Infinity;
|
||||
|
||||
for (const arr of Object.values(seriesByStore || {})) {
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const v of arr) {
|
||||
if (!Number.isFinite(v)) continue;
|
||||
mn = Math.min(mn, v);
|
||||
mx = Math.max(mx, v);
|
||||
}
|
||||
}
|
||||
|
||||
if (mn === Infinity) return { min: -defaultAbs, max: defaultAbs };
|
||||
|
||||
const min = Math.min(-defaultAbs, Math.floor(mn));
|
||||
const max = Math.max(defaultAbs, Math.ceil(mx));
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/* ---------------- prefs ---------------- */
|
||||
|
||||
const LS_GROUP = "stviz:v1:stats:group";
|
||||
|
|
@ -495,6 +542,8 @@ export async function renderStats($app) {
|
|||
<div class="small" style="white-space:nowrap; opacity:.75;">Price</div>
|
||||
|
||||
<div class="rangeDual" aria-label="Price range">
|
||||
<div class="rangeTrack"></div>
|
||||
<div class="rangeFill" id="statsRangeFill"></div>
|
||||
<input id="statsMinPrice" type="range" min="0" max="1000" step="1" value="0" />
|
||||
<input id="statsMaxPrice" type="range" min="0" max="1000" step="1" value="1000" />
|
||||
</div>
|
||||
|
|
@ -523,6 +572,7 @@ export async function renderStats($app) {
|
|||
|
||||
const $minR = document.getElementById("statsMinPrice");
|
||||
const $maxR = document.getElementById("statsMaxPrice");
|
||||
const $fill = document.getElementById("statsRangeFill");
|
||||
const $priceLabel = document.getElementById("statsPriceLabel");
|
||||
const $priceWrap = document.getElementById("statsPriceWrap");
|
||||
|
||||
|
|
@ -571,6 +621,7 @@ export async function renderStats($app) {
|
|||
const t = Number.isFinite(v) ? v / 1000 : 1;
|
||||
return priceFromT(t);
|
||||
}
|
||||
|
||||
function updateRangeZ() {
|
||||
// help when thumbs overlap
|
||||
const a = Number($minR.value);
|
||||
|
|
@ -584,9 +635,21 @@ export async function renderStats($app) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateRangeFill() {
|
||||
if (!$fill) return;
|
||||
const a = Number($minR.value) || 0; // 0..1000
|
||||
const b = Number($maxR.value) || 1000;
|
||||
const lo = Math.min(a, b) / 1000;
|
||||
const hi = Math.max(a, b) / 1000;
|
||||
$fill.style.left = `${(lo * 100).toFixed(2)}%`;
|
||||
$fill.style.right = `${((1 - hi) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function updatePriceLabel() {
|
||||
if (!$priceLabel) return;
|
||||
$priceLabel.textContent = `${formatDollars(selectedMinPrice)} – ${formatDollars(selectedMaxPrice)}`;
|
||||
$priceLabel.textContent = `${formatDollars(
|
||||
selectedMinPrice
|
||||
)} – ${formatDollars(selectedMaxPrice)}`;
|
||||
}
|
||||
|
||||
function saveFilterPrefs(group, size) {
|
||||
|
|
@ -615,16 +678,16 @@ export async function renderStats($app) {
|
|||
return { q, minP, maxP };
|
||||
}
|
||||
|
||||
async function drawOrUpdateChart({ labels, stores, seriesByStore }) {
|
||||
async function drawOrUpdateChart(series, yBounds) {
|
||||
const { labels, stores, seriesByStore } = series;
|
||||
|
||||
const Chart = await ensureChartJs();
|
||||
const canvas = document.getElementById("statsChart");
|
||||
if (!canvas) return;
|
||||
|
||||
const datasets = stores.map((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,
|
||||
tension: 0.15,
|
||||
}));
|
||||
|
|
@ -632,6 +695,10 @@ export async function renderStats($app) {
|
|||
if (_chart) {
|
||||
_chart.data.labels = labels;
|
||||
_chart.data.datasets = datasets;
|
||||
if (yBounds) {
|
||||
_chart.options.scales.y.min = yBounds.min;
|
||||
_chart.options.scales.y.max = yBounds.max;
|
||||
}
|
||||
_chart.update("none");
|
||||
return;
|
||||
}
|
||||
|
|
@ -663,6 +730,8 @@ export async function renderStats($app) {
|
|||
ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 },
|
||||
},
|
||||
y: {
|
||||
min: yBounds?.min,
|
||||
max: yBounds?.max,
|
||||
title: { display: true, text: "Avg % vs per-SKU median" },
|
||||
ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 },
|
||||
},
|
||||
|
|
@ -674,7 +743,7 @@ export async function renderStats($app) {
|
|||
let raw = null; // loaded series (reports cached in-memory)
|
||||
let applyTimer = null;
|
||||
|
||||
async function rerender(loadOnly = false) {
|
||||
async function rerender() {
|
||||
destroyStatsChart();
|
||||
|
||||
const group = String($group?.value || "all");
|
||||
|
|
@ -690,9 +759,13 @@ export async function renderStats($app) {
|
|||
const b = computePriceBoundsFromReport(newestReport, raw.stores);
|
||||
|
||||
// dynamic floor: if we have a real min, use it (but keep >= 1); else default to 25
|
||||
const floor = Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25;
|
||||
const floor =
|
||||
Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25;
|
||||
boundMin = floor;
|
||||
boundMax = Number.isFinite(b.max) && b.max > boundMin ? Math.ceil(b.max) : Math.max(boundMin, 1000);
|
||||
boundMax =
|
||||
Number.isFinite(b.max) && b.max > boundMin
|
||||
? Math.ceil(b.max)
|
||||
: Math.max(boundMin, 1000);
|
||||
|
||||
// hydrate UI from prefs (and clamp to bounds)
|
||||
const saved = loadFilterPrefs(group, size);
|
||||
|
|
@ -707,6 +780,8 @@ export async function renderStats($app) {
|
|||
selectedMaxPrice = boundMax;
|
||||
setSliderFromPrice($minR, boundMin);
|
||||
setSliderFromPrice($maxR, boundMax);
|
||||
updateRangeZ();
|
||||
updateRangeFill();
|
||||
updatePriceLabel();
|
||||
} else {
|
||||
$minR.disabled = false;
|
||||
|
|
@ -724,11 +799,10 @@ export async function renderStats($app) {
|
|||
setSliderFromPrice($minR, selectedMinPrice);
|
||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||
updateRangeZ();
|
||||
updateRangeFill();
|
||||
updatePriceLabel();
|
||||
}
|
||||
|
||||
if (loadOnly) return;
|
||||
|
||||
// apply filters and draw
|
||||
const tokens = tokenizeQuery($q?.value || "");
|
||||
const series = computeSeriesFromRaw(raw, {
|
||||
|
|
@ -737,14 +811,16 @@ export async function renderStats($app) {
|
|||
maxPrice: selectedMaxPrice,
|
||||
});
|
||||
|
||||
await drawOrUpdateChart(series);
|
||||
const abs = group === "all" ? 12 : 8;
|
||||
const yBounds = computeYBounds(series.seriesByStore, abs);
|
||||
|
||||
const rel = relReportPath(group, size);
|
||||
onStatus(
|
||||
`Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc(
|
||||
rel
|
||||
)} @ ${esc(raw.latestSha.slice(0, 7))}`
|
||||
);
|
||||
await drawOrUpdateChart(series, yBounds);
|
||||
|
||||
const short = `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}.`;
|
||||
onStatus(short);
|
||||
if ($status) {
|
||||
$status.title = `Source: ${relReportPath(group, size)} @ ${raw.latestSha.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
saveFilterPrefs(group, size);
|
||||
} catch (e) {
|
||||
|
|
@ -771,13 +847,16 @@ export async function renderStats($app) {
|
|||
maxPrice: selectedMaxPrice,
|
||||
});
|
||||
|
||||
await drawOrUpdateChart(series);
|
||||
const abs = group === "all" ? 12 : 8;
|
||||
const yBounds = computeYBounds(series.seriesByStore, abs);
|
||||
|
||||
onStatus(
|
||||
`Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc(
|
||||
relReportPath(group, size)
|
||||
)} @ ${esc(raw.latestSha.slice(0, 7))}`
|
||||
);
|
||||
await drawOrUpdateChart(series, yBounds);
|
||||
|
||||
const short = `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}.`;
|
||||
onStatus(short);
|
||||
if ($status) {
|
||||
$status.title = `Source: ${relReportPath(group, size)} @ ${raw.latestSha.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
saveFilterPrefs(group, size);
|
||||
}, ms);
|
||||
|
|
@ -805,20 +884,21 @@ export async function renderStats($app) {
|
|||
setSliderFromPrice($minR, selectedMinPrice);
|
||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||
updateRangeZ();
|
||||
updateRangeFill();
|
||||
updatePriceLabel();
|
||||
}
|
||||
|
||||
// initial load
|
||||
await rerender(false);
|
||||
await rerender();
|
||||
|
||||
// dropdowns: reload raw series + rehydrate filters (clamped)
|
||||
$group?.addEventListener("change", async () => {
|
||||
onStatus("Loading…");
|
||||
await rerender(false);
|
||||
await rerender();
|
||||
});
|
||||
$size?.addEventListener("change", async () => {
|
||||
onStatus("Loading…");
|
||||
await rerender(false);
|
||||
await rerender();
|
||||
});
|
||||
|
||||
// search: realtime
|
||||
|
|
@ -861,9 +941,14 @@ export async function renderStats($app) {
|
|||
setSliderFromPrice($minR, selectedMinPrice);
|
||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||
updateRangeZ();
|
||||
updateRangeFill();
|
||||
updatePriceLabel();
|
||||
|
||||
applyFiltersDebounced(0);
|
||||
$q?.focus();
|
||||
});
|
||||
|
||||
// ensure fill is correct on first paint
|
||||
updateRangeZ();
|
||||
updateRangeFill();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -488,7 +488,19 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
|||
border-color: rgba(200, 120, 20, 0.28);
|
||||
}
|
||||
|
||||
/* --- Stats page: dual range slider --- */
|
||||
/* Prevent layout width shift when scrollbar appears/disappears */
|
||||
html { overflow-y: scroll; }
|
||||
|
||||
/* Prevent long status text from forcing header wrap */
|
||||
#statsStatus{
|
||||
max-width: 52ch;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- Stats dual range slider: custom track + fill (no native progress) --- */
|
||||
.rangeDual {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
|
|
@ -496,6 +508,28 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
|||
height: 18px;
|
||||
}
|
||||
|
||||
.rangeDual .rangeTrack,
|
||||
.rangeDual .rangeFill {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.rangeDual .rangeTrack {
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.rangeDual .rangeFill {
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #37566b; /* matches your focus outline color */
|
||||
}
|
||||
|
||||
/* Hide native track/progress; keep thumbs clickable */
|
||||
.rangeDual input[type="range"] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
|
@ -504,10 +538,14 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
|||
height: 18px;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
accent-color: #9aa3b2;
|
||||
pointer-events: none; /* allow both sliders to be interactable via thumbs */
|
||||
pointer-events: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* thumbs must still receive pointer events */
|
||||
.rangeDual input[type="range"]::-webkit-slider-runnable-track { background: transparent; }
|
||||
.rangeDual input[type="range"]::-webkit-slider-thumb { pointer-events: all; }
|
||||
|
||||
.rangeDual input[type="range"]::-moz-range-track { background: transparent; }
|
||||
.rangeDual input[type="range"]::-moz-range-progress { background: transparent; }
|
||||
.rangeDual input[type="range"]::-moz-range-thumb { pointer-events: all; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue