From 05f14464b89eecd9dc0a113e8ee68e39781f3590 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Mon, 2 Feb 2026 20:45:05 -0800 Subject: [PATCH] UX Improvements --- viz/app/stats_page.js | 153 ++++++++++++++++++++++++++++++++---------- viz/style.css | 46 +++++++++++-- 2 files changed, 161 insertions(+), 38 deletions(-) diff --git a/viz/app/stats_page.js b/viz/app/stats_page.js index a3058ad..d31b1d6 100644 --- a/viz/app/stats_page.js +++ b/viz/app/stats_page.js @@ -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) {
Price
+
+
@@ -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(); } diff --git a/viz/style.css b/viz/style.css index f9d178e..c8864e6 100644 --- a/viz/style.css +++ b/viz/style.css @@ -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; }