From 221def8e9f5312d4c9a771e8fb3903c50e9c21b4 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:28:51 +0200 Subject: [PATCH] fix(cat): the dashboard spoke German to anyone paid in francs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sh5aTRjzkcZkTyiu9D5RCM --- .../nudge-language-ignores-currency.test.ts | 40 ++++++++++++++++++ src/services/cat/nudge-copy.ts | 42 ++++++++++++------- 2 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 __tests__/unit/cat/nudge-language-ignores-currency.test.ts diff --git a/__tests__/unit/cat/nudge-language-ignores-currency.test.ts b/__tests__/unit/cat/nudge-language-ignores-currency.test.ts new file mode 100644 index 000000000..03a6bd5ca --- /dev/null +++ b/__tests__/unit/cat/nudge-language-ignores-currency.test.ts @@ -0,0 +1,40 @@ +/** + * Nudge language used to be 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 — and the founder, whose + * interface is English, opened his dashboard 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 a currency is not a language — Switzerland + * has four national languages. This is the same category error `@/utils/locale` + * was written to end one layer down. + */ + +import { resolveNudgeLanguage, NUDGE_COPY } from '@/services/cat/nudge-copy'; + +describe('nudge language', () => { + it('does not read a language out of the currency', () => { + for (const currency of ['CHF', 'EUR', 'USD', 'chf', null, undefined]) { + expect(resolveNudgeLanguage({ language: null, currency })).toBe('en'); + } + }); + + it('defaults to the language the interface actually speaks', () => { + expect(resolveNudgeLanguage(null)).toBe('en'); + expect(resolveNudgeLanguage(undefined)).toBe('en'); + expect(resolveNudgeLanguage({})).toBe('en'); + expect(resolveNudgeLanguage({ language: '', currency: 'CHF' })).toBe('en'); + }); + + it('still honours an explicit choice, whatever the currency says', () => { + expect(resolveNudgeLanguage({ language: 'de', currency: 'USD' })).toBe('de'); + expect(resolveNudgeLanguage({ language: 'de-CH', currency: 'USD' })).toBe('de'); + expect(resolveNudgeLanguage({ language: 'en', currency: 'CHF' })).toBe('en'); + }); + + it('keeps a copy table for every language it can return', () => { + expect(Object.keys(NUDGE_COPY).sort()).toEqual(['de', 'en']); + expect(NUDGE_COPY.en.languageName).toBe('English'); + }); +}); diff --git a/src/services/cat/nudge-copy.ts b/src/services/cat/nudge-copy.ts index 9523e0b4a..173d15139 100644 --- a/src/services/cat/nudge-copy.ts +++ b/src/services/cat/nudge-copy.ts @@ -2,9 +2,9 @@ * Nudge copy SSOT — every user-facing nudge string, per language. * * One language per user: the language is resolved ONCE from the user's profile - * (profile.language, falling back to a preferred-currency heuristic) and every - * nudge — deterministic templates AND LLM-written reasons — uses it. Mixed - * English/German cards on one dashboard were a founder-verified trust bug. + * (profile.language, and nothing else) and every nudge — deterministic + * templates AND LLM-written reasons — uses it. Mixed English/German cards on + * one dashboard were a founder-verified trust bug. */ import { ENTITY_REGISTRY, type EntityType } from '@/config/entity-registry'; @@ -13,23 +13,35 @@ export type NudgeLanguage = 'en' | 'de'; /** * Resolve the ONE language all nudges for this user are written in. - * profile.language is authoritative; with no explicit language, an explicit - * CHF currency preference (Swiss-focused platform) implies German. Default English. + * + * `profile.language` — an explicit choice — is the only input. Currency is NOT + * a language signal, and reading it as one is why a founder with an English + * interface got a dashboard of German nudge cards: CHF is this platform's + * DEFAULT fiat (see config/currencies), so "prefers CHF" is what every account + * that never chose anything looks like. A default is not a preference. And + * Switzerland has four national languages, so even a deliberate CHF would not + * imply German. + * + * It is the same category error `@/utils/locale` was written to end one layer + * down — 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". + * + * Note the practical consequence: no surface in this app writes + * `profile.language` (the sole write is a mapper default of 'en'), and the + * interface ships `` with no translations. So 'de' is + * currently unreachable, and NUDGE_COPY.de waits for real i18n rather than + * being selected by a heuristic nobody opted into. That is deliberate — German + * nudge cards inside an English interface are the mixed-language dashboard + * this file's header calls a trust bug, just at a larger grain. */ export function resolveNudgeLanguage( + // `currency` stays in the accepted shape because callers pass a whole + // profile — and because a regression test has to be able to hand this + // function the exact CHF-shaped profile that used to come back German. profile: { language?: string | null; currency?: string | null } | null | undefined ): NudgeLanguage { const lang = (profile?.language ?? '').toLowerCase(); - if (lang.startsWith('de')) { - return 'de'; - } - if (lang.startsWith('en')) { - return 'en'; - } - if ((profile?.currency ?? '').toUpperCase() === 'CHF') { - return 'de'; - } - return 'en'; + return lang.startsWith('de') ? 'de' : 'en'; } interface CopyBlock {