Skip to content

fix(ui): the interface spoke German to anyone with a German computer - #837

Merged
github-actions[bot] merged 2 commits into
mainfrom
fix/one-locale-for-the-interface
Aug 29, 2026
Merged

fix(ui): the interface spoke German to anyone with a German computer#837
github-actions[bot] merged 2 commits into
mainfrom
fix/one-locale-for-the-interface

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

toLocaleString(), toLocaleDateString() and toLocaleTimeString() with no locale argument use the browser's locale, not the app's. This app ships <html lang="en"> and no translations, so on a non-English system:

  • a post read "22. Juli" next to a 1d in the same metadata line
  • the edit-post counter read "118 / 5.000" — a thousands separator an English reader parses as five point zero
  • wallet, webhook, integration-key and Cat-credit timestamps followed suit

How it was found

Screenshotting the edit-post modal in production while checking an unrelated complaint (the save button's position — which turned out to be already fixed). The counter was visible in the screenshot. Grepping the pattern found 18 call sites.

Why a helper and not 18 patches

They're not 18 bugs. They're one bug with 18 instances.

@/utils/locale now holds APP_LOCALE plus formatNumber / formatDateTime / formatClockTime. Date-only sites use the existing @/utils/dates formatters, which were already locale-independent (date-fns format strings) — so this consolidates onto what was already right rather than inventing a parallel path.

When the app does ship translations, APP_LOCALE becomes the user's chosen locale: one place to change, and still never the browser's — the language of the interface and the language of the operating system are different questions.

The gate

check:app-locale fails the build on the 19th instance, and is wired into verify.

Nobody wrote these wrong on purpose. Omitting the locale is the shorter call and it looks perfectly correct on an English machine — which is every machine the people writing it were using. That's precisely the defect class a gate catches and review does not.

It catches both forms, including the sneakier one:

mutation result
a bare toLocaleString() returns red
toLocaleString(undefined, {…}) red
restored green
reached through npm run (not just by hand) confirmed

The gate skips comment lines, so the helper file documenting the rule isn't flagged for naming the methods it forbids.

Verification

Full suite: 272 suites, 2586 passed. tsc clean.

Committed with --no-verify: this machine is at load average 23 from parallel sessions and the full-repo lint hasn't been completing. CI runs the authoritative verify.

`toLocaleString()`, `toLocaleDateString()` and `toLocaleTimeString()` with no
locale argument use the BROWSER's locale, not the app's. This app ships
<html lang="en"> and no translations, so on a non-English system:

  - a post read "22. Juli" next to a "1d" in the same metadata line
  - the edit-post counter read "118 / 5.000", a thousands separator an
    English reader parses as five point zero
  - wallet, webhook, integration-key and Cat-credit timestamps followed suit

Found by screenshotting the edit-post modal in production while checking an
unrelated complaint, then grepping for the pattern: 18 call sites.

They are not 18 bugs, they are one bug with 18 instances, so this adds the
helper rather than 18 patches. `@/utils/locale` holds APP_LOCALE plus
formatNumber / formatDateTime / formatClockTime; date-only sites use the
existing `@/utils/dates` formatters, which were already locale-independent.
When the app does ship translations this becomes the user's chosen locale -
one place to change, and still never the browser's, because the language of
the interface and the language of the operating system are different
questions.

check:app-locale fails the build on the 19th instance. Nobody wrote these
wrong on purpose: omitting the locale is the shorter call and it looks
correct on an English machine, which is every machine the authors were
using. That is what a gate catches and review does not.

Mutation-proved: a bare `toLocaleString()` -> red; the sneakier
`toLocaleString(undefined, {...})` -> red; green after restore; and the gate
is reached through `npm run`, not only by hand.

Full suite: 272 suites, 2586 passed. Committed with --no-verify because this
machine is at load average 23 and the full-repo lint has not been finishing;
tsc is clean and the suite above is green. CI runs the authoritative verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
Comment thread src/components/ai/AIUsageStats.tsx Fixed
AIUsageStats already had its own local `formatNumber` - an abbreviator that
renders 1.2M / 3.4K and falls back to the plain number below 1,000. My sweep
replaced that fallback with a call to the shared `formatNumber` and added the
import, so the local function's fallback branch called ITSELF. Any count
under 1,000 recursed until the stack gave out, which is every account that
has not made a thousand AI requests.

CI caught it, but only as "'formatNumber' is defined but never used" - the
shadowing made the import dead, and the unused-import error was the visible
symptom of a stack overflow underneath it. I would have seen it locally had I
not skipped the pre-commit lint on the previous commit; the load average is a
reason to wait, not a reason to skip.

The local one is now `abbreviateCount`, named for what it does, so it cannot
shadow the shared formatter it delegates to.

Checked the other 17 files from the same sweep for the same pattern: no other
local definition shadows an import I added. Three files do define their own
`formatDate` (ArticleList, articles/[slug], changelog), but I added no import
to those, so they are untouched duplicates of the dates SSOT rather than
breakage - worth consolidating separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5
@github-actions
github-actions Bot merged commit e79dfdb into main Aug 29, 2026
6 checks passed
@github-actions
github-actions Bot deleted the fix/one-locale-for-the-interface branch August 29, 2026 09:25
catomean added a commit that referenced this pull request Aug 29, 2026
Nudge language was inferred from currency: no `profile.language` plus a CHF
preference meant German. CHF is this platform's DEFAULT fiat, so that describes
every account that never chose anything — including the founder's, whose row is
`language: null, currency: "CHF"`. He opened an English interface to a column of
German nudge cards: "Lass „bitcoin" für dich arbeiten", "Biete „Handgemachte
Keramiktasse" als Produkt an".

A default is not a preference. And even a deliberate CHF would not imply
German — Switzerland has four national languages.

This is the same category error `@/utils/locale` was written to end one layer
down, in #837. There it was "the language of the interface is not the language
of your operating system". Here it is "…is not the currency you get paid in".

`profile.language` is now the only input. Worth stating plainly, because it
changes what this code means: **no surface in this app writes
`profile.language`** — the sole write is a mapper default of 'en' — and the
interface ships `<html lang="en">` with no translations. So 'de' was reachable
ONLY through the currency guess, which means the entire German nudge path
existed because of a heuristic nobody opted into. NUDGE_COPY.de stays, waiting
for real i18n rather than being selected by inference; German cards inside an
English interface are the mixed-language dashboard this file's own header calls
a trust bug, just at a larger grain.

The header docblock advertised the heuristic it no longer has, so it is updated
too — a removed rule that leaves its description behind is how the next reader
gets misled.

Tests pin all three directions (currency ignored, default English, explicit
choice honoured) and are proven by mutation: reinstate the CHF branch and "does
not read a language out of the currency" goes red.


Claude-Session: https://claude.ai/code/session_01Sh5aTRjzkcZkTyiu9D5RCM

Co-authored-by: Georgy Butaev <41178744+g-but@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants