Our numerical reasoning practice shows you source material (a table, a bar
chart, a line graph) and asks you questions about it. The content that drives
those charts carries a unit alongside each value, because "4200" on its own
means nothing and "4200 subscribers" does.
For a while, several of our charts rendered that as:
4200subscribers
Which is the single most recognisable bug in the genre. Here is the code that
produced it, and if you have written a dashboard you have written this line:
<span className="font-semibold">
{hasValue ? `${value}${unit}` : '-'}
</span>
Template literal, value, unit. Correct for 45 and %. Wrong for 4200 and
subscribers. And the fix is not "add a space", because then you get:
4200 %
£ 286000
Worse, the currency case is not a spacing problem at all. A pound symbol is a
prefix, so gluing it on in either direction gives you 286000£, which is not
how anyone writes money in English.
Three shapes, one function
The unit strings in our content fall into three groups, and they want three
different treatments:
| shape | examples | rendering |
|---|---|---|
| currency |
£, $, £m
|
symbol leads, remainder trails |
| symbol or one letter |
%, k, % per year
|
attach directly |
| word or multi-letter |
hours, MWh, thousand households
|
space between |
So the concatenation became a function. All 32 lines of it, docstring included:
/**
* Join a data value to the unit a question bank gives for it.
*
* Bank units come in three shapes, and gluing them straight on reads wrong for
* two of them: "4200subscribers", "£4200" rendered as "4200£".
*/
export function formatWithUnit(value: number | string, unit = ''): string {
if (!unit) return String(value);
if (/^[£$€]/.test(unit)) {
const symbol = unit[0];
const rest = unit.slice(1);
const n = typeof value === 'number' ? value : Number(value);
// "-£5", not "£-5".
return Number.isFinite(n) && n < 0
? `-${symbol}${Math.abs(n)}${rest}`
: `${symbol}${value}${rest}`;
}
if (unit.startsWith('%') || unit.startsWith(' ') || unit.length === 1) {
return `${value}${unit}`;
}
return `${value} ${unit}`;
}
Some notes on the decisions in there, because every branch is a judgement call
someone will want to argue with.
Currency is split, not matched whole. The unit £m is a symbol plus a
magnitude, and they go on opposite sides of the number: £12m. Taking
unit[0] as the symbol and unit.slice(1) as the remainder handles £, $m
and €bn with one branch.
Negative numbers move the sign outside the symbol. £-5 is the kind of
output that makes a reader stop and squint at a chart, which in a timed practice
test is a cost I am imposing on them for nothing. Three lines to produce -£5.
% per year attaches, per year would not. The rule is "starts with %",
not "is %", because a percentage of something still wants the sign tight
against the number: 2.5% per year.
length === 1 catches k and m without a list. Any one-character unit
attaches directly. It is a heuristic and I am comfortable with it: there is no
single-letter unit in English that wants a space.
A unit that already begins with a space is left exactly as written. This is
the escape hatch. If a piece of content needs spacing this function would not
give it, the content can say so and the function will not second-guess it.
The test file is all cases, which is the right shape for a pure function like
this:
expect(formatWithUnit(4200, 'subscribers')).toBe('4200 subscribers');
expect(formatWithUnit(1840, 'MWh')).toBe('1840 MWh');
expect(formatWithUnit(2.5, '% per year')).toBe('2.5% per year');
expect(formatWithUnit(12, '£m')).toBe('£12m');
expect(formatWithUnit(-5, '$')).toBe('-$5');
expect(formatWithUnit(7, ' units')).toBe('7 units');
expect(formatWithUnit('n/a', '')).toBe('n/a');
That last one is why the signature takes number | string. A value is not
always a number; sometimes a table cell says n/a, and Number('n/a') is NaN,
which is how you end up shipping a chart that reads "NaN%".
Two components were doing their own concatenation across five call sites, and
both now call this instead. The interesting part was not writing the function,
it was that the same mistake had been written twice independently, because
${value}${unit} looks so obviously right that nobody reviews it.
The same bug wearing a different hat
In the same commit, this:
const SHAPE_COLORS: Record<KFLogicalShape['color'], string> = {
red: '#ef4444',
blue: '#3b82f6',
green: '#22c55e',
yellow: '#eab308',
black: '#1f2937',
};
Four of those are colours. The fifth is a role. Red means red, but "black"
means ink, and #1f2937 is a very dark grey that was effectively invisible
against the dark card those shapes are drawn on. In dark mode, the item that
the content called black was the one you could not see.
// "Black" is the ink colour, so it follows the theme's foreground: a fixed
// #1f2937 vanished into the dark card the frames sit on.
black: 'currentColor',
With className="text-foreground" on the SVG, so currentColor resolves to
whatever the theme says text is. One word in a lookup table, and the shape is
dark on light and light on dark.
The thing both bugs have in common
Our content banks store descriptive strings: a unit, a colour name, a label.
Both of these bugs came from a renderer taking one of those strings literally.
unit was treated as a suffix because it usually is. black was treated as a
hex value because it usually is. In both cases the right move is for the
renderer to interpret the string as a declaration of intent rather than as
output.
That is an easy sentence to write after the fact and an annoying one to act on
in advance. The practical version: when a data field's name is a noun from the
problem domain rather than from the view layer, do not pipe it into the view
unexamined.
Both fixes are on live pages if you want to look at them. The charts and tables
that carry these units are what
numerical reasoning practice
is made of, the tabbed source documents are listed on
Aon's practice page, and the abstract shape
sequences whose "black" is now ink are the Elements Logical test on
Korn Ferry's. Each page says which of
its tests are free.
Top comments (0)