From 4f60428ce692d8eb21e478a3b850720f147fb797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 20:40:37 +0200 Subject: [PATCH] docs(planning): R4 i18n runtime probe report for v1.4.27 --- .planning/research/v1427-r4-i18n.md | 305 ++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 .planning/research/v1427-r4-i18n.md diff --git a/.planning/research/v1427-r4-i18n.md b/.planning/research/v1427-r4-i18n.md new file mode 100644 index 000000000..2c5540b95 --- /dev/null +++ b/.planning/research/v1427-r4-i18n.md @@ -0,0 +1,305 @@ +# R4 i18n runtime probe — v1.4.27 + +Read-only audit of `messages/{de,en,fr,es,it,pl}.json` against the source-code +usage on `develop` (HEAD `617d4518`). Locale-integrity, drift-guard, and +format-ordering vitest suites all pass; the issues below would not fail any +existing assertion but reach the DOM on FR/ES/IT/PL surfaces today. + +``` +pnpm vitest run \ + src/lib/__tests__/i18n-locale-integrity.test.ts \ + src/__tests__/i18n-drift-guard.test.ts \ + src/lib/i18n/__tests__/format-locale-order.test.ts +→ 3 files / 69 tests passed +``` + +The single locale-integrity test path in the brief (`src/__tests__/locale-integrity.test.ts`) does not exist — the equivalent file ships at +`src/lib/__tests__/i18n-locale-integrity.test.ts`. The drift-guard and +format-locale tests resolve as-listed. + +--- + +## Severity legend + +- **P0** — visible DOM bug for shipped locale users; fix before tagging v1.4.27. +- **P1** — feature-parity gap; non-EN locale lands English copy on a live route. +- **P2** — copy correctness / mid-string formatting drift; not user-blocking. +- **P3** — informational / documentation drift. + +--- + +## P0 — mid-string interpolation drift (Marc-flagged "DE vs PL" pattern) + +Ten EN keys carry two interpolation tokens. The FR/ES/IT/PL bundles +translated the **connector only** (`of` → `de` / `di` / `z`) but **dropped the +adjoining ASCII space**, so every `pageInfo`-shaped surface renders as +`Page 2 de10` instead of `Page 2 de 10`. The same shape ships in five +distinct user paths. + +| Key | EN | FR | ES | IT | PL | +| --- | --- | --- | --- | --- | --- | +| `measurements.pageInfo` | `Page {page} of {total}` | `Page {page} de{total}` | `Page {page} de{total}` | `Page {page} di{total}` | `Page {page} z{total}` | +| `mood.pageInfo` | `Page {page} of {total}` | `Page {page} de{total}` | `Page {page} de{total}` | `Page {page} di{total}` | `Page {page} z{total}` | +| `medications.pageInfo` | `Page {page} of {total}` | `Page {page} de{total}` | `Page {page} de{total}` | `Page {page} di{total}` | `Page {page} z{total}` | +| `admin.section.auditLog.pageOf` | `Page {page} of {total}` | `Page {page} de{total}` | `Page {page} de{total}` | `Page {page} di{total}` | `Page {page} z{total}` | +| `admin.showingEntries` | `Showing {count} of {total} auth entries` | `Showing {count} de{total} auth entries` | `Showing {count} de{total} auth entries` | `Showing {count} di{total} auth entries` | `Showing {count} z{total} auth entries` | +| `onboarding.tour.stepOf` | `Step {current} of {total}` | `Step {current} de{total}` | `Step {current} de{total}` | `Step {current} di{total}` | `Step {current} z{total}` | +| `gettingStarted.progress` | `{done} of {total} done` | `{done} de{total} Terminé` | `{done} de{total} Hecho` | `{done} di{total} Fatto` | `{done} z{total} Gotowe` | +| `targets.consistency.daysInRange` | `{count} of {total} in range` | `{count} de{total} in range` | `{count} de{total} in range` | `{count} di{total} in range` | `{count} z{total} in range` | +| `targets.consistency.daysLogged` | `{count} of {total} logged` | `{count} de{total} logged` | `{count} de{total} logged` | `{count} di{total} logged` | `{count} z{total} logged` | +| `targets.summary.weekTitle` | `{met} of {total} targets met this week` | `{met} de{total} targets met this week` | `{met} de{total} targets met this week` | `{met} di{total} targets met this week` | `{met} z{total} targets met this week` | + +DE comparison line (correct): +- `gettingStarted.progress` = `{done} von {total} erledigt` +- `admin.showingEntries` = `Zeigt {count} von {total} Auth-Einträgen` + +**Fix shape.** Restore the space on both sides of the connector and +finish the partial translation pass: + +- FR `Page {page} de{total}` → `Page {page} sur {total}` (or + `Page {page} de {total}` if Marc prefers "de"; the native French form for + pagination is "sur"). +- ES `Page {page} de{total}` → `Página {page} de {total}`. +- IT `Page {page} di{total}` → `Pagina {page} di {total}`. +- PL `Page {page} z{total}` → `Strona {page} z {total}`. + +Apply the same pattern to the other nine rows; nine of ten still ship +the English noun ("Page", "Step", "Showing", "in range", "logged", +"targets met this week") on the four newer locales. Track the partial +translation gap in the P1 section below. + +Drift-guard would not catch this — the test asserts presence and +non-empty value but does not assert balanced whitespace around +placeholders. Suggest extending +`src/__tests__/i18n-drift-guard.test.ts` with a regex assertion +`/[A-Za-zÀ-ÿ]\{|\}[A-Za-zÀ-ÿ]/` returning false across the bundle. + +--- + +## P0 — `dashboard.glp1.*` English leak on FR/ES/IT/PL (R3a B1 scope) + +The GLP-1 tile ships live on the dashboard (`src/components/dashboard/glp1-tile.tsx` +lines 278 / 303 / 318 / 324). Four of the eleven `dashboard.glp1.*` keys +were translated to DE only — the FR / ES / IT / PL bundles still hold the +EN string: + +| Key | EN / DE / FR / ES / IT / PL | +| --- | --- | +| `dashboard.glp1.lastInjection` | EN `Last injection` · DE `Letzte Injektion` · FR/ES/IT/PL `Last injection` | +| `dashboard.glp1.nextInjection` | EN `Next injection` · DE `Nächste Injektion` · FR/ES/IT/PL `Next injection` | +| `dashboard.glp1.weightDelta` | EN `since start` · DE `seit Beginn` · FR/ES/IT/PL `since start` | +| `dashboard.glp1.inDays` | EN `in {count} days` · DE `in {count} Tagen` · FR/ES/IT/PL `in {count} days` | + +The other seven keys in the namespace (`title`, `tabLevel`, `tabWeight`, +`tabsAria`, `rangeStripLabel`, `levelUnavailable`, `weightUnavailable`) +are translated correctly across every locale, so this is a focused +copy-paste regression rather than a missing namespace. + +Suggested copy: + +| Key | FR | ES | IT | PL | +| --- | --- | --- | --- | --- | +| `lastInjection` | `Dernière injection` | `Última inyección` | `Ultima iniezione` | `Ostatnia iniekcja` | +| `nextInjection` | `Prochaine injection` | `Próxima inyección` | `Prossima iniezione` | `Następna iniekcja` | +| `weightDelta` | `depuis le début` | `desde el inicio` | `dall'inizio` | `od początku` | +| `inDays` | `dans {count} jours` | `en {count} días` | `tra {count} giorni` | `za {count} dni` | + +The drift-guard's GLP-1 row would catch a missing key but not an EN-leak. +The Marc-Voice rule plus `cache invalidate on new content` memory both +apply — once the bundles ship, the cached `useTranslations()` instance +on each browser will need a route-change or hard reload to pick up the +new strings. + +--- + +## P1 — partial-translation residue across the wider bundle + +The `pageInfo`-shaped fix above is a symptom of a larger pattern. +A naive count of "DE has been translated, at least one of FR/ES/IT/PL still +equals EN" returns **1,664 keys**. By prefix the residue concentrates in: + +| Namespace | EN-leak rows | Note | +| --- | --- | --- | +| `achievements.badges.*` | 118 | Badge titles + copy | +| `admin.section.*` | 99 | Admin sub-pages | +| `settings.ai.*` | 65 | Coach / provider settings | +| `settings.sections.*` | 56 | Sidebar headings | +| `insights.coach.*` | 51 | Coach surface copy | +| `targets.status.*` | 36 | Status enums | +| `admin.feedback.*` | 34 | Admin feedback view | +| `insights.recommendation.*` | 20 | Insight recos | +| `settings.testConnection.*` | 18 | Provider test rows | +| `insights.correlationRow.*` | 16 | Correlation widget | + +This is the long tail Marc accepted when shipping FR/ES/IT/PL stubs in +v1.4.25. The locale-integrity test only enforces parity of key **shape**, +not parity of translated value; the drift-guard pins the v1.4.27 new +keys explicitly. Out of v1.4.27 scope as a fix target, but the +`dashboard.glp1.lastInjection` regression came from this same vector +so a future tightening of the drift-guard (e.g. fail when value equals +the EN value AND the EN value contains an alphabetic ASCII letter) would +have caught it. + +Decision call for Marc: do we want the drift-guard to start failing on +the wider residue, or scope the v1.4.27 fix to the four GLP-1 rows and +the ten interpolation strings only? + +--- + +## P2 — `notifications.user.telegramTestBody` carries the brand twice + +``` +de "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." +en "HealthLog: connection successful. Telegram notifications are active." +fr "HealthLog: connexion réussie. Les notifications Telegram sont actives." +es "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." +it "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." +pl "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." +``` + +The brand is correctly preserved on the leading marker. Mid-string text +is fully translated. No bug — flagged only because the brand-leading +form is intentionally repeated across `notifications.user.*` and +`notifications.admin.testNotificationTitle`, which lands as +"HealthLog notification de test" / "HealthLog notificación de prueba" / +etc. The marker pattern is consistent; no fix. + +--- + +## P2 — `format.dateShort` / `timeShort` / `dateTime` not consumed at runtime + +The three keys exist across every locale with the correct native +ordering (DE = `{day}.{month}.{year}`, EN = `{month}/{day}/{year}`, +FR/ES/IT = `{day}/{month}/{year}`, PL = `{day}.{month}.{year}`). +`src/lib/i18n/__tests__/format-locale-order.test.ts` pins the ordering +correctly. However, a `grep` across `src/` finds **zero callsites** that +actually consume `format.dateShort`, `format.timeShort`, or +`format.dateTime` — every render path still calls +`src/lib/format-locale.ts` (Intl-driven). The test header acknowledges +this ("the actual formatting still routes through `Intl.*`"). Keep the +keys for the PDF / email / CSV downstreams the test comment names, +flag for the v1.4.28 doctor-report work — if those surfaces never wire +up, the keys are dead and the namespace can retire. + +--- + +## P2 — `app/not-found.tsx` ships hard-coded English copy + +`src/app/not-found.tsx` (added in `de0c1633` under R3d MB6) renders three +English literals (`Page not found`, `The page you were looking for ...`, +`Back to dashboard`) without going through `useTranslations()`. The +NotFound page sits **above** the `` in the App Router +hierarchy on the same tier as `global-error.tsx`, but unlike +`global-error.tsx` (which carries a `// Keep it static, in English` +docstring justifying the choice) the NotFound page has no equivalent +explanation. A FR / ES / IT / PL visitor who hits a 404 lands on English +copy. + +Two viable shapes: +1. Move NotFound into a localised tree (e.g. `src/app/[locale]/not-found.tsx`) + so it picks up the same provider stack as the live routes. +2. Keep the page bare-bones English (parity with `global-error.tsx`) and + document the decision inline. + +If (2), match the `global-error.tsx` pattern of an inline justification +comment so the next reviewer doesn't open the same ticket. + +--- + +## P2 — `app/privacy/page.tsx` carries English-only marketing copy + +`src/app/privacy/page.tsx` is English-only by **explicit decision** +(`v1.4.26 — Public privacy policy`, header comment lines 22–27 spell +out "English-only body copy for this release. German + the four +remaining locales follow in a subsequent patch"). The Contents +disclosure label (`"Contents"`, line 155) is hard-coded; the App Store +reviewer link (`Privacy policy`, line 97) is hard-coded. Per the page's +own comment the localised pass is deferred, but if v1.4.27 hopes to +ship the iOS submission alongside the German market, the TOC label and +the data-subject-rights enumeration both still need a DE / FR / ES / IT +/ PL render. Out of R4 scope to fix; flagged because the brief lists +"privacy TOC label" as one of the keys to spot-check. + +--- + +## P3 — `telegram.helpBody` contains `<` / `>` entities + +Every locale's `telegram.helpBody` value includes `<Name>` / +`<name>`. These are **intentional**: Telegram's `parse_mode=HTML` +treats angle brackets as tag markers and requires the entity form for +literal `<` / `>`. The mojibake scan flagged them as the only entity hits +across all six bundles — no fix required. (Documenting so the next +mojibake sweep doesn't re-open the question.) + +--- + +## P3 — `admin.carrier` / `admin.carrierUnknown` look correct + +R3a B3 scope. Both keys are translated across every locale; the call +site (`src/components/admin/login-overview-section.tsx:167`) wires the +chip header through `t("admin.carrier")`. EN `Carrier` / `Unknown +carrier`, DE `Anbieter` / `Unbekannter Anbieter`, FR `Opérateur` / +`Opérateur inconnu`, ES `Operador` / `Operador desconocido`, IT +`Operatore` / `Operatore sconosciuto`, PL `Operator` / `Nieznany +operator`. Drift-guard pins both rows. No action. + +--- + +## P3 — `insights.emptyState.*` (21 keys) ships clean + +R3a B4 / R3b B6 scope. All twenty-one keys (seven metrics × +title/description/cta) are translated across every locale with full +sentences in the native form — no EN leak, no malformed +interpolation, no mojibake. Drift-guard pins all twenty-one explicitly +(`src/__tests__/i18n-drift-guard.test.ts` lines 89–113). The CTA call +sites under `src/app/insights/{blutdruck,gewicht,puls,bmi,stimmung,medikamente,schlaf}/page.tsx` +all consume the right keys. The `EmptyState` primitive's `ctaSize="lg"` +prop (R3d MB7) is structural — no copy implications. + +--- + +## P3 — `notifications.admin.*` (8 keys) + `notifications.user.*` (1 key) ship clean + +R3a B5 / R3b B6 scope. The four `reminderCheck*` keys, two `testNotification*` +keys, two `deployFailed*` keys, and the single `telegramTestBody` row +all carry native translations with consistent placeholder usage +(`{application}`, `{deployment}`, `{error}`, `{logsUrl}`, `{medication}`, +`{dose}`, `{window}`, `{minutes}`). Token order varies by locale (e.g. +DE `nicht geloggt` follows the German verb-final pattern) which is +correct, not drift. All four call sites under +`src/app/api/admin/notifications/*` and +`src/app/api/settings/telegram/test/route.ts` are wired through the +server-side `t()`. No action. + +--- + +## Tests run + +``` +pnpm vitest run \ + src/lib/__tests__/i18n-locale-integrity.test.ts \ + src/__tests__/i18n-drift-guard.test.ts \ + src/lib/i18n/__tests__/format-locale-order.test.ts +→ 3 files, 69 tests, 0 failures +``` + +No assertion fails on `develop` HEAD `617d4518`. Every issue raised +above lives below the existing assertions' resolution. + +--- + +## Suggested test tightenings (decisions for Marc) + +1. **Whitespace-around-placeholder guard.** Extend + `i18n-drift-guard.test.ts` with a per-locale check that + `/[A-Za-zÀ-ÿ]\{|\}[A-Za-zÀ-ÿ]/` returns no match. Would have caught + the ten `pageInfo`-shape strings immediately. +2. **GLP-1 EN-leak guard.** For the `dashboard.glp1.*` namespace + specifically — every value in FR/ES/IT/PL must differ from its EN + sibling unless on a small allowlist (brand names, identical + tokens). The wider 1,664-row residue means this can't go + bundle-wide without churn, but a narrow per-namespace guard fits + the drift-guard's existing label shape. +3. **Native-pagination connector test.** A `pageInfo` assertion: the + FR / ES / IT / PL value contains a space-flanked connector + matching the native pagination phrase ("sur", "de", "di", "z").