dedupe j()/errText(), stale-fetch guard, empty bar-picker state Six small, mostly-unrelated items from the #47 review round (#57): 1. 'Statistik zurücksetzen' now checks res.ok and shows the server's error instead of silently re-rendering the un-reset data on a 401/500 with no indication anything went wrong. 2. DayChart now tracks its container width via ResizeObserver and calls uPlot's setSize() on change — previously read once at plot-creation time, so a resize/orientation-change (more visible on the phone/ tablet path into /stats) left the chart the wrong width. 3. .qty-btn (cart +/- buttons) grown from 28x28px to 44x44px, the conventional minimum touch target — was the one conspicuously small interactive control in the money path, at a fast-moving bar counter. 4. Tablet's bar-config fetch (App.tsx) now guards against an out-of-order resolution if barId changes again while a previous api.config() call is still in flight. Currently unreachable (nothing triggers a second barId change mid-fetch today) but closes the gap before the next 'add a cancel button to the loading screen' change could make it live. 5. j()/errText() were duplicated byte-for-byte between Admin.tsx and Stats.tsx (Admin.tsx's own comment noted this exact duplication had already caused drift once before) — centralized in api.ts, both files now import instead of redeclaring. 6. Tablet's bar picker now shows an explicit empty-state message when zero bars exist (fresh install, or every bar deleted), previously indistinguishable from 'still loading'. Closes #57. Verified: tsc --noEmit and vite build both clean.
81 lines
3.5 KiB
TypeScript
81 lines
3.5 KiB
TypeScript
import { useEffect, useRef } from 'preact/hooks';
|
|
import uPlot from 'uplot';
|
|
import 'uplot/dist/uPlot.min.css';
|
|
|
|
// A handful of distinguishable line colors, theme-independent (uPlot draws
|
|
// on canvas, so it can't pick up CSS custom properties the way the rest of
|
|
// the app's chrome does) — enough for the "top 5 drinks" case, cycles if a
|
|
// caller ever passes more series.
|
|
const COLORS = ['#7aa2f7', '#f7768e', '#9ece6a', '#e0af68', '#bb9af7'];
|
|
|
|
export interface ChartSeries { name: string; values: number[] }
|
|
|
|
// Thin uPlot wrapper: x-axis is just sequential day labels (business days
|
|
// with zero sales are already dropped server-side, so evenly-spaced ticks
|
|
// read better than gapped real dates would) rather than real timestamps —
|
|
// avoids re-deriving a Date from the server's businessDay() string, which
|
|
// would need the same DST-aware timezone handling admin.ts already
|
|
// centralizes rather than a second copy in the browser.
|
|
export function DayChart({ labels, series }: { labels: string[]; series: ChartSeries[] }) {
|
|
const holderRef = useRef<HTMLDivElement>(null);
|
|
const plotRef = useRef<uPlot | null>(null);
|
|
|
|
useEffect(() => {
|
|
const holder = holderRef.current;
|
|
if (!holder) return;
|
|
|
|
const xs = labels.map((_, i) => i);
|
|
const data: uPlot.AlignedData = [xs, ...series.map(s => s.values)];
|
|
|
|
const opts: uPlot.Options = {
|
|
width: holder.clientWidth || 600,
|
|
height: 220,
|
|
legend: { show: series.length > 1 },
|
|
cursor: { drag: { x: false, y: false } },
|
|
// distr: 2 (ordinal) — the default (1, linear) lets uPlot's tick
|
|
// generator pick fractional increments on a short/sparse series,
|
|
// and the label lookup below (`labels[v]`) misses on a non-integer
|
|
// v, rendering a blank tick. Ordinal forces whole-number splits,
|
|
// which is what an index-based x-axis always wants anyway (#55).
|
|
scales: { x: { time: false, distr: 2 } },
|
|
axes: [
|
|
{ stroke: '#888', grid: { stroke: '#333' }, values: (_u, vals) => vals.map(v => labels[v] ?? '') },
|
|
{ stroke: '#888', grid: { stroke: '#333' } },
|
|
],
|
|
series: [
|
|
{},
|
|
...series.map((s, i) => ({
|
|
label: s.name,
|
|
stroke: COLORS[i % COLORS.length],
|
|
width: 2,
|
|
points: { show: labels.length <= 14 },
|
|
})),
|
|
],
|
|
};
|
|
|
|
plotRef.current = new uPlot(opts, data, holder);
|
|
// Re-create on every data change rather than plot.setData: these charts
|
|
// redraw once per page load / manual refresh, not on a hot path, so
|
|
// the simplicity of "just rebuild it" outweighs the incremental-update
|
|
// complexity setData would otherwise save.
|
|
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
|
}, [labels, series]);
|
|
|
|
// Width was only ever read once at plot-creation time — no resize/
|
|
// orientation-change handling, minor on desktop but more visible on the
|
|
// phone/tablet path into /stats. setSize() is uPlot's cheap resize path
|
|
// (no destroy/rebuild), separate from the effect above since it only
|
|
// needs to run once for the holder's lifetime, not on every data change.
|
|
useEffect(() => {
|
|
const holder = holderRef.current;
|
|
if (!holder || typeof ResizeObserver === 'undefined') return;
|
|
const ro = new ResizeObserver(entries => {
|
|
const width = entries[0]?.contentRect.width;
|
|
if (width && plotRef.current) plotRef.current.setSize({ width, height: 220 });
|
|
});
|
|
ro.observe(holder);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
return <div ref={holderRef} class="chart" />;
|
|
}
|