Skip to main content

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​

Recap​

  • The silent-zero bug is a failed query rendered as a confident number — ?? 0 plus a chart that draws nothing looks identical to real data, and the old page shipped it.
  • AnalyticsPanel owns 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.
  • isError and isEmpty are 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 — useCountUpFloat routes every update through requestAnimationFrame, 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.

Next: 17 — The chart wrappers, and the Recharts scars.