From 35a62f170d8c9bf81c6ddc68dcb763b17e3a8e69 Mon Sep 17 00:00:00 2001 From: Train Chen Date: Tue, 26 May 2026 17:36:52 +0100 Subject: [PATCH] feat: dedupe bilingual() identical sides + add CTA ja/zh translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the "Write today / Write today" rendering when target=ja and primary=zh-Hant (or any pair where both sides fall back to en via the Translations fallback chain). Two changes: 1. bilingual() dedupe — one-line change in LanguageContext.tsx. When target and primary resolve to the same string, render once instead of "X / X". Silent improvement for every existing call site without modifying their Translations objects. 2. HomeScreen CTA gets explicit ja + zh-Hans + zh-Hant keys. "Write today" / "Write another" now render in the target language when those are the active target: ja: 今日書きましょう / もう一度書きましょう zh-Hans: 今天写日记 / 再写一篇 zh-Hant: 今天寫日記 / 再寫一篇 Three new bilingual() tests cover the dedupe behavior: - Same string both sides → renders once - Different strings → keeps "X / Y" pair - Both keys provided → renders the actual pair (no false dedupe) Other chrome anchors (Brain Dump, Thought Garden, etc.) still fall back to en but no longer say "Brain Dump / Brain Dump" — the dedupe handles them transparently. A future translation-pass PR will add real Japanese and Chinese keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG.md | 15 +++++++++++++ src/components/journal/HomeScreen.tsx | 18 ++++++++++++++-- src/contexts/LanguageContext.test.tsx | 31 +++++++++++++++++++++++++++ src/contexts/LanguageContext.tsx | 4 +++- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1bd07c7..8f56a36 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -59,6 +59,21 @@ This PR adds a proper Spanish branch in both blocks: French CBT scaffolding stays as the explicit default fallback. No behavior change for any other language. **Why:** Spanish was target-language #2 added to the app, but the original French edge function was never split. The bug only surfaced when the language-* consolidation reorganized the file. Catching it now keeps the granularity goal honest — coaching prompts should be in the target language, full stop. +--- +### Bilingual rendering — dedupe identical sides, CTA gets Japanese + Chinese + +**Fixes the "Write today / Write today" rendering when the active pair has no translation for either side.** + +Two changes: + +1. **`bilingual()` dedupes identical sides.** When `targetLang` and `primaryLang` resolve to the same string (typically because both fell back to English via the Translations fallback chain), the helper now returns the string once instead of `"X / X"`. Silent improvement for every call site across the app — no API change, no semantic change for pairs that legitimately differ (e.g. `fr / en`). + +2. **HomeScreen CTA gets explicit Japanese, Simplified Chinese, and Traditional Chinese keys.** "Write today" / "Write another" now render correctly when the target is `ja` or the primary is `zh-Hans` / `zh-Hant`: + - ja: `今日書きましょう` / `もう一度書きましょう` (polite, inviting register) + - zh-Hans: `今天写日记` / `再写一篇` + - zh-Hant: `今天寫日記` / `再寫一篇` + +**Why partial:** the CTA is the most visually prominent bilingual call on Home, so it gets translations now. The rest of the chrome (`Brain Dump`, `Thought Garden`, `French journaling practice`, `More tools`, etc.) is silently improved by the dedupe alone — they no longer say `"Brain Dump / Brain Dump"` when both sides fall back. A future translation-pass PR will add `ja:` and `zh-*:` keys to the remaining anchors. ### Japanese language support — target-only diff --git a/src/components/journal/HomeScreen.tsx b/src/components/journal/HomeScreen.tsx index 5d1d15d..ffabd1a 100644 --- a/src/components/journal/HomeScreen.tsx +++ b/src/components/journal/HomeScreen.tsx @@ -167,8 +167,22 @@ export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, e > {hasJournaledToday - ? bilingual({ fr: 'Écrire encore', en: 'Write another', es: 'Escribir más' }) - : bilingual({ fr: "Écrire aujourd'hui", en: 'Write today', es: 'Escribir hoy' }) + ? bilingual({ + fr: 'Écrire encore', + en: 'Write another', + es: 'Escribir más', + ja: 'もう一度書きましょう', + 'zh-Hans': '再写一篇', + 'zh-Hant': '再寫一篇', + }) + : bilingual({ + fr: "Écrire aujourd'hui", + en: 'Write today', + es: 'Escribir hoy', + ja: '今日書きましょう', + 'zh-Hans': '今天写日记', + 'zh-Hant': '今天寫日記', + }) } diff --git a/src/contexts/LanguageContext.test.tsx b/src/contexts/LanguageContext.test.tsx index 605fa2c..366ab3f 100644 --- a/src/contexts/LanguageContext.test.tsx +++ b/src/contexts/LanguageContext.test.tsx @@ -263,6 +263,37 @@ describe('bilingual() — formatted pair', () => { expect(result.current.bilingual({ fr: 'Vide-tête', en: 'Brain Dump', es: 'Volcado mental' })) .toBe('Vide-tête / Brain Dump'); }); + + it('dedupes when target and primary resolve to the same string', () => { + // target=ja primary=zh-Hant; neither key present → both fall back to en. + // bilingual() should render once, not "Write today / Write today". + localStorage.setItem(STORAGE_KEY, JSON.stringify({ primary: 'zh-Hant', target: 'ja' })); + const { result } = renderHook(() => useLanguage(), { wrapper }); + expect(result.current.bilingual({ fr: "Écrire aujourd'hui", en: 'Write today', es: 'Escribir hoy' })) + .toBe('Write today'); + }); + + it('does not dedupe when target and primary differ', () => { + // target=fr primary=en — both resolve and differ → keep the pair. + const { result } = renderHook(() => useLanguage(), { wrapper }); + expect(result.current.bilingual({ fr: "Écrire aujourd'hui", en: 'Write today', es: 'Escribir hoy' })) + .toBe("Écrire aujourd'hui / Write today"); + }); + + it('renders both when ja and zh-Hant keys are provided', () => { + // Same pair as the dedupe case, but now both keys are present → no fallback, + // no dedupe, both strings render. + localStorage.setItem(STORAGE_KEY, JSON.stringify({ primary: 'zh-Hant', target: 'ja' })); + const { result } = renderHook(() => useLanguage(), { wrapper }); + expect(result.current.bilingual({ + fr: "Écrire aujourd'hui", + en: 'Write today', + es: 'Escribir hoy', + ja: '今日書きましょう', + 'zh-Hans': '今天写日记', + 'zh-Hant': '今天寫日記', + })).toBe('今日書きましょう / 今天寫日記'); + }); }); describe('stringFor() — dev-mode fallback warning', () => { diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index 01d24ef..5404421 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -165,7 +165,9 @@ export function LanguageProvider({ children }: { children: ReactNode }) { const bilingual = (translations: Translations) => { const tgt = stringFor(pair.target, translations); const prm = stringFor(pair.primary, translations); - return `${tgt} / ${prm}`; + // Dedupe: when target and primary resolve to the same string (typically + // because both fell back to en), render once instead of "X / X". + return tgt === prm ? tgt : `${tgt} / ${prm}`; }; return (