mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge pull request #498 from MBombeck/release/v1.28.41
release/v1.28.41 — complete the translations, guard the gap
This commit is contained in:
11 files changed
+195
-15
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+2
-2
@@ -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."
|
||||
},
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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.<metric>.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user