Refinance a 200,000 mortgage. Current rate 7%, 300 months left. New rate: 7%. New term: 25 years. Closing costs: 4,000.
That is not a refinance. It is the loan you already have, plus a bill for four thousand.
My calculator, in green:
Save $0.00/mo
The number is right. The word is not.
One ternary, three answers
tag: monthlySave >= 0
? { text: 'Save ' + fmt(monthlySave, 2) + '/mo', level: 'good' }
: { text: fmt(-monthlySave, 2) + '/mo more', level: 'bad' }
Enter fullscreen mode Exit fullscreen mode
“Did it go up or down” has three answers. A ternary has two branches. >= is where the third one goes to die — it does not disappear, it gets quietly filed under the first.
I went looking for the pattern and it was not one calculator, it was the house style. Every comparison tag on the site was written the same way, and every one of them was wrong at exactly one point:
calculator at break-even, it printed and coloured it ROIProfit 0.00
green
percentage change
+0.00% · Increase
green
CAGR
Growth
green
net worth
Positive
green
capital gains tax
Gain 0.00
green
real rate of return
Beats inflation
green
refinance
Save 0.00/mo
green
debt consolidation
0.00/mo more
amber
Seven of the eight called nothing a win. The eighth is more interesting, and I will come back to it.
None of these are floating-point curiosities. Every input in that table is something a person types on purpose: buy at 10,000 and sell at 10,000. A salary that did not move. A 3% return in a 3% inflation year. Assets that exactly cover the debts — a number people work towards.
“Beats inflation” is the one that bothers me
Most of these are a wrong word next to a right number, and the reader can see the 0.00 and correct for it.
Beats inflation has no number next to it. There is a percentage in the headline, but the tag is the part that renders as a green pill and the part a reader takes away. If your return exactly matches inflation you have made precisely nothing in real terms, and the tool congratulated you.
That is the difference between a cosmetic bug and a wrong answer: whether the reader has enough on screen to notice.
The eighth one, which was worse than wrong
Debt consolidation compares a payment the user types against one it computes. Feed it a debt of 30,000 at 8% over 5 years, and it works out 608.29/mo. Type 608.29 back in — the number it just showed you — and:
0.00/mo more
The other branch. Not because the maths differs, but because the computed value is 608.2857…, and 608.29 minus that is a hair below zero.
So at break-even this calculator did not have a wrong answer, it had no stable answer. The same two numbers on screen could be labelled a saving or a cost depending on where the float landed. And nothing about the display would tell you which side you got, because both branches print 0.00.
Rounding before comparing is the actual fix
function verdict(x, dp, up, flat, down) {
var r = Number(Number(x).toFixed(dp));
if (r > 0) { return { text: up, level: 'good' }; }
if (r < 0) { return { text: down, level: 'bad' }; }
return { text: flat, level: 'neutral' };
}
Enter fullscreen mode Exit fullscreen mode
Three branches is the obvious half. The toFixed is the half I nearly left out, and it is the one that actually fixes debt consolidation.
The label has to be computed from the number the reader can see, not from the number you have. If you print to the cent, compare to the cent. Otherwise you are asserting a difference that exists only in a register, in a pill sitting directly beside the digits that deny it.
It also makes the boundary honest in the other direction:
newRate 6.999961 -> Same payment — the costs buy nothing [neutral]
newRate 6.9999 -> Save 0.01/mo [good]
Enter fullscreen mode Exit fullscreen mode
A saving of four-tenths of a cent a month is not a saving. It prints as 0.00. Now it is labelled as what it prints as.
The third colour
There were three pill styles: green, amber, red. Nothing for “neither”, so >= had somewhere convenient to hide.
Amber is not the answer. Amber means pay attention, this might be a problem, and break-even is not a problem. It is a null result, and null results deserve to look like null results:
.result-tag.neutral{ background:var(--ink-soft); color:#fff; }
Enter fullscreen mode Exit fullscreen mode
Grey says “neither” without saying “warning”. The missing branch and the missing colour were the same omission — I had built a vocabulary with only two words in it and then had to say something with it.
Testing the boundary and both sides of it
const FLAT = [
['roi', { initial: '5000', final: '5000' }, 'Broke even'],
['percentageChange', { from: '50', to: '50' }, 'No change'],
['realReturn', { nominal: '3', inflation: '3' }, 'Exactly matches inflation'],
['refinance', { balance: '200000', currentRate: '7', currentMonths: '300',
newRate: '7', newYears: '25', costs: '4000' }, 'Same payment — the costs buy nothing'],
...
];
for (const [engine, v, want] of FLAT) {
check(engine + ': says "' + want + '"', tag(E[engine](v)), want);
check(engine + ': is not coloured as good news', level(E[engine](v)), 'neutral');
}
Enter fullscreen mode Exit fullscreen mode
Two assertions per case, because they fail for different reasons: someone can fix the word and leave the pill green, and the pill is the part people see first.
Then the same number of assertions on either side of the boundary. A three-way fix is one > away from breaking the direction it used to get right, and a test suite that only checks the newly-fixed case will not notice.
What I would take from it
>= in a place where equality means something different from “greater” is a bug you write with your fingers, not your head. It reads as a rounding decision — do I include the endpoint — and it is not. It is a decision about whether a third outcome exists.
The tell is the naming. When the two branches are good and bad, ask what the boundary between them is called. If it has a name — break-even, no change, flat, matches, unchanged, exactly covers — then it is a case, and you have not written it.
I build Utilorax, a set of free browser-based tools. This came out of the percentage change calculator, which no longer tells you that nothing is an increase.