mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
22b11427e8
commit
3d3402e3af
5 files changed
+950
No files matched your search
@@ -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).
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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.`);
|
||||
Reference in new issue
Block a user