diff --git a/CHANGELOG.md b/CHANGELOG.md index 34dc4e025..43ab7c46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap + +One reminder-adjacent label — the recovery-driver "functional impact" track on +the illness insights card — was showing its internal name instead of a +translation in every language; it is now translated in all six. The class of +bug behind it (a label resolved through a computed key the static check can't +see, so a missing translation ships silently — the same seam as the well-being +labels fixed a release ago) is now closed structurally: a new test walks each +such key space against every language bundle, so a missing translation fails +the build instead of reaching a user. + ## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number The per-metric insight assessments read like a data readout — they opened on diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01f0fd0ae..08022cd1a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.40 + version: 1.28.41 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index b44c2efcb..85b1f7bf8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_TEMPERATURE": "Körpertemperatur", - "SKIN_TEMPERATURE": "Hauttemperatur", "RESPIRATORY_RATE": "Atemfrequenz", "BLOOD_PRESSURE_SYS": "Systolischer Blutdruck", "BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Funktionelle Beeinträchtigung" }, "listLoadError": "Episoden konnten nicht geladen werden." }, diff --git a/messages/en.json b/messages/en.json index 312ee4ee7..f7f780413 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_TEMPERATURE": "Body temperature", - "SKIN_TEMPERATURE": "Skin temperature", "RESPIRATORY_RATE": "Respiratory rate", "BLOOD_PRESSURE_SYS": "Systolic blood pressure", "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Functional impact" }, "listLoadError": "Could not load your episodes." }, diff --git a/messages/es.json b/messages/es.json index d0a4e9700..40507a47b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_TEMPERATURE": "Temperatura corporal", - "SKIN_TEMPERATURE": "Temperatura de la piel", "RESPIRATORY_RATE": "Frecuencia respiratoria", "BLOOD_PRESSURE_SYS": "Presión arterial sistólica", "BLOOD_PRESSURE_DIA": "Presión arterial diastólica", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Impacto funcional" }, "listLoadError": "No se pudieron cargar tus episodios." }, diff --git a/messages/fr.json b/messages/fr.json index 4e444d565..4191e0ab3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_TEMPERATURE": "Température corporelle", - "SKIN_TEMPERATURE": "Température cutanée", "RESPIRATORY_RATE": "Fréquence respiratoire", "BLOOD_PRESSURE_SYS": "Pression artérielle systolique", "BLOOD_PRESSURE_DIA": "Pression artérielle diastolique", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Retentissement fonctionnel" }, "listLoadError": "Impossible de charger vos épisodes." }, diff --git a/messages/it.json b/messages/it.json index 42b207b9c..3cea4cbb7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_TEMPERATURE": "Temperatura corporea", - "SKIN_TEMPERATURE": "Temperatura cutanea", "RESPIRATORY_RATE": "Frequenza respiratoria", "BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica", "BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Impatto funzionale" }, "listLoadError": "Impossibile caricare gli episodi." }, diff --git a/messages/pl.json b/messages/pl.json index 8122f13f0..c0fe16053 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7751,12 +7751,12 @@ "RECOVERY_SCORE": "Recovery", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_TEMPERATURE": "Temperatura ciała", - "SKIN_TEMPERATURE": "Temperatura skóry", "RESPIRATORY_RATE": "Częstość oddechów", "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe", "PULSE": "Pulse", - "WEIGHT": "Weight" + "WEIGHT": "Weight", + "FUNCTIONAL_IMPACT": "Wpływ na funkcjonowanie" }, "listLoadError": "Nie udało się załadować epizodów." }, diff --git a/package.json b/package.json index 4b68b3f0e..9b6ba4312 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.40", + "version": "1.28.41", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index a5b91f84e..0ed3acd53 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.40"; + /* @sw-version-fallback */ "v1.28.41"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts new file mode 100644 index 000000000..d5d2af4ac --- /dev/null +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + ILLNESS_SCAN_TYPES, + FUNCTIONAL_IMPACT_RETURN_KEY, +} from "@/lib/illness/correlation"; +import { + illnessTypeEnum, + illnessLifecycleEnum, +} from "@/lib/validations/illness"; +import { + allergyCategoryEnum, + allergyTypeEnum, + allergySeverityEnum, + allergyStatusEnum, +} from "@/lib/validations/allergy"; +import { familyRelationshipEnum } from "@/lib/validations/family-history"; +import { INSTRUMENTS } from "@/lib/mental-health/instruments"; + +/** + * Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys. + * + * The three existing i18n guards each see one face of the problem and miss the + * fourth: + * - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls, + * so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it. + * - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any + * `ns.${x}` template as covering its whole subtree — so a MEMBER missing + * from the subtree is invisible. + * - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only + * fires when a key is present in SOME locale but not all. + * + * None of them assert the class that actually ships broken: "for enum E, every + * member's dynamically-built key resolves, non-empty, in every locale." Because + * parity is perfect, a key missing from ONE locale is missing from ALL SIX at + * once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE, + * SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT` + * (v1.28.41) ship absent from every bundle. + * + * This test derives the EXPECTED key set from the SOURCE enum/const and asserts + * every `prefix + member` resolves. Adding an enum member without a bundle key + * fails CI by construction — no hand-maintained floor to drift. + * + * ── Covered key spaces (source is a clean, finite, import-safe enum/const) ── + * - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY + * - illness.type.* illnessTypeEnum + * - illness.lifecycle.* illnessLifecycleEnum + * - records.allergies.category.* allergyCategoryEnum + * - records.allergies.type.* allergyTypeEnum + * - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel + * - records.allergies.status.* allergyStatusEnum + * - records.family.relationship.* familyRelationshipEnum + * - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs + * - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI — + * - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before) + * + * ── Deliberately EXCLUDED (would produce false positives) ── + * - `insights.workouts.sport.*` — the Strava render path stores a raw + * `sport_type` and the render site legitimately falls back to that raw + * string, so a "missing" key is a graceful degradation, not a broken token + * (audit LOW #2). Asserting it would fail on a path that is by-design lax. + * - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`, + * `insights..emptyState.cta` — structurally unreachable behind a + * tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW). + * Registering them would demand bundle keys for code paths users never hit. + * - `measurementReminders.types.*` — already exhaustively covered by + * `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source + * const lives in a client component, not a lib-level export. + * - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean + * enums the audit flags as candidates; left for a follow-up extension. Add + * them here once their exact prefix→member mapping is confirmed 1:1 (the + * cycle cervix* prefixes and catalog slug maps need per-space verification + * to stay false-positive-free). + */ + +const MESSAGES_DIR = join(__dirname, "../../messages"); + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + locale: f.replace(/\.json$/, ""), + messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record< + string, + unknown + >, + })); + +/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */ +const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey); + +interface KeySpace { + /** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */ + prefix: string; + /** The finite member set, derived from the SOURCE enum/const. */ + members: readonly string[]; +} + +const REGISTRY: readonly KeySpace[] = [ + // illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties). + { + prefix: "illness.vital", + members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY], + }, + { prefix: "illness.type", members: illnessTypeEnum.options }, + { prefix: "illness.lifecycle", members: illnessLifecycleEnum.options }, + + // records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel. + { + prefix: "records.allergies.category", + members: allergyCategoryEnum.options, + }, + { prefix: "records.allergies.type", members: allergyTypeEnum.options }, + { + prefix: "records.allergies.severity", + members: [...allergySeverityEnum.options, "NONE"], + }, + { prefix: "records.allergies.status", members: allergyStatusEnum.options }, + + // records.family.relationship.* + { + prefix: "records.family.relationship", + members: familyRelationshipEnum.options, + }, + + // mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard. + { prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS }, + { prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS }, + { prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS }, +]; + +describe("dynamic-key exhaustiveness (enum-derived)", () => { + it("discovers all six shipped locales", () => { + expect(LOCALES.map((l) => l.locale).sort()).toEqual([ + "de", + "en", + "es", + "fr", + "it", + "pl", + ]); + }); + + it("every registered key space has a non-empty member set", () => { + for (const space of REGISTRY) { + expect( + space.members.length, + `${space.prefix} resolved to an empty member set — source import broke`, + ).toBeGreaterThan(0); + } + }); + + for (const { locale, messages } of LOCALES) { + for (const space of REGISTRY) { + for (const member of space.members) { + const key = `${space.prefix}.${member}`; + it(`resolves ${key} in ${locale}`, () => { + const value = resolveKey(messages, key); + expect(value, `${key} missing in ${locale}.json`).toBeTypeOf( + "string", + ); + expect((value ?? "").trim().length).toBeGreaterThan(0); + }); + } + } + } +});