mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(dashboard): emit i18n keys instead of hardcoded German strings
The /api/dashboard/summary endpoint returned per-metric `title` and
`unit` as translated German strings ("Gewicht", "Schritte", …), which
injected DE into clients with an English UI. The wire now carries i18n
keys (`titleKey` / `unitKey`) under a new `dashboard.metric.*`
namespace; the PWA's `useTranslations()` and the iOS client's bundled
`Localizable.xcstrings` each resolve against their own locale bundle.
Locale parity preserved across de, en, es, fr, it, pl (DE / EN native,
the rest matched against the existing measurement-type labels). The
locale narrowing in `buildContext` is widened to accept every shipped
locale rather than dropping fr / es / it / pl back to the default — the
greeting + streak label now resolve in the user's configured language.
The OpenAPI yaml drift was a pre-existing 1.5.3 → 1.5.4 version-string
gap; regenerating picked it up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a75b96b2a9
commit
e93feaf8d1
8 files changed
+242
-40
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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<MetricKind, string> = {
|
||||
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<MetricKind, string>`).
|
||||
*/
|
||||
const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
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<MetricKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user