Every number a user of Nakodo sees goes through one file: src/lib/format.ts, 77 lines,
eleven exports, imported by 38 other files. It is the least interesting file in the repository and it decides how
the whole app reads, so I spent an hour measuring what it actually does rather than what I remember writing.
Five findings, in order of how much they annoy me.
One export has no callers, and 29 places wrote its body out instead
export function formatNumber(n: number | null | undefined): string {
return n == null ? "n/a" : n.toLocaleString("en-US");
}
$ grep -rn "formatNumber(" src | grep -v lib/format.ts | wc -l
0
$ grep -rn 'toLocaleString("en-US")' src | wc -l
29
Nothing calls it. Meanwhile toLocaleString("en-US") is typed out 29 times in 12 files, including this, three
lines from the top of src/lib/plans.ts, in a file that already imports from format.ts:
const n = (v: number) => v.toLocaleString("en-US");
That is not a disaster, because toLocaleString is a one-liner either way. It is a signal, though: the shared
helper is harder to reach for than the thing it wraps, which means the file is not pulling its weight as an
interface. The null handling is the only part worth sharing, and the 29 call sites are all places where the value
cannot be null, so each of them was right to skip it.
Four words for an absent value
Read the whole file for its return values when the input is missing and you get a vocabulary nobody designed:
| Function | Missing input gives |
|---|---|
formatPercent, formatNumber, formatDate
|
"n/a" |
timeAgo |
"never" |
countryName |
"Not set" |
languageName |
"Unknown" |
timeUntil (a time in the past) |
"a moment" |
Three of those are defensible in context. "Never" for a null timestamp reads better than "n/a" in "Last emailed:
never", and "Not set" is right for a country the user has not chosen. But countryName and languageName sit
next to each other, take the same kind of input, and disagree. And the disagreement is worse than the wording,
because the two do different things with a code that is syntactically fine and semantically wrong:
countryName("ZZ") // "Unknown Region" <- Intl's words, not ours
languageName("zz") // "zz" <- the input, unchanged
countryName("gb") // "gb" <- lower case does not resolve
Intl.DisplayNames has opinions and they are not our opinions. of() returns the code itself for something it
cannot name, which our ?? code fallback then passes through, except for a reserved region like ZZ where it
cheerfully invents "Unknown Region" in the user's language. Three outcomes for three kinds of bad input, in two
functions that look identical.
formatCount skips a unit
export function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
The intent is a decimal below ten of a unit and none above it: 1.5K, 47K, 2.3M, 18M. The rounding is done
by toFixed after the division, and the branch is chosen before it, which is where it goes wrong:
formatCount(999_499) // "999K"
formatCount(999_500) // "1000K" <- should be 1.0M
formatCount(999_999) // "1000K"
formatCount(9_999) // "10.0K"
formatCount(10_000) // "10K"
Two bugs for the price of one boundary. A count between 999,500 and 999,999 renders as 1000K, because the value
is under a million when the branch is picked and over it after rounding. And a count ticking from 9,999 to 10,000
changes its own format from 10.0K to 10K, so a number on screen can appear to lose precision while growing.
The fix is to round first and then decide, which is three lines and no new behaviour. Note what made this visible
at all: it is a pure function of one number, so I could print the whole boundary in a loop. There is no test file
for it, which is the actual gap.
Two date conventions meeting on one screen
export function formatDate(date: Date | string | null | undefined): string {
if (!date) return "n/a";
return new Date(date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
// "2 Oct", for dates within the next year or so.
export function formatDay(date: Date): string {
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
}
One file, two locales, ten call sites for the first and twelve for the second. en-US gives
Oct 28, 2026; en-GB gives 28 Oct. Both are hardcoded on purpose: a locale read from the browser would
format differently on the server and in the client and React would complain about the mismatch, so pinning it is
right. Pinning two of them is a decision I never made, it is just what each function needed when it was written.
They meet. On a creator's page, the sighting notes use the short form and the chart under them uses the long one:
const shortDate = (d: Date | string) => formatDay(new Date(d));
// ...
<Note label="Sighted" date={shortDate(cc.createdAt)} /> // "28 Oct"
// ...and, in the same card, the chart's points:
.map((v) => ({ title: v.title, views: v.viewCount ?? 0, date: formatDate(v.publishedAt) })) // "Oct 28, 2026"
Day-month next to month-day-year is not a bug a user reports, it is the kind of thing that makes an interface feel
like it was assembled rather than designed. The repair is one locale for the file and a short/long option.
timeAgo has a 47-hour day
timeAgo("2026-10-26T13:00:00Z", now) // "47 h ago"
timeAgo("2026-10-26T12:00:00Z", now) // "2 days ago"
timeAgo("2026-08-30T12:00:00Z", now) // "59 days ago"
The ladder is just now under a minute, minutes under an hour, hours under 48, days under 60, months after that.
The 48 is deliberate (yesterday evening reads better as "19 h ago" than "1 day ago") and 59 days is as far as
"days" goes, so there is a week where the answer is a two-digit number of days that nobody can picture. I would
narrow that to 30 if I were starting again, and the only reason not to change it now is that it is honest.
Every one of these takes the clock as an argument, timeAgo(date, now = Date.now()), which is the whole reason I
could print those tables. It is also why timeAgo on a server-rendered page is the server's idea of now, and the
one place where that would mislead has a client component to replace it after hydration.
The one formatter the public site uses
Thirty-eight files import this module and not one of them renders a public page. There is one indirect
exception: src/lib/plans.ts builds the plan limit lines with plural, and the pricing page and the llms.txt
generator both read those lines.
export const plural = (n: number, one: string, many = `${one}s`) =>
`${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
So the only formatter a stranger can see the output of is this one, and you can read it in two places:
$ curl -s https://nakodo.app/pricing | grep -o '[0-9,]* AI email drafts a day' | sort -u
20 AI email drafts a day
250 AI email drafts a day
75 AI email drafts a day
$ curl -s https://nakodo.app/llms.txt | grep -o 'Pro, £39 a month: [^;]*'
Pro, £39 a month: 25 new creators or businesses each weekday (550 a month)
The default many is one + "s", which is wrong often enough that two call sites already pass the plural by
hand: plural(n, "search", "searches") and plural(n, "creator has", "creators have"). A third pair comes from the
vocabulary module, which hands plural an explicit one and many for a business, because "business" plus "s"
is not a word. Forty-seven calls, three of which had to
work around the default, and the default stays because the other forty-four are nouns that take an s.
That is the file. Nothing in it is clever, and between them these eleven functions decide whether the app reads
like it was built by one person or three. The measurable version of that sentence: one dead export, 29 copies of
it, two locales, one rounding boundary that skips a unit, and four words for nothing. The plan lines are on
nakodo.app/pricing if you want to see the one that made it out in public.
Top comments (0)