mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
docs(planning): R4 i18n runtime probe report for v1.4.27
This commit is contained in:
1 parent
617d451864
commit
4f60428ce6
1 file changed
+305
@@ -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 `<` / `>` 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").
|
||||
Reference in new issue
Block a user