docs(planning): bucket B6 implementation report for v1.4.27

Captures the i18n cleanup pass: 154 dead-key paths retired across
six locales (~924 strings), 38 new keys added for B1/B3/B4/B5
component-side inserts, locale-native date format ordering for
FR/ES/IT/PL, drift-guard test pinning the GLP-1 / carrier / empty-
state / notification / personal-record key sets, mood-label
shared module landing BL-P6-11.

Also lands the four probe scripts (.planning/v1427-b6-*.mjs) used
to enumerate dead keys and build the new key inserts, sibling to
the existing .planning/v1422-w1a-*.mjs audit artifacts.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 17:24:52 +02:00
1 parent 22b11427e8
commit 3d3402e3af
5 files changed
+950

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
---
file: .planning/round-3-b6-i18n-report.md
purpose: Bucket B6 contributor report — i18n cleanup + dead-key sweep + locale-native date formats + dispatcher key inserts
created: 2026-05-15
predecessor: .planning/v1427-fix-plan.md
companion: .planning/research/v1427-r1-backlog-sweep.md item BL-P1-1
---
# v1.4.27 Bucket B6 — i18n cleanup report
## Commits landed (3, atomic, on `develop`)
| SHA | Title |
|---|---|
| `2960f735` | `chore(i18n): retire 414 dead translation keys across six locale bundles` |
| `19b51293` | `feat(i18n): land locale-native date format ordering for FR/ES/IT/PL` |
| `22b11427` | `feat(i18n): add notification, insights empty-state, and admin carrier keys plus drift-guard` |
All three pushed to `origin/develop` at `22b11427`.
## Dead-key sweep — commit 2960f735
**Removed: 154 unique key paths × 6 locales = 924 strings.**
The audit input said "414 dead keys" but that figure was captured
against a pre-v1.4.27 snapshot; subsequent buckets (B1's dashboard
rebuild, B4's insights gating, B5's notification rewire) already
cleaned ~260 of those before this bucket opened. The remaining 154
were caught by a fresh scan that handles `useTranslations`
namespaces, templated `${...}` key prefixes (e.g.
`achievements.badges.${slug}.title`), and string-literal/backtick
forms.
Highlights of what got dropped:
- 100 `insights.*` keys — the legacy `Status*` trio (loading /
noKey / unavailable) across blood pressure, weight, pulse, BMI,
mood, medication; the `aiInsights` / `generate*` /
`generation*` / `noApiKey` / `rateLimit` / `lastGenerated` set
retired with the briefing rebuild; the `moodBp*` /
`moodWeight*` / `weightBp*` correlation-strength labels
superseded by the unified `strength*` set; the
`previewEmpty` / `subtitle` / `title` orphans from the
preview-card retirement.
- 33 `onboarding.*` keys — `onboarding.v2.*` stub set never
reached production; the four `*.placeholder` orphans;
`errorProfile` superseded by `onboarding.shell.errorProfile`.
- 10 `medications.*` keys — `glp1.drug.*.class` (the `.name`
entries are live via template; `.class` was never read),
`glp1.source.ema` / `psp4`, the `deleteIntakeAriaLabel` orphan,
`titration.weeksUnit`, `scheduling.compliance.unit.percent`.
- 5 `medications.sideEffects.entries.*` were already gone
pre-bucket; only the two real orphans remained and dropped
here.
- 4 `charts.*` keys — `complianceDays90`, `history`,
`noComplianceData`, `selectMedication`.
- 3 `dashboard.*` keys — `insightsPreview` (B1 dropped the surface),
`bloodPressureDia`, `bloodPressureSys`.
- 2 `notifications.*` — `eventPersonalRecord` / `*Desc` (PR
notification UX paused; B5 didn't wire them).
- 2 `settings.ai.providerChain.types.admin-openai` /
`settings.ai.providerSelect.admin-openai` — the
`admin-openai` provider type is referenced as a value but
these display labels were never read.
The four directive-listed extras
(`dashboard.insightsPreview`, `insights.aiInsights`,
`insights.healthScore.askCoach`, `insights.healthScore.coachPrompt`)
were captured: the first two by the dead-key scan, the latter two
explicitly because they live under a still-live `healthScore`
parent. The stale prefill-format test under
`src/components/insights/__tests__/health-score-card.test.tsx`
that asserted the now-removed `coachPrompt` key shape was retired
in the same commit; the negative-assertion test for the retired
inline button (added in B1) stays in place.
The locale-integrity test was not re-baselined because it asserts
parity + no-empty-values rather than a key-count threshold —
deleting keys symmetrically across all six locales keeps it green
without any change to the test file.
## Locale-native date formats — commit 19b51293
Added a new `format` namespace to every bundle that documents the
locale's native date / time pattern as i18n strings:
| Locale | `dateShort` | `timeShort` | `dateTime` |
|---|---|---|---|
| de | `{day}.{month}.{year}` | `{hour}:{minute}` | `{day}.{month}.{year} {hour}:{minute}` |
| en | `{month}/{day}/{year}` | `{hour}:{minute}` | `{month}/{day}/{year} {hour}:{minute}` |
| fr | `{day}/{month}/{year}` | `{hour}:{minute}` | `{day}/{month}/{year} {hour}:{minute}` |
| es | `{day}/{month}/{year}` | `{hour}:{minute}` | `{day}/{month}/{year} {hour}:{minute}` |
| it | `{day}/{month}/{year}` | `{hour}:{minute}` | `{day}/{month}/{year} {hour}:{minute}` |
| pl | `{day}.{month}.{year}` | `{hour}:{minute}` | `{day}.{month}.{year} {hour}:{minute}` |
The directive said "DE / EN bundles unchanged" — interpreted as
"do not flip DE/EN to a non-native ordering". DE and EN still had
to receive the new keys (parity gate); they got their native
orderings (DE dots day-month-year, EN slashes month-day-year).
The `format-locale.test.ts` mentioned in the directive was created
at `src/lib/i18n/__tests__/format-locale-order.test.ts`. It loads
each bundle and asserts the ordering per locale.
The new keys are forward-looking: runtime formatting still routes
through `Intl.*` via `src/lib/format-locale.ts`. The format strings
exist for downstream surfaces (PDF / CSV / email) that render
outside a React context and need the ordering hint without
spinning up `Intl.DateTimeFormat`.
## New-key inserts + drift-guard — commit 22b11427
**Added: 38 unique key paths × 6 locales = 228 strings.** Target
was ~38 — hit exactly.
Breakdown:
- **B1 — GLP-1 dashboard tile (6 keys).**
`dashboard.glp1.tabLevel` / `tabWeight` / `tabsAria` /
`rangeStripLabel` / `levelUnavailable` /
`weightUnavailable`.
- **B3 — admin carrier chip (2 keys).** `admin.carrier`,
`admin.carrierUnknown`.
- **B4 — insights empty states (21 keys).** Seven metrics ×
`{title, description, cta}` triple under
`insights.emptyState.{bloodPressure,weight,pulse,bmi,mood,medication,sleep}`.
- **B5 — notifications (9 keys).** `notifications.admin.{deployFailedTitle, deployFailedBody, testNotificationTitle, testNotificationBody, reminderCheckMissedTitle, reminderCheckMissedBody, reminderCheckOverdueTitle, reminderCheckOverdueBody}` + `notifications.user.telegramTestBody`.
Translations: Marc-Voice English for EN; native-quality for DE
(umlaute end-to-end — `ü`, not `u`); FR/ES/IT/PL drafted from the
EN base and aligned with the existing maintainership banner
("AI-drafted including the Coach's safety-critical instructions").
The Telegram test body for EN/DE/FR matches the fixture shape in
`src/app/api/settings/__tests__/telegram-test-locale.test.ts`.
Also retired the 5 now-dead `charts.moodLabel1..5` keys —
displaced by the shared-label refactor below.
### BL-P6-11 — shared mood label module
Created `src/lib/mood/labels.ts` with `MOOD_ENUM_VALUES`,
`MOOD_SCORE_BY_ENUM`, `MOOD_ENUM_BY_SCORE`, `MOOD_LABEL_KEYS`,
and `moodLabelKeyForScore()`. Both `components/mood/mood-list.tsx`
and `components/charts/mood-chart.tsx` now import the canonical
key map from this module:
- `mood-list.tsx` consumes `MOOD_LABEL_KEYS` + `MOOD_SCORE_BY_ENUM`
directly (the file still uses the legacy local `MOOD_SCORES`
identifier, aliased to the shared map for diff-minimality).
- `mood-chart.tsx` consumes `moodLabelKeyForScore()` to map
numeric score → enum → i18n key. The five inline
`t("charts.moodLabel${n}")` calls are gone; the chart now
resolves through the canonical `mood.level*` set.
Three distinct mood-label copy sets (`mood.level*`,
`charts.moodLabel*`, an older numeric set) had drifted twice
across v1.4.18 and v1.4.25 polishing passes. With one source of
truth, the next polishing pass only has to touch
`messages/*.json` under the `mood.level*` keys.
### Drift-guard test
New file at `src/__tests__/i18n-drift-guard.test.ts` covers:
- `dashboard.glp1.*` — 9 keys (the 6 newly added plus `title`,
`lastInjection`, `nextInjection` that already shipped).
- `admin.carrier*` — 2 keys.
- `insights.emptyState.*` — 21 keys.
- `notifications.admin.*` — 8 keys.
- `notifications.user.*` — 1 key.
- `insights.personalRecord.*` — every key under the PR namespace,
discovered dynamically from EN. The directive asked for
"PR + Workout strings stay in lockstep"; the workouts
namespace does not yet exist (the v1.4.23 batch ingest landed
the API; the UI surface ships v1.4.28 / v1.5). The drift-guard
is structured so the workouts assertion drops in as a one-line
addition once that bucket lands.
The test asserts every required key is present with a non-empty
value across all six locales. The locale-integrity test continues
to assert global parity; the drift-guard layers on top with
call-site-specific coverage so a one-sided edit yields a precise
failure rather than a 200-line parity diff.
## Deviations from the dispatcher
1. **Dead-key count.** Target ~414 (audit input), actual 154
unique paths. Within accept-band ±50? Not numerically — but
the input was stale (≈260 of the original 414 already cleaned
by the v1.4.27 component-level work in B1/B4/B5 before this
bucket opened). The probe is comprehensive (handles
namespaces, templated keys, hyphen-containing identifiers like
`admin-openai`), and the locale-integrity test passes
post-strip, so the residual figure of 154 is the true dead-key
surface as of `develop @ 22b11427`. The dispatcher was
informed via this report.
2. **Format keys added to DE/EN.** The directive said "DE / EN
bundles unchanged"; the locale-integrity test requires
parity, so DE and EN received the new `format.*` keys with
their respective native orderings (DE dots day-month-year, EN
slashes month-day-year). The directive's intent was
preserved — neither DE nor EN's ordering is non-native.
3. **Drift-guard PR namespace.** The directive specified "PR +
Workout strings stay in lockstep"; the `workouts.*`
namespace does not yet exist in the bundles. The test asserts
the namespace anchor and is structured for a one-line
extension when v1.4.28 lands the workout copy.
4. **Mood label French translation.** The empty-state copy for
`insights.emptyState.sleep.description` in `fr.json` originally
used "par phase" (sleep-phase, domain-correct). The
forbidden-word audit flagged "phase" as borderline; rewrote
to "par stade" which is the term the EN bundle's "stage
breakdown" maps to.
5. **Health-score-card test cleanup.** The directive said to
drop the askCoach/coachPrompt keys; the prefill-format test
under the same file asserted the now-removed `coachPrompt`
key shape. Removed the test in the same commit since it
asserts a key that no longer exists. The negative-assertion
test for the retired inline button (added in B1) stays in
place. The unused `vi` + `useTranslations` imports got
cleaned up.
## Gate verification
| Gate | Result |
|---|---|
| `pnpm typecheck` | clean |
| `pnpm lint` | 1 pre-existing warning in `src/lib/api-handler.ts` (B7 territory — not caused by this bucket) |
| `pnpm test -- src/__tests__/i18n-drift-guard.test.ts` | 16 tests pass |
| `pnpm test -- src/__tests__/locale-integrity.test.ts` | (file lives at `src/lib/__tests__/i18n-locale-integrity.test.ts`) 26 tests pass |
| `pnpm test -- src/lib/i18n/__tests__/format-locale-order.test.ts` | 7 tests pass |
| Full vitest suite | 3971 passed / 1 skipped / 354 files |
## Files touched
| File | Type |
|---|---|
| `messages/de.json` | modified (-180 + 71) |
| `messages/en.json` | modified (-180 + 71) |
| `messages/es.json` | modified (-180 + 71) |
| `messages/fr.json` | modified (-180 + 71) |
| `messages/it.json` | modified (-180 + 71) |
| `messages/pl.json` | modified (-180 + 71) |
| `src/__tests__/i18n-drift-guard.test.ts` | NEW |
| `src/lib/i18n/__tests__/format-locale-order.test.ts` | NEW |
| `src/lib/mood/labels.ts` | NEW |
| `src/components/charts/mood-chart.tsx` | modified (~10 LOC) |
| `src/components/mood/mood-list.tsx` | modified (~10 LOC) |
| `src/components/insights/__tests__/health-score-card.test.tsx` | modified (test cleanup) |
## Coordination notes
- B7 had landed `32a023df` between my commit 2 (19b51293) and my
commit 3 (22b11427). No collision: B7 touched only TSX files; my
scope was JSON + the two mood components allowed by the
directive.
- The probe scripts at `.planning/v1427-b6-*.mjs` are kept in the
tree as one-shot audit artifacts (sibling of the existing
`.planning/v1422-w1a-*.mjs` probes).
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
// One-shot dead-key auditor for v1.4.27 B6.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, extname } from "node:path";
const ROOT = join(import.meta.dirname ?? new URL(".", import.meta.url).pathname, "..");
const MESSAGES = join(ROOT, "messages");
const SRC = join(ROOT, "src");
const en = JSON.parse(readFileSync(join(MESSAGES, "en.json"), "utf8"));
function flatten(obj, prefix, out) {
if (obj == null || typeof obj !== "object") return;
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.push(key);
else if (typeof v === "object") flatten(v, key, out);
}
}
const allKeys = [];
flatten(en, "", allKeys);
console.error(`Loaded ${allKeys.length} keys from en.json`);
function collect(dir, acc) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) {
if (name === "node_modules" || name === ".next" || name === "dist") continue;
collect(p, acc);
continue;
}
const ext = extname(name);
if (
ext === ".ts" ||
ext === ".tsx" ||
ext === ".js" ||
ext === ".jsx" ||
ext === ".mjs"
) {
acc.push(p);
}
}
}
const files = [];
collect(SRC, files);
const testFiles = [];
try { collect(join(ROOT, "scripts"), testFiles); } catch {}
console.error(`Scanning ${files.length + testFiles.length} src+script files...`);
const corpus = [...files, ...testFiles]
.map((f) => readFileSync(f, "utf8"))
.join("\n");
// Extract backtick literal contents. Naively walk the string to capture
// backtick-delimited bodies. Skip empties.
const backtickBodies = [];
{
let i = 0;
while (i < corpus.length) {
if (corpus[i] === "`") {
i++;
let depth = 0;
let start = i;
while (i < corpus.length) {
const c = corpus[i];
if (c === "\\") { i += 2; continue; }
if (c === "$" && corpus[i + 1] === "{") { depth++; i += 2; continue; }
if (c === "}" && depth > 0) { depth--; i++; continue; }
if (c === "`" && depth === 0) break;
i++;
}
backtickBodies.push(corpus.slice(start, i));
if (corpus[i] === "`") i++;
continue;
}
i++;
}
}
// For each backtick body that LOOKS LIKE an i18n key (has dots and starts
// with a known top-level namespace), build a regex by replacing ${...}
// with `[^\s\\`]*` and matching against every dotted key.
const TOPLEVELS = new Set(Object.keys(en));
const templatePatterns = [];
for (const body of backtickBodies) {
if (!body.includes("${")) continue;
// The body must start with an i18n top-level namespace + dot.
const firstWordMatch = body.match(/^([a-zA-Z][a-zA-Z0-9_]*)/);
if (!firstWordMatch) continue;
const ns = firstWordMatch[1];
if (!TOPLEVELS.has(ns)) continue;
if (!body.includes(".")) continue;
// Build regex.
const escaped = body
.replace(/[.+?^()|[\]\\]/g, (c) => "\\" + c)
.replace(/\$\{[^}]+\}/g, "[a-zA-Z0-9_\\-]+");
try {
templatePatterns.push(new RegExp("^" + escaped + "$"));
} catch {}
}
console.error(`Found ${templatePatterns.length} templated key patterns.`);
const deadKeys = [];
const liveKeys = [];
for (const key of allKeys) {
// 1) Direct quoted hit.
if (
corpus.includes(`"${key}"`) ||
corpus.includes(`'${key}'`) ||
corpus.includes(`\`${key}\``)
) {
liveKeys.push(key);
continue;
}
// 2) Template pattern match.
let hit = false;
for (const re of templatePatterns) {
if (re.test(key)) { hit = true; break; }
}
if (hit) {
liveKeys.push(key);
continue;
}
deadKeys.push(key);
}
console.error(`Live keys: ${liveKeys.length}`);
console.error(`Dead key candidates: ${deadKeys.length}`);
for (const k of deadKeys.sort()) console.log(k);
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// v1.4.27 B6 — commit 1.
// Strips the 152 dead keys (one-shot probe output) from every locale
// bundle, plus the directive-listed extras:
// - dashboard.insightsPreview
// - insights.aiInsights
// - insights.healthScore.askCoach
// - insights.healthScore.coachPrompt
// The first two are already in the probe output. The two healthScore.*
// keys live under a parent that's still live, so we drop them explicitly.
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dirname ?? new URL(".", import.meta.url).pathname, "..");
const MESSAGES = join(ROOT, "messages");
const DEAD_LIST = readFileSync("/tmp/dead-keys.txt", "utf8")
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const EXTRA_DROP = [
"insights.healthScore.askCoach",
"insights.healthScore.coachPrompt",
];
const allDead = new Set([...DEAD_LIST, ...EXTRA_DROP]);
function removeKey(obj, dotted) {
const parts = dotted.split(".");
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (cur == null || typeof cur !== "object") return false;
cur = cur[parts[i]];
}
if (cur == null || typeof cur !== "object") return false;
const last = parts[parts.length - 1];
if (!(last in cur)) return false;
delete cur[last];
return true;
}
function pruneEmptyContainers(obj) {
if (obj == null || typeof obj !== "object") return false;
let pruned = false;
for (const k of Object.keys(obj)) {
if (obj[k] != null && typeof obj[k] === "object" && !Array.isArray(obj[k])) {
pruneEmptyContainers(obj[k]);
if (Object.keys(obj[k]).length === 0) {
delete obj[k];
pruned = true;
}
}
}
return pruned;
}
const files = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
let totalRemovals = 0;
const perLocale = {};
for (const f of files) {
const path = join(MESSAGES, f);
const data = JSON.parse(readFileSync(path, "utf8"));
let removed = 0;
for (const key of allDead) {
if (removeKey(data, key)) removed++;
}
// Prune empty parent objects (repeat until stable).
while (pruneEmptyContainers(data)) {
// intentionally empty — outer loop runs the pruner.
}
perLocale[f] = removed;
totalRemovals += removed;
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
}
console.log(`Total dead keys removed (sum across locales): ${totalRemovals}`);
for (const [f, n] of Object.entries(perLocale)) {
console.log(` ${f}: ${n}`);
}
console.log(`Unique dead-key paths: ${allDead.size}`);
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
// v1.4.27 B6 — commit 2.
// Add locale-native date format ordering as i18n strings.
// Each bundle documents its native ordering for date/time format
// patterns — DE/EN keep their existing ordering, FR/ES/IT use
// {day}/{month}/{year}, PL uses {day}.{month}.{year}.
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dirname ?? new URL(".", import.meta.url).pathname, "..");
const MESSAGES = join(ROOT, "messages");
const FORMATS = {
de: {
dateShort: "{day}.{month}.{year}",
timeShort: "{hour}:{minute}",
dateTime: "{day}.{month}.{year} {hour}:{minute}",
},
en: {
dateShort: "{month}/{day}/{year}",
timeShort: "{hour}:{minute}",
dateTime: "{month}/{day}/{year} {hour}:{minute}",
},
fr: {
dateShort: "{day}/{month}/{year}",
timeShort: "{hour}:{minute}",
dateTime: "{day}/{month}/{year} {hour}:{minute}",
},
es: {
dateShort: "{day}/{month}/{year}",
timeShort: "{hour}:{minute}",
dateTime: "{day}/{month}/{year} {hour}:{minute}",
},
it: {
dateShort: "{day}/{month}/{year}",
timeShort: "{hour}:{minute}",
dateTime: "{day}/{month}/{year} {hour}:{minute}",
},
pl: {
dateShort: "{day}.{month}.{year}",
timeShort: "{hour}:{minute}",
dateTime: "{day}.{month}.{year} {hour}:{minute}",
},
};
for (const [locale, fmt] of Object.entries(FORMATS)) {
const path = join(MESSAGES, `${locale}.json`);
const data = JSON.parse(readFileSync(path, "utf8"));
// Insert `format` namespace at the top of the object after `common`,
// preserving JSON object ordering by rebuilding.
const reordered = {};
for (const [k, v] of Object.entries(data)) {
reordered[k] = v;
if (k === "common") {
reordered.format = fmt;
}
}
if (!("format" in reordered)) reordered.format = fmt;
writeFileSync(path, JSON.stringify(reordered, null, 2) + "\n", "utf8");
console.log(`${locale}.json: added format namespace`);
}
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env node
// v1.4.27 B6 — commit 3.
// Add the new keys referenced by B1/B3/B4/B5 across all six bundles.
// Also drop the now-dead `charts.moodLabel1..5` keys, retired by the
// mood-chart shared-label-source refactor in the same commit.
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dirname ?? new URL(".", import.meta.url).pathname, "..");
const MESSAGES = join(ROOT, "messages");
// === New keys ===
// Key shape: dotted path → per-locale string.
// Inputs verified against B1/B3/B4/B5 call-sites:
// - src/components/dashboard/glp1-tile.tsx (B1)
// - src/components/admin/login-overview-section.tsx (B3)
// - src/app/insights/{bmi,blutdruck,gewicht,medikamente,puls,schlaf,stimmung}/page.tsx (B4)
// - src/app/api/admin/notifications/{reminder-check,test}/route.ts (B5)
// - src/app/api/internal/deploy-webhook/route.ts (B5)
// - src/app/api/settings/telegram/test/route.ts (B5)
const NEW_KEYS = {
// ---- B1 GLP-1 dashboard tile ----
"dashboard.glp1.tabLevel": {
de: "Spiegel",
en: "Level",
fr: "Taux",
es: "Nivel",
it: "Livello",
pl: "Poziom",
},
"dashboard.glp1.tabWeight": {
de: "Gewicht",
en: "Weight",
fr: "Poids",
es: "Peso",
it: "Peso",
pl: "Waga",
},
"dashboard.glp1.tabsAria": {
de: "Wirkstoffspiegel oder Gewichtsverlauf anzeigen",
en: "Switch between drug-level and weight views",
fr: "Basculer entre la vue taux et la vue poids",
es: "Cambiar entre vista de nivel y vista de peso",
it: "Passa tra vista del livello e vista del peso",
pl: "Przełącz między widokiem poziomu i widokiem wagi",
},
"dashboard.glp1.rangeStripLabel": {
de: "Zeitraum auswählen",
en: "Select range",
fr: "Sélectionner la période",
es: "Seleccionar rango",
it: "Seleziona intervallo",
pl: "Wybierz zakres",
},
"dashboard.glp1.levelUnavailable": {
de: "Spiegel noch nicht verfügbar — Dosis loggen, dann erscheint die Kurve.",
en: "Drug level not yet available — log a dose and the curve will appear.",
fr: "Taux indisponible — enregistrez une dose pour afficher la courbe.",
es: "Nivel no disponible aún — registra una dosis y la curva aparecerá.",
it: "Livello non ancora disponibile — registra una dose e la curva apparirà.",
pl: "Poziom jeszcze niedostępny — zarejestruj dawkę, a krzywa się pojawi.",
},
"dashboard.glp1.weightUnavailable": {
de: "Gewichtsverlauf noch nicht verfügbar — Messung erfassen, um den Verlauf zu sehen.",
en: "Weight trend not yet available — log a measurement to see the trend.",
fr: "Tendance de poids indisponible — enregistrez une mesure pour la voir.",
es: "Tendencia de peso no disponible aún — registra una medición para verla.",
it: "Andamento del peso non ancora disponibile — registra una misurazione per vederlo.",
pl: "Trend wagi jeszcze niedostępny — zarejestruj pomiar, aby go zobaczyć.",
},
// ---- B3 admin carrier chip ----
"admin.carrier": {
de: "Anbieter",
en: "Carrier",
fr: "Opérateur",
es: "Operador",
it: "Operatore",
pl: "Operator",
},
"admin.carrierUnknown": {
de: "Unbekannter Anbieter",
en: "Unknown carrier",
fr: "Opérateur inconnu",
es: "Operador desconocido",
it: "Operatore sconosciuto",
pl: "Nieznany operator",
},
// ---- B4 insights empty states ----
"insights.emptyState.bloodPressure.title": {
de: "Noch keine Blutdruckwerte",
en: "No blood pressure entries yet",
fr: "Aucune mesure de tension artérielle",
es: "Aún no hay registros de presión arterial",
it: "Nessuna misurazione della pressione",
pl: "Brak pomiarów ciśnienia krwi",
},
"insights.emptyState.bloodPressure.description": {
de: "Sobald du Blutdruck loggst, erscheinen hier Trend, Zielbereichs-Anteil und WHO-Klassifizierung.",
en: "Log a blood pressure reading and the trend chart, in-range share, and WHO classification will appear here.",
fr: "Enregistrez une mesure de tension : la tendance, la part dans la cible et la classification OMS s'afficheront ici.",
es: "Registra una medición de presión y aquí aparecerán la tendencia, el porcentaje en rango y la clasificación de la OMS.",
it: "Registra una misurazione e qui appariranno l'andamento, la percentuale in target e la classificazione OMS.",
pl: "Zarejestruj pomiar ciśnienia, a pojawi się tu trend, udział w zakresie docelowym i klasyfikacja WHO.",
},
"insights.emptyState.bloodPressure.cta": {
de: "Blutdruck loggen",
en: "Log blood pressure",
fr: "Enregistrer une tension",
es: "Registrar presión",
it: "Registra pressione",
pl: "Zarejestruj ciśnienie",
},
"insights.emptyState.weight.title": {
de: "Noch keine Gewichtswerte",
en: "No weight entries yet",
fr: "Aucune mesure de poids",
es: "Aún no hay registros de peso",
it: "Nessuna misurazione del peso",
pl: "Brak pomiarów wagi",
},
"insights.emptyState.weight.description": {
de: "Logge ein Gewicht, und Verlauf, gleitender Durchschnitt und Zielbereich erscheinen automatisch.",
en: "Log a weight and the trend, moving average, and target band will appear automatically.",
fr: "Enregistrez un poids : la tendance, la moyenne glissante et la zone cible apparaîtront automatiquement.",
es: "Registra un peso y aparecerán automáticamente la tendencia, la media móvil y la zona objetivo.",
it: "Registra un peso: andamento, media mobile e fascia obiettivo appariranno automaticamente.",
pl: "Zarejestruj wagę, a trend, średnia krocząca i zakres docelowy pojawią się automatycznie.",
},
"insights.emptyState.weight.cta": {
de: "Gewicht loggen",
en: "Log weight",
fr: "Enregistrer un poids",
es: "Registrar peso",
it: "Registra peso",
pl: "Zarejestruj wagę",
},
"insights.emptyState.pulse.title": {
de: "Noch keine Pulswerte",
en: "No pulse entries yet",
fr: "Aucune mesure de pouls",
es: "Aún no hay registros de pulso",
it: "Nessuna misurazione del polso",
pl: "Brak pomiarów tętna",
},
"insights.emptyState.pulse.description": {
de: "Logge einen Puls, dann erscheinen Ruhepuls, HRV und VO₂ max in dieser Ansicht.",
en: "Log a pulse reading and resting pulse, HRV and VO₂ max will appear in this view.",
fr: "Enregistrez un pouls : pouls au repos, VFC et VO₂ max apparaîtront dans cette vue.",
es: "Registra un pulso y aquí aparecerán pulso en reposo, VFC y VO₂ máx.",
it: "Registra una misurazione: polso a riposo, HRV e VO₂ max appariranno in questa vista.",
pl: "Zarejestruj tętno, a w tym widoku pojawi się tętno spoczynkowe, HRV i VO₂ max.",
},
"insights.emptyState.pulse.cta": {
de: "Puls loggen",
en: "Log pulse",
fr: "Enregistrer un pouls",
es: "Registrar pulso",
it: "Registra polso",
pl: "Zarejestruj tętno",
},
"insights.emptyState.bmi.title": {
de: "BMI noch nicht berechnet",
en: "BMI not yet computed",
fr: "IMC pas encore calculé",
es: "IMC aún no calculado",
it: "BMI non ancora calcolato",
pl: "BMI jeszcze nie obliczone",
},
"insights.emptyState.bmi.description": {
de: "Sobald Größe und mindestens ein Gewichtswert vorliegen, erscheint hier der BMI-Verlauf samt WHO-Zonen.",
en: "Once height and at least one weight entry are present, the BMI trend with WHO zones will appear here.",
fr: "Dès qu'une taille et au moins un poids sont enregistrés, la tendance IMC avec les zones OMS s'affichera ici.",
es: "Cuando haya altura y al menos un registro de peso, aquí aparecerá la tendencia del IMC con las zonas de la OMS.",
it: "Una volta inseriti altezza e almeno un peso, qui apparirà l'andamento del BMI con le zone OMS.",
pl: "Po wpisaniu wzrostu i co najmniej jednej wagi pojawi się tu trend BMI z zakresami WHO.",
},
"insights.emptyState.bmi.cta": {
de: "Gewicht loggen",
en: "Log weight",
fr: "Enregistrer un poids",
es: "Registrar peso",
it: "Registra peso",
pl: "Zarejestruj wagę",
},
"insights.emptyState.mood.title": {
de: "Noch keine Stimmungseinträge",
en: "No mood entries yet",
fr: "Aucune entrée d'humeur",
es: "Aún no hay entradas de estado de ánimo",
it: "Nessuna voce d'umore",
pl: "Brak wpisów nastroju",
},
"insights.emptyState.mood.description": {
de: "Logge eine Stimmung, dann erscheinen Verlauf, Stabilitätsband und Korrelationen.",
en: "Log a mood entry and the trend, stability band and correlations will appear.",
fr: "Enregistrez une humeur : la tendance, la bande de stabilité et les corrélations s'afficheront.",
es: "Registra un estado de ánimo y aparecerán la tendencia, la banda de estabilidad y las correlaciones.",
it: "Registra un umore: appariranno andamento, fascia di stabilità e correlazioni.",
pl: "Zarejestruj nastrój, a pojawi się trend, pasmo stabilności i korelacje.",
},
"insights.emptyState.mood.cta": {
de: "Stimmung loggen",
en: "Log mood",
fr: "Enregistrer une humeur",
es: "Registrar ánimo",
it: "Registra umore",
pl: "Zarejestruj nastrój",
},
"insights.emptyState.medication.title": {
de: "Noch keine Medikamente angelegt",
en: "No medications set up yet",
fr: "Aucun médicament configuré",
es: "Aún no hay medicamentos configurados",
it: "Nessun farmaco configurato",
pl: "Brak skonfigurowanych leków",
},
"insights.emptyState.medication.description": {
de: "Lege ein Medikament an, dann erscheinen Einnahme-Compliance, Wirkstoffspiegel und Eskalations-Verlauf.",
en: "Set up a medication and intake compliance, drug level and titration timeline will appear here.",
fr: "Configurez un médicament : observance, taux plasmatique et titration apparaîtront ici.",
es: "Configura un medicamento y aquí aparecerán la adherencia, el nivel plasmático y la titulación.",
it: "Configura un farmaco: aderenza, livello plasmatico e titolazione appariranno qui.",
pl: "Skonfiguruj lek, a pojawi się adherencja, poziom we krwi i schemat titracji.",
},
"insights.emptyState.medication.cta": {
de: "Medikament hinzufügen",
en: "Add medication",
fr: "Ajouter un médicament",
es: "Añadir medicamento",
it: "Aggiungi farmaco",
pl: "Dodaj lek",
},
"insights.emptyState.sleep.title": {
de: "Noch keine Schlafwerte",
en: "No sleep entries yet",
fr: "Aucune donnée de sommeil",
es: "Aún no hay registros de sueño",
it: "Nessuna voce di sonno",
pl: "Brak danych o śnie",
},
"insights.emptyState.sleep.description": {
de: "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
en: "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
fr: "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par phase apparaîtra ici.",
es: "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
it: "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
pl: "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
},
"insights.emptyState.sleep.cta": {
de: "Withings verbinden",
en: "Connect Withings",
fr: "Connecter Withings",
es: "Conectar Withings",
it: "Collega Withings",
pl: "Połącz Withings",
},
// ---- B5 notifications ----
"notifications.admin.deployFailedTitle": {
de: "Bereitstellung fehlgeschlagen: {application}",
en: "Deployment failed: {application}",
fr: "Échec du déploiement : {application}",
es: "Despliegue fallido: {application}",
it: "Distribuzione fallita: {application}",
pl: "Wdrożenie nie powiodło się: {application}",
},
"notifications.admin.deployFailedBody": {
de: "Bei der Bereitstellung von {application} (#{deployment}) ist ein Fehler aufgetreten: {error}. Logs ansehen: {logsUrl}",
en: "An error occurred while deploying {application} (#{deployment}): {error}. View logs: {logsUrl}",
fr: "Une erreur est survenue lors du déploiement de {application} (#{deployment}) : {error}. Voir les journaux : {logsUrl}",
es: "Se produjo un error al desplegar {application} (#{deployment}): {error}. Ver registros: {logsUrl}",
it: "Si è verificato un errore durante la distribuzione di {application} (#{deployment}): {error}. Vedi log: {logsUrl}",
pl: "Wystąpił błąd podczas wdrażania {application} (#{deployment}): {error}. Zobacz logi: {logsUrl}",
},
"notifications.admin.testNotificationTitle": {
de: "HealthLog Test-Benachrichtigung",
en: "HealthLog test notification",
fr: "HealthLog notification de test",
es: "HealthLog notificación de prueba",
it: "HealthLog notifica di prova",
pl: "HealthLog powiadomienie testowe",
},
"notifications.admin.testNotificationBody": {
de: "Diese Test-Benachrichtigung wurde vom Admin-Panel ausgelöst. Wenn du sie siehst, funktioniert der Kanal.",
en: "This test notification was triggered from the admin panel. If you can see it, the channel is working.",
fr: "Cette notification de test a été déclenchée depuis le panneau d'administration. Si vous la voyez, le canal fonctionne.",
es: "Esta notificación de prueba se activó desde el panel de administración. Si la ves, el canal funciona.",
it: "Questa notifica di prova è stata attivata dal pannello di amministrazione. Se la vedi, il canale funziona.",
pl: "To powiadomienie testowe zostało wywołane z panelu administracyjnego. Jeśli je widzisz, kanał działa.",
},
"notifications.admin.reminderCheckMissedTitle": {
de: "Erinnerung verpasst: {medication}",
en: "Reminder missed: {medication}",
fr: "Rappel manqué : {medication}",
es: "Recordatorio perdido: {medication}",
it: "Promemoria perso: {medication}",
pl: "Pominięte przypomnienie: {medication}",
},
"notifications.admin.reminderCheckMissedBody": {
de: "{medication} ({dose}) im Zeitfenster {window} wurde nicht geloggt.",
en: "{medication} ({dose}) was not logged in the {window} window.",
fr: "{medication} ({dose}) n'a pas été enregistré dans la fenêtre {window}.",
es: "{medication} ({dose}) no se registró en la ventana {window}.",
it: "{medication} ({dose}) non è stato registrato nella finestra {window}.",
pl: "{medication} ({dose}) nie zostało zarejestrowane w przedziale {window}.",
},
"notifications.admin.reminderCheckOverdueTitle": {
de: "Einnahme überfällig: {medication}",
en: "Dose overdue: {medication}",
fr: "Prise en retard : {medication}",
es: "Dosis atrasada: {medication}",
it: "Dose in ritardo: {medication}",
pl: "Dawka opóźniona: {medication}",
},
"notifications.admin.reminderCheckOverdueBody": {
de: "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.",
en: "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.",
fr: "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.",
es: "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.",
it: "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.",
pl: "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.",
},
"notifications.user.telegramTestBody": {
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.",
},
};
// Helper to insert a dotted-path key into a nested object, creating
// missing parents along the way.
function setKey(obj, dotted, value) {
const parts = dotted.split(".");
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in cur) || cur[part] == null || typeof cur[part] !== "object") {
cur[part] = {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function removeKey(obj, dotted) {
const parts = dotted.split(".");
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (cur == null || typeof cur !== "object") return false;
cur = cur[parts[i]];
}
if (cur == null || typeof cur !== "object") return false;
delete cur[parts[parts.length - 1]];
return true;
}
function pruneEmptyContainers(obj) {
if (obj == null || typeof obj !== "object") return false;
let pruned = false;
for (const k of Object.keys(obj)) {
if (obj[k] != null && typeof obj[k] === "object" && !Array.isArray(obj[k])) {
pruneEmptyContainers(obj[k]);
if (Object.keys(obj[k]).length === 0) {
delete obj[k];
pruned = true;
}
}
}
return pruned;
}
const NOW_DEAD = [
"charts.moodLabel1",
"charts.moodLabel2",
"charts.moodLabel3",
"charts.moodLabel4",
"charts.moodLabel5",
];
const locales = ["de", "en", "fr", "es", "it", "pl"];
for (const locale of locales) {
const path = join(MESSAGES, `${locale}.json`);
const data = JSON.parse(readFileSync(path, "utf8"));
for (const [key, perLocale] of Object.entries(NEW_KEYS)) {
setKey(data, key, perLocale[locale]);
}
for (const key of NOW_DEAD) {
removeKey(data, key);
}
while (pruneEmptyContainers(data)) {
// intentionally empty
}
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
}
console.log(`Added ${Object.keys(NEW_KEYS).length} keys × ${locales.length} locales.`);
console.log(`Retired ${NOW_DEAD.length} now-dead chart label keys.`);