16 — Panels, tiles, and a hand-rolled sparkline
Read this first: you will open the four smallest files in frontend/src/components/analytics/
and discover that the most important one contains no chart at all — it is a card that refuses to
lie. Along the way you find a count-up hook shaped by a lint rule, a sparkline that is deliberately
not Recharts, and one tooltip that every chart on the page shares.
Time: about 25 minutes. Assumes lesson 15.
The bug this folder exists to kill
Every number on the analytics page arrives by query, and queries fail. The interesting question is what the page shows while they fail.
Predict: a KPI tile reads its value straight off the query result — something like
summary?.workforce.activeEmployees ?? 0 — and the query 500s. What does an executive looking at
the dashboard see? Write your answer down before reading on.
The answer: a zero that reads as data. summary is undefined, ?? 0 kicks in, and the tile
confidently renders "0" in 3xl bold — animated, even, because the count-up hook happily animates to
zero. Nothing looks broken. Headcount looks like it collapsed. The old analytics page shipped
exactly this, and the fix is quoted in the very first doc comment you will read:
/**
* The card every chart sits in. Owns the loading / error / empty states in one
* place so no chart hand-rolls a ternary — a failed query renders an honest error
* rather than an empty-looking chart (the old page's silent-zero bug).
*/
export const AnalyticsPanel = ({
That is the silent-zero bug, named in the source. The rest of this lesson is how the folder makes it structurally hard to reintroduce.
One card owns the three states
AnalyticsPanel is 70-odd lines and most of them are the part chart authors always forget:
{isLoading ? (
<Skeleton h={minH} borderRadius="lg" />
) : isError ? (
<StateBlock icon={<LuTriangleAlert />} label="Couldn't load this data. Try refreshing." minH={minH} />
) : isEmpty ? (
<StateBlock icon={<LuChartColumnBig />} label={emptyLabel} minH={minH} />
) : (
children
)}
The rule: the chart author never writes a loading, error, or empty state. They pass three
booleans and the panel does the rest — which means the states cannot be forgotten, and every panel
on the page fails the same way. Note that isError and isEmpty are separate props on purpose:
isLoading?: boolean;
isError?: boolean;
/** True when the query succeeded but returned nothing to plot. */
isEmpty?: boolean;
emptyLabel?: string;
/** Min body height so loading/empty states don't collapse the card. */
minH?: number | string;
"Couldn't load this data" and "No data for this selection." are different facts. The first says
try refreshing; the second says your filter excluded everything — a legitimate answer, not a
failure. Collapsing them is a milder cousin of the silent zero. And minH exists so a loading card
is the same height as a loaded one: without it, panels reflow as queries resolve and the grid
jumps.
Here is a real call site, from the overview tab:
<AnalyticsPanel
title="Department landscape"
subtitle={/* … */}
isLoading={dLoading} isError={dError} isEmpty={byDept.length === 0} minH={340}
delay={100}
actions={/* … a 3D/2D view toggle … */}
>
<AnalyticsScene /* … */ />
</AnalyticsPanel>
Three booleans wired straight off the query, actions for the top-right toggle, and the child is
just the happy path. delay staggers the fadeSlideUp entrance so cards cascade in instead of
popping at once — the same trick the KPI tiles use.
The number that counts up
KpiTile is the headline metric card, and its own doc comment states its ambition plainly: it is
"the single biggest "reads like BI" upgrade over a flat stat card." The animated number comes
from a hook whose first line explains why it exists at all:
/** Animates a float (useCountUp rounds to ints — no good for %/decimals). */
const useCountUpFloat = (target: number, duration = 900) => {
const [value, setValue] = useState(0);
const raf = useRef<number | null>(null);
useEffect(() => {
// rAF from the first frame (never a synchronous setState in the effect body);
// a target of 0 simply animates the current value down to 0.
const start = performance.now();
const run = (now: number) => {
const t = Math.min((now - start) / duration, 1);
setValue((1 - Math.pow(1 - t, 4)) * target);
if (t < 1) raf.current = requestAnimationFrame(run);
};
raf.current = requestAnimationFrame(run);
return () => { if (raf.current !== null) cancelAnimationFrame(raf.current); };
}, [target, duration]);
return value;
};
Read the inline comment twice — it is a design decision with a written justification. The obvious
shortcut is if (target === 0) setValue(0) at the top of the effect, but a synchronous setState
in an effect body triggers the react-hooks lint rule (v7 flags it as a forced second render
pass). The hook's answer: don't special-case zero at all. Every update goes through
requestAnimationFrame, so the first state change lands on the next frame, and "a target of 0
simply animates the current value down to 0." The easing 1 - Math.pow(1 - t, 4) is a quartic
ease-out — fast start, gentle landing, which is why the numbers feel like they arrive rather than
tick.
The displayed number sets fontVariantNumeric="tabular-nums" so digits are equal-width and the
value does not wobble sideways while animating. The icon chip next to it derives both its colors
from the tile's accent:
<Box p={2} borderRadius="lg" flexShrink={0} style={{ backgroundColor: tint(hue, isDark ? 0.22 : 0.12), color: hue }}>
tint is six lines of bit-shifting that turn a hex accent into an rgba wash — stronger in dark
mode because a 12% wash disappears on gray.800. One accent prop, two derived colors: the tile
stays on the categorical palette from lesson 15 without anyone picking
a second hue by hand.
When down is good
The delta chip compares against the previous period, and its props already know that not every increase is a win:
/** Signed change vs the comparison period; drives the delta chip. */
delta?: number | null;
deltaLabel?: string;
/** True when a rise is bad (exits, absences) so the chip tone flips. */
invertDelta?: boolean;
const hasDelta = delta != null && delta !== 0;
const isGood = hasDelta && (invertDelta ? delta < 0 : delta > 0);
const deltaTone = !hasDelta ? colors.textMuted : isGood ? colors.status.good : colors.status.critical;
const rising = (delta ?? 0) > 0;
Two separate questions, two separate variables: rising picks the arrow direction (up is up,
always), isGood picks the color. An absence rate that falls shows a down arrow in good green.
Conflating the two — coloring by sign — is how dashboards end up celebrating rising attrition.
The last prop turns the tile from a display into a control: onClick makes it a drill-down
trigger, and the styling advertises it —
cursor={onClick ? 'pointer' : undefined}
transition="border-color 0.15s, box-shadow 0.15s, transform 0.15s"
_hover={onClick ? { borderColor: 'brand.400', boxShadow: 'md', transform: 'translateY(-2px)' } : undefined}
No onClick, no pointer, no hover lift — a tile that does nothing must not promise something. The
overview tab wires it to openDrill({ … }), which is lesson 18's
story.
Ninety-six pixels do not need a charting library
The bottom-right of each tile holds a trend glyph, and its doc comment is a cost argument:
/**
* Inline-SVG trend line for KPI tiles. Hand-drawn rather than a Recharts instance
* per tile — a dozen ResponsiveContainers would be far heavier for a 96px glyph.
*/
Predict: the overview row renders four always-on tiles, and the count-up hook re-renders each
tile every animation frame for 900ms. What would it cost if each sparkline were a Recharts
<ResponsiveContainer>? Answer before reading on.
The resolution: each ResponsiveContainer carries a resize observer and a full chart component
tree, and every count-up frame would reconcile that tree — roughly fifty times per tile per
mount, four tiles at once, for a 96-by-32 glyph that never resizes. The hand-rolled version is
three SVG elements and some arithmetic:
const min = Math.min(...data);
const max = Math.max(...data);
const span = max - min || 1;
const pad = 3; // keep stroke + end dot inside the viewbox
const stepX = width / (data.length - 1);
const yOf = (v: number) => height - pad - ((v - min) / span) * (height - pad * 2);
const points = data.map((v, i) => [i * stepX, yOf(v)] as const);
const line = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
const area = `${line} L${width},${height} L0,${height} Z`;
span = max - min || 1 guards a flat series against dividing by zero; pad exists for the reason
its comment gives — a 1.5px stroke and a 2px end dot would otherwise clip at the viewbox edge. The
render is the line path, the same path closed into a gradient-filled area, and a dot on the last
point so the eye lands on now. The gradient id comes from useId() because SVG url(#…)
references resolve document-wide — four tiles with a shared hard-coded id would all paint the first
tile's gradient. And the whole SVG is role="presentation" aria-hidden: the glyph is decoration;
the animated number above it is the datum.
One tooltip for every chart
The last file is the consistency play. Every Recharts chart on the page — line, bars, donut — hands hover rendering to the same component:
/**
* The one tooltip every chart on the page uses, so no chart restyles hover inline.
* Pass to Recharts as `content={<VizTooltip formatValue={...} />}`. Reads the swatch
* color from each payload entry so identity is carried by the same hue as the mark.
*/
export const VizTooltip = ({ active, payload, label, formatValue }: VizTooltipProps) => {
if (!active || !payload || payload.length === 0) return null;
Recharts injects active, payload, and label; the page supplies only formatValue, because
"12,340" means nothing until it says pesos, hours, or percent. Each row's swatch is
bg={entry.color} — the tooltip never picks colors, it repeats whatever hue the mark already used,
so the swatch cannot drift from the series. Default Recharts tooltips styled per-chart is how a
page ends up with five slightly different hover cards; one component makes that impossible in the
same way AnalyticsPanel makes forgotten error states impossible. The chart wrappers that pass it
in are lesson 17.
Where this shows up in MotorPH
- frontend/src/components/analytics/AnalyticsPanel.tsx — the card with the silent-zero comment
- frontend/src/components/analytics/KpiTile.tsx — count-up, tint, delta chip, drill-down trigger
- frontend/src/components/analytics/Sparkline.tsx — the hand-rolled SVG glyph
- frontend/src/components/analytics/VizTooltip.tsx — the one tooltip
- frontend/src/pages/hr/hr-analytics/OverviewTab.tsx — four tiles and the department-landscape panel wired to live queries
- frontend/src/components/analytics/charts/TrendLine.tsx — a wrapper passing
VizTooltipascontent(next lesson) - the Frontend reference — the reference section, when you need option lists rather than story
Recap
- The silent-zero bug is a failed query rendered as a confident number —
?? 0plus a chart that draws nothing looks identical to real data, and the old page shipped it. AnalyticsPanelowns loading, error, and empty in one place, so a chart author physically cannot forget a state — they pass three booleans and write only the happy path.isErrorandisEmptyare different facts — "try refreshing" versus "your filter excluded everything" — and collapsing them is the silent zero's milder cousin.- Never set state synchronously in an effect body —
useCountUpFloatroutes every update throughrequestAnimationFrame, satisfying the lint rule and animating a genuine 0 honestly. - Hand-roll the 96px glyph, delegate the real charts — a Recharts instance per tile means a resize observer and a full tree reconciled every count-up frame; three SVG paths cost nothing.