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(null); const plotRef = useRef(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
; }