docs(planning): R4 i18n runtime probe report for v1.4.27

This commit is contained in:
Marc-André Bombeck committed 2026-05-15 20:40:37 +02:00
1 parent 617d451864
commit 4f60428ce6
1 file changed
+305
+305
View File
@@ -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 `<I18nProvider>` 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 `&lt;` / `&gt;` entities
Every locale's `telegram.helpBody` value includes `&lt;Name&gt;` /
`&lt;name&gt;`. 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").