diff --git a/messages/de.json b/messages/de.json index 1c182f546..7d58514cd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -294,6 +294,32 @@ "title": "Noch keine Workouts", "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." } + }, + "metric": { + "title": { + "weight": "Gewicht", + "bloodPressure": "Blutdruck", + "pulse": "Puls", + "bodyFat": "Körperfett", + "glucose": "Blutzucker", + "sleep": "Schlaf", + "steps": "Schritte", + "totalBodyWater": "Gesamtkörperwasser", + "boneMass": "Knochenmasse", + "oxygenSaturation": "Sauerstoffsättigung" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "Schritte", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/messages/en.json b/messages/en.json index 5eca14859..3b5a181be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -294,6 +294,32 @@ "title": "No workouts yet", "cta": "Open Apple Health on your iPhone to sync workouts here." } + }, + "metric": { + "title": { + "weight": "Weight", + "bloodPressure": "Blood Pressure", + "pulse": "Pulse", + "bodyFat": "Body Fat", + "glucose": "Blood Glucose", + "sleep": "Sleep", + "steps": "Steps", + "totalBodyWater": "Total Body Water", + "boneMass": "Bone Mass", + "oxygenSaturation": "Oxygen Saturation" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "steps", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/messages/es.json b/messages/es.json index 1e5092520..0dd400697 100644 --- a/messages/es.json +++ b/messages/es.json @@ -294,6 +294,32 @@ "title": "Aún no hay entrenamientos", "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." } + }, + "metric": { + "title": { + "weight": "Peso", + "bloodPressure": "Presión arterial", + "pulse": "Pulso", + "bodyFat": "Grasa corporal", + "glucose": "Glucemia", + "sleep": "Sueño", + "steps": "Pasos", + "totalBodyWater": "Agua corporal total", + "boneMass": "Masa ósea", + "oxygenSaturation": "Saturación de oxígeno" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "pasos", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 7e3d58c54..19e3d926b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -294,6 +294,32 @@ "title": "Aucun entraînement", "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." } + }, + "metric": { + "title": { + "weight": "Poids", + "bloodPressure": "Tension artérielle", + "pulse": "Pouls", + "bodyFat": "Masse grasse", + "glucose": "Glycémie", + "sleep": "Sommeil", + "steps": "Pas", + "totalBodyWater": "Eau corporelle totale", + "boneMass": "Masse osseuse", + "oxygenSaturation": "Saturation en oxygène" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "pas", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/messages/it.json b/messages/it.json index ca42fbf5c..10dde6625 100644 --- a/messages/it.json +++ b/messages/it.json @@ -294,6 +294,32 @@ "title": "Nessun allenamento", "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." } + }, + "metric": { + "title": { + "weight": "Peso", + "bloodPressure": "Pressione arteriosa", + "pulse": "Polso", + "bodyFat": "Massa grassa", + "glucose": "Glicemia", + "sleep": "Sonno", + "steps": "Passi", + "totalBodyWater": "Acqua corporea totale", + "boneMass": "Massa ossea", + "oxygenSaturation": "Saturazione di ossigeno" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "passi", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 9218da715..9ffd003b4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -294,6 +294,32 @@ "title": "Brak treningów", "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." } + }, + "metric": { + "title": { + "weight": "Waga", + "bloodPressure": "Ciśnienie krwi", + "pulse": "Tętno", + "bodyFat": "Tkanka tłuszczowa", + "glucose": "Glikemia", + "sleep": "Sen", + "steps": "Kroki", + "totalBodyWater": "Całkowita woda w organizmie", + "boneMass": "Masa kostna", + "oxygenSaturation": "Saturacja tlenem" + }, + "unit": { + "weight": "kg", + "bloodPressure": "mmHg", + "pulse": "bpm", + "bodyFat": "%", + "glucose": "mg/dL", + "sleep": "h", + "steps": "kroki", + "totalBodyWater": "kg", + "boneMass": "kg", + "oxygenSaturation": "%" + } } }, "measurements": { diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 17b47abd1..c1c2a0c41 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -131,6 +131,14 @@ describe("GET /api/dashboard/summary", () => { expect(body.data.metrics).toEqual( expect.arrayContaining([expect.objectContaining({ id: "weight" })]), ); + // v1.5.x — wire shape carries i18n keys, not translated strings. + // The previous shape emitted "Gewicht" / "kg" / "Schritte" verbatim, + // which injected DE into an English iOS UI. + const weightCard = body.data.metrics.find((m) => m.id === "weight") as + | { titleKey: string; unitKey: string } + | undefined; + expect(weightCard?.titleKey).toBe("dashboard.metric.title.weight"); + expect(weightCard?.unitKey).toBe("dashboard.metric.unit.weight"); expect( body.data.metrics.find((m) => m.id === "bp"), "bp must not emit when allTimeCount === 0 (REG-11)", diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index cf6e7cbbf..015e00991 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -53,7 +53,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; @@ -76,10 +76,26 @@ type MetricKind = interface MetricCard { id: string; kind: MetricKind; - title: string; + /** + * v1.5.x — i18n key for the metric's display title (e.g. + * `dashboard.metric.title.weight`). Clients resolve the key against + * their own locale bundle: the PWA via `useTranslations()`, the iOS + * client via its bundled `Localizable.xcstrings`. The previous wire + * shape carried a German string literal which injected DE into an + * English-locale iOS UI. + */ + titleKey: string; latestValue: number | null; secondaryValue: number | null; - unit: string; + /** + * v1.5.x — i18n key for the metric's display unit (e.g. + * `dashboard.metric.unit.weight`). Same resolution contract as + * `titleKey`. SI tokens like `kg`, `mmHg`, `bpm`, `mg/dL` are + * identical across every shipped locale; per-kind keys keep the + * door open for locale-specific overrides (e.g. Imperial units) + * without another wire-shape change. + */ + unitKey: string; trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; @@ -96,37 +112,50 @@ interface MetricCard { * v1.4.33 maintainer-item-1 — ISO timestamp of the metric's single * most recent reading. When `allTimeCount > 0` but the latest * reading is older than 7 days, the iOS tile renders a muted - * "Letzter Wert vor Xd" caption so the user understands the value - * isn't stale silently. `null` when the metric has no readings at - * all. + * "last reading N days ago" caption so the user understands the + * value isn't stale silently. `null` when the metric has no readings + * at all. */ lastSeenAt: string | null; } -const METRIC_TITLES: Record = { - weight: "Gewicht", - bloodPressure: "Blutdruck", - pulse: "Puls", - bodyFat: "Körperfett", - glucose: "Blutzucker", - sleep: "Schlaf", - steps: "Schritte", - totalBodyWater: "Gesamtkörperwasser", - boneMass: "Knochenmasse", - oxygenSaturation: "Sauerstoffsättigung", +/** + * v1.5.x — wire i18n keys, not translated strings. + * + * The legacy maps emitted German literals like "Gewicht" / "Schritte" + * which injected into an English iOS UI once the iOS app flipped its + * source language. Clients now resolve these keys against their own + * locale bundle (the PWA via `useTranslations()`, iOS via + * `Localizable.xcstrings`). The server payload becomes language-neutral + * and the response shrinks slightly. + * + * One key per `MetricKind` — symmetric maps so a new kind has to add + * both a title and a unit key (Type-checked: `Record`). + */ +const METRIC_TITLE_KEYS: Record = { + weight: "dashboard.metric.title.weight", + bloodPressure: "dashboard.metric.title.bloodPressure", + pulse: "dashboard.metric.title.pulse", + bodyFat: "dashboard.metric.title.bodyFat", + glucose: "dashboard.metric.title.glucose", + sleep: "dashboard.metric.title.sleep", + steps: "dashboard.metric.title.steps", + totalBodyWater: "dashboard.metric.title.totalBodyWater", + boneMass: "dashboard.metric.title.boneMass", + oxygenSaturation: "dashboard.metric.title.oxygenSaturation", }; -const METRIC_UNITS: Record = { - weight: "kg", - bloodPressure: "mmHg", - pulse: "bpm", - bodyFat: "%", - glucose: "mg/dL", - sleep: "h", - steps: "Schritte", - totalBodyWater: "kg", - boneMass: "kg", - oxygenSaturation: "%", +const METRIC_UNIT_KEYS: Record = { + weight: "dashboard.metric.unit.weight", + bloodPressure: "dashboard.metric.unit.bloodPressure", + pulse: "dashboard.metric.unit.pulse", + bodyFat: "dashboard.metric.unit.bodyFat", + glucose: "dashboard.metric.unit.glucose", + sleep: "dashboard.metric.unit.sleep", + steps: "dashboard.metric.unit.steps", + totalBodyWater: "dashboard.metric.unit.totalBodyWater", + boneMass: "dashboard.metric.unit.boneMass", + oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", }; function trendOf(values: number[]): MetricCard["trend"] { @@ -287,8 +316,17 @@ function buildContext( user: { displayName: string | null; username: string; locale: string | null }, ): SummaryBuilderContext { const greetingName = user.displayName ?? user.username; - const locale: Locale = - user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale; + // v1.5.x — accept every shipped locale (de / en / es / fr / it / pl) + // so the greeting + streak label resolve against the user's + // configured language. The legacy narrowing dropped fr/es/it/pl back + // to the default; that bug is masked for the title/unit fields by + // the new key-based wire shape but still mattered for the strings + // that stay translated server-side. + const locale: Locale = (locales as readonly string[]).includes( + user.locale ?? "", + ) + ? (user.locale as Locale) + : defaultLocale; return { greetingName, locale }; } @@ -568,10 +606,10 @@ async function buildDashboardSummary( metrics.push({ id: "weight", kind: "weight", - title: METRIC_TITLES.weight, + titleKey: METRIC_TITLE_KEYS.weight, latestValue: latest?.value ?? null, secondaryValue: null, - unit: METRIC_UNITS.weight, + unitKey: METRIC_UNIT_KEYS.weight, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -606,10 +644,10 @@ async function buildDashboardSummary( metrics.push({ id: "bp", kind: "bloodPressure", - title: METRIC_TITLES.bloodPressure, + titleKey: METRIC_TITLE_KEYS.bloodPressure, latestValue: latestSys?.value ?? null, secondaryValue: latestDia?.value ?? null, - unit: METRIC_UNITS.bloodPressure, + unitKey: METRIC_UNIT_KEYS.bloodPressure, trend: trendOf(sysSpark), sparkline: sysSpark, updatedAt: @@ -633,10 +671,10 @@ async function buildDashboardSummary( metrics.push({ id: "pulse", kind: "pulse", - title: METRIC_TITLES.pulse, + titleKey: METRIC_TITLE_KEYS.pulse, latestValue: latest?.value ?? null, secondaryValue: null, - unit: METRIC_UNITS.pulse, + unitKey: METRIC_UNIT_KEYS.pulse, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -658,10 +696,10 @@ async function buildDashboardSummary( metrics.push({ id: "bodyFat", kind: "bodyFat", - title: METRIC_TITLES.bodyFat, + titleKey: METRIC_TITLE_KEYS.bodyFat, latestValue: latest?.value ?? null, secondaryValue: null, - unit: METRIC_UNITS.bodyFat, + unitKey: METRIC_UNIT_KEYS.bodyFat, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, @@ -692,10 +730,10 @@ async function buildDashboardSummary( metrics.push({ id: kind, kind, - title: METRIC_TITLES[kind], + titleKey: METRIC_TITLE_KEYS[kind], latestValue: latest?.value ?? null, secondaryValue: null, - unit: METRIC_UNITS[kind], + unitKey: METRIC_UNIT_KEYS[kind], trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,