diff --git a/.planning/round-3-b6-i18n-report.md b/.planning/round-3-b6-i18n-report.md new file mode 100644 index 000000000..c98cbf8d7 --- /dev/null +++ b/.planning/round-3-b6-i18n-report.md @@ -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). diff --git a/.planning/v1427-b6-dead-key-probe.mjs b/.planning/v1427-b6-dead-key-probe.mjs new file mode 100644 index 000000000..2a9aa7cfe --- /dev/null +++ b/.planning/v1427-b6-dead-key-probe.mjs @@ -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); diff --git a/.planning/v1427-b6-dead-key-strip.mjs b/.planning/v1427-b6-dead-key-strip.mjs new file mode 100644 index 000000000..92618361e --- /dev/null +++ b/.planning/v1427-b6-dead-key-strip.mjs @@ -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}`); diff --git a/.planning/v1427-b6-format-keys.mjs b/.planning/v1427-b6-format-keys.mjs new file mode 100644 index 000000000..80131f87c --- /dev/null +++ b/.planning/v1427-b6-format-keys.mjs @@ -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`); +} diff --git a/.planning/v1427-b6-new-keys.mjs b/.planning/v1427-b6-new-keys.mjs new file mode 100644 index 000000000..c280606d7 --- /dev/null +++ b/.planning/v1427-b6-new-keys.mjs @@ -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.`);