From 0548c0265e690af6fb68a221c9eaa55bafab29b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 12:08:31 +0200 Subject: [PATCH 1/2] Label every ingest source instead of leaking its enum value Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their internal name: the catalogue was written for seven sources and every provider added since fell through to the raw value. The measurement list carried the same fall-through in its source filter and its table column, and the medication intake history read "via APPLE_HEALTH" for a dose synced from Apple Health. The three catalogues move into one module keyed by the enum, so an omission is a type error rather than a raw value on screen, and the sleep discrepancy tooltip drops the private brand-name map it had grown to work around the gap. --- CHANGELOG.md | 17 ++++ messages/de.json | 17 +++- messages/en.json | 17 +++- messages/es.json | 17 +++- messages/fr.json | 17 +++- messages/it.json | 17 +++- messages/ko.json | 17 +++- messages/pl.json | 17 +++- src/__tests__/i18n-english-leak-guard.test.ts | 3 + .../sleep-source-discrepancy-marker.tsx | 39 ++------ .../measurements/measurement-list.tsx | 24 ++--- .../medications/intake-history-list-v2.tsx | 32 ++++--- src/components/settings/sources-section.tsx | 16 +--- .../__tests__/i18n-locale-integrity.test.ts | 3 + src/lib/i18n/source-labels.ts | 94 +++++++++++++++++++ 15 files changed, 269 insertions(+), 78 deletions(-) create mode 100644 src/lib/i18n/source-labels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b401e7a11..dcc00fa68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [Unreleased] + +### Fixed + +- Settings, Sources: four of the sources in the priority ladder were listed + by their internal name. `GOOGLE_HEALTH`, `OURA`, `POLAR` and `STRAVA` + appeared exactly like that instead of Google Health, Oura, Polar and + Strava. The label catalogue was written when there were seven sources and + every provider added since fell through to the raw value. The same + fall-through sat in the measurement list, where the source filter offers + every source there is and the table paints one per row, and on the + medication intake history, where a dose synced from Apple Health read + "via APPLE_HEALTH". All three catalogues are complete in all seven + languages now, and a test reads the source enums out of the schema and + fails when a member has no label, so the next provider cannot ship + without its name. + ## [1.38.2] — 2026-09-02 Anthropic keys work on current Claude models again, and the daily briefing diff --git a/messages/de.json b/messages/de.json index 3526b66b0..3445309ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -731,6 +731,13 @@ "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count} Messwerte gelöscht", "bulkDeleteSuccessOne": "Messwert gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", @@ -1630,6 +1637,7 @@ "sourceApi": "iOS-App", "sourceReminder": "Erinnerung", "sourceImport": "Import", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "über {origin}", "intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst", "intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.", @@ -4889,7 +4897,14 @@ "IMPORT": "Import", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Berechnet" + "COMPUTED": "Berechnet", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Uhr", diff --git a/messages/en.json b/messages/en.json index 8bdc1d87b..42b53a7cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -731,6 +731,13 @@ "sourceComputed": "Computed", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteSuccessOne": "Measurement deleted", "bulkDeleteError": "Error deleting the selected measurements", @@ -1630,6 +1637,7 @@ "sourceApi": "iOS App", "sourceReminder": "Reminder", "sourceImport": "Import", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "via {origin}", "intakeHistoryEmptyTitle": "No intakes recorded yet", "intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.", @@ -4889,7 +4897,14 @@ "IMPORT": "Import", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Computed" + "COMPUTED": "Computed", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Watch", diff --git a/messages/es.json b/messages/es.json index db93fa6ba..f24a91a82 100644 --- a/messages/es.json +++ b/messages/es.json @@ -731,6 +731,13 @@ "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteSuccessOne": "Medición eliminada", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", @@ -1630,6 +1637,7 @@ "sourceApi": "App de iOS", "sourceReminder": "Recordatorio", "sourceImport": "Importación", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "vía {origin}", "intakeHistoryEmptyTitle": "Aún no hay tomas", "intakeHistoryEmptyDescription": "En cuanto registres una toma, aparecerá aquí con la hora y la fuente.", @@ -4889,7 +4897,14 @@ "IMPORT": "Importar", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Calculado" + "COMPUTED": "Calculado", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Reloj", diff --git a/messages/fr.json b/messages/fr.json index 90c0d6fb5..36901d982 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -731,6 +731,13 @@ "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteSuccessOne": "Mesure supprimée", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", @@ -1630,6 +1637,7 @@ "sourceApi": "App iOS", "sourceReminder": "Rappel", "sourceImport": "Import", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "via {origin}", "intakeHistoryEmptyTitle": "Aucune prise pour l’instant", "intakeHistoryEmptyDescription": "Dès que tu enregistreras une prise, elle apparaîtra ici avec l’heure et la source.", @@ -4889,7 +4897,14 @@ "IMPORT": "Importer", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Calculé" + "COMPUTED": "Calculé", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Montre", diff --git a/messages/it.json b/messages/it.json index df3c4d740..ee272c0cb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -731,6 +731,13 @@ "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteSuccessOne": "Misurazione eliminata", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", @@ -1630,6 +1637,7 @@ "sourceApi": "App iOS", "sourceReminder": "Promemoria", "sourceImport": "Importazione", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "tramite {origin}", "intakeHistoryEmptyTitle": "Ancora nessuna assunzione", "intakeHistoryEmptyDescription": "Appena registrerai un’assunzione, comparirà qui con orario e sorgente.", @@ -4889,7 +4897,14 @@ "IMPORT": "Importa", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Calcolato" + "COMPUTED": "Calcolato", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Orologio", diff --git a/messages/ko.json b/messages/ko.json index 2d435e3aa..ab9e42553 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -731,6 +731,13 @@ "sourceComputed": "계산값", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "{count}개 측정을 삭제했어요", "bulkDeleteSuccessOne": "측정을 삭제했어요", "bulkDeleteError": "선택한 측정을 삭제하지 못했어요", @@ -1630,6 +1637,7 @@ "sourceApi": "iOS 앱", "sourceReminder": "알림", "sourceImport": "불러오기", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "{origin} 경유", "intakeHistoryEmptyTitle": "아직 복용 기록이 없어요", "intakeHistoryEmptyDescription": "일일 복용 페이지에서 용량을 기록하면 이력이 쌓여요.", @@ -4889,7 +4897,14 @@ "IMPORT": "불러오기", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "계산값" + "COMPUTED": "계산값", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "워치", diff --git a/messages/pl.json b/messages/pl.json index 735ead22a..b95837dd1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -731,6 +731,13 @@ "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", + "sourceNightscout": "Nightscout", + "sourcePolar": "Polar", + "sourceOura": "Oura", + "sourceTelegram": "Telegram", + "sourceMcp": "MCP", + "sourceGoogleHealth": "Google Health", + "sourceStrava": "Strava", "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteSuccessOne": "Usunięto pomiar", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", @@ -1630,6 +1637,7 @@ "sourceApi": "Aplikacja iOS", "sourceReminder": "Przypomnienie", "sourceImport": "Import", + "sourceAppleHealth": "Apple Health", "intakeSourceVia": "przez {origin}", "intakeHistoryEmptyTitle": "Brak przyjęć", "intakeHistoryEmptyDescription": "Gdy tylko zarejestrujesz przyjęcie, pojawi się tu wraz z godziną i źródłem.", @@ -4889,7 +4897,14 @@ "IMPORT": "Importuj", "WHOOP": "WHOOP", "FITBIT": "Fitbit", - "COMPUTED": "Obliczone" + "COMPUTED": "Obliczone", + "NIGHTSCOUT": "Nightscout", + "POLAR": "Polar", + "OURA": "Oura", + "TELEGRAM": "Telegram", + "MCP": "MCP", + "GOOGLE_HEALTH": "Google Health", + "STRAVA": "Strava" }, "deviceLabels": { "watch": "Zegarek", diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 424ddf033..4c9f77b68 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -110,12 +110,15 @@ const LEGIT_IDENTICAL = new Set([ "admin.overview.snapshotOfflineGeoOn", "admin.webPushVapidTitle", "measurements.sourceAppleHealth", + "measurements.sourceGoogleHealth", + "medications.sourceAppleHealth", "onboarding.source.more.appleHealth", "settings.ai.providerChain.types.anthropic", "settings.ai.providerChain.types.codex", "settings.ai.providerSelect.anthropic", "settings.sections.export.import.appleHealth.title", "settings.sections.sources.sourceLabels.APPLE_HEALTH", + "settings.sections.sources.sourceLabels.GOOGLE_HEALTH", "settings.appleHealth.title", "mood.tag.fastFood", // Technical / config labels conventionally kept verbatim. diff --git a/src/components/insights/sleep-source-discrepancy-marker.tsx b/src/components/insights/sleep-source-discrepancy-marker.tsx index 65b28f498..8c972f80f 100644 --- a/src/components/insights/sleep-source-discrepancy-marker.tsx +++ b/src/components/insights/sleep-source-discrepancy-marker.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/tooltip"; import { useTranslations } from "@/lib/i18n/context"; import { formatDurationMinutes } from "@/lib/i18n/duration"; +import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; /** * v1.28.x — the discreet "sources disagree" marker, extracted from @@ -30,35 +31,6 @@ export interface SleepSourceDiscrepancyDto { }[]; } -/** - * Localised label keys per `MeasurementSource` — the same map the - * source-priority settings surface uses (`sources-section.tsx`), so both - * surfaces humanise a source identically. - */ -const SOURCE_LABEL_KEYS: Record = { - WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", - APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", - MANUAL: "settings.sections.sources.sourceLabels.MANUAL", - IMPORT: "settings.sections.sources.sourceLabels.IMPORT", - WHOOP: "settings.sections.sources.sourceLabels.WHOOP", - COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", - FITBIT: "settings.sections.sources.sourceLabels.FITBIT", -}; - -/** - * Brand names for sources without a localised settings label — brand - * spellings are locale-invariant, so no i18n key is needed for them. - */ -const SOURCE_BRAND_NAMES: Record = { - OURA: "Oura", - POLAR: "Polar", - GOOGLE_HEALTH: "Google Health", - NIGHTSCOUT: "Nightscout", - STRAVA: "Strava", - TELEGRAM: "Telegram", - MCP: "MCP", -}; - /** * Localised device-type label keys (`Measurement.deviceType`), reused from * the source-priority settings surface. Appended in parentheses so two @@ -79,8 +51,13 @@ export function sourceDisplayName( deviceType: string | null, t: (key: string) => string, ): string { - const labelKey = SOURCE_LABEL_KEYS[source]; - const base = labelKey ? t(labelKey) : (SOURCE_BRAND_NAMES[source] ?? source); + // The wire carries whatever the row's `source` column held, so an + // unknown value still has to render as itself rather than as a missing + // key. The catalogue covers every enum member the guard knows about. + const labelKey = MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS + ] as string | undefined; + const base = labelKey ? t(labelKey) : source; const deviceKey = deviceType ? DEVICE_TYPE_LABEL_KEYS[deviceType] : undefined; return deviceKey ? `${base} (${t(deviceKey)})` : base; } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 455dd9e69..b9667f9e0 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -67,6 +67,7 @@ import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; import { getUnitForType } from "@/lib/validations/measurement"; @@ -217,23 +218,22 @@ function toDateTimeLocalValue(isoString: string): string { } /** - * Render the `MeasurementSource` enum (`MANUAL` / `WITHINGS` / `IMPORT` - * / `APPLE_HEALTH`) using the existing `measurements.source*` - * translation keys instead of leaking the SCREAMING_SNAKE enum into - * the table cell. + * Render the `MeasurementSource` enum using the `measurements.source*` + * translation keys instead of leaking the SCREAMING_SNAKE enum into the + * table cell or the filter dropdown. The catalogue is keyed by the enum, + * so a provider added without its label is a type error rather than a row + * that reads `GOOGLE_HEALTH`. */ function formatMeasurementSource( source: string, t: ReturnType["t"], ) { - if (source === "WITHINGS") return t("measurements.sourceWithings"); - if (source === "IMPORT") return t("measurements.sourceImport"); - if (source === "MANUAL") return t("measurements.sourceManual"); - if (source === "APPLE_HEALTH") return t("measurements.sourceAppleHealth"); - if (source === "COMPUTED") return t("measurements.sourceComputed"); - if (source === "WHOOP") return t("measurements.sourceWhoop"); - if (source === "FITBIT") return t("measurements.sourceFitbit"); - return source; + const key = MEASUREMENT_SOURCE_LIST_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS + ] as string | undefined; + // A row written by a server the client does not know yet still renders + // as its own value rather than as a missing translation key. + return key ? t(key) : source; } /** diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 19f1c84f9..68a1cf34a 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -18,6 +18,10 @@ import { import { useAuth } from "@/hooks/use-auth"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + INTAKE_SOURCE_LABEL_KEYS, + type IntakeSourceValue, +} from "@/lib/i18n/source-labels"; import { queryKeys } from "@/lib/query-keys"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -71,23 +75,22 @@ import { apiGet } from "@/lib/api/api-fetch"; * outline SkipForward + "Übersprungen" for skipped rows. The * component never labels a row both at once. * - Source badge surfaces where the intake was logged — WEB → "Web", - * API → "iOS App", REMINDER → "Reminder", IMPORT → "Import" — wrapped - * in a muted "via {origin}" caption next to the status. The labels map - * the coarse `IntakeSource` enum to the practical client per the - * existing `medications.source*` keys; there is no finer client field. + * API → "iOS App", REMINDER → "Reminder", IMPORT → "Import", + * APPLE_HEALTH → "Apple Health" — wrapped in a muted "via {origin}" + * caption next to the status. The labels map the coarse `IntakeSource` + * enum to the practical client per the existing `medications.source*` + * keys; there is no finer client field. * - Empty state copy + CTA back to the daily intake page. * - No edit-in-row, no delete buttons. */ -type IntakeSource = "WEB" | "API" | "REMINDER" | "IMPORT"; - export interface IntakeEvent { id: string; medicationId: string; scheduledFor: string; takenAt: string | null; skipped: boolean; - source: IntakeSource; + source: IntakeSourceValue; createdAt: string; } @@ -205,13 +208,14 @@ export function IntakeHistoryListV2({ enabled: isAuthenticated, }); - const sourceLabels: Record = useMemo( - () => ({ - WEB: t("medications.sourceWeb"), - API: t("medications.sourceApi"), - REMINDER: t("medications.sourceReminder"), - IMPORT: t("medications.sourceImport"), - }), + const sourceLabels: Record = useMemo( + () => + Object.fromEntries( + Object.entries(INTAKE_SOURCE_LABEL_KEYS).map(([source, key]) => [ + source, + t(key), + ]), + ) as Record, [t], ); diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index c5cef3cc4..3872d8676 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -48,6 +48,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { DEFAULT_DEVICE_TYPE_PRIORITY, @@ -96,19 +97,6 @@ const METRIC_LABEL_KEYS: Record = { sleepDebt: "settings.sections.sources.metrics.sleepDebt", }; -const SOURCE_LABEL_KEYS: Record = { - WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", - APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", - MANUAL: "settings.sections.sources.sourceLabels.MANUAL", - IMPORT: "settings.sections.sources.sourceLabels.IMPORT", - // v1.11.0 — WHOOP native source + the COMPUTED proxy (surfaces in the - // `recovery` ladder for native-vs-derived ordering). - WHOOP: "settings.sections.sources.sourceLabels.WHOOP", - COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", - // v1.12.0 — Fitbit/Pixel native source. - FITBIT: "settings.sections.sources.sourceLabels.FITBIT", -}; - const DEVICE_TYPE_LABEL_KEYS: Record = { watch: "settings.sections.sources.deviceLabels.watch", band: "settings.sections.sources.deviceLabels.band", @@ -355,7 +343,7 @@ export function SourcesSection() { {index + 1}. - {t(SOURCE_LABEL_KEYS[source] ?? source)} + {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])} {/* v1.4.27 R3d MB2 — stack up/down vertically on narrow viewports so each button keeps the diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 16285702b..0fb001b28 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -260,6 +260,9 @@ describe("i18n locale file integrity", () => { "medications.wizard.steps.step3.unit.g", // v1.11.0 — "WHOOP" is a brand name, identical across every locale. "settings.sections.sources.sourceLabels.WHOOP", + // "MCP" is the protocol's own acronym — the same four letters in every + // locale, exactly like the WHOOP entry above. + "settings.sections.sources.sourceLabels.MCP", ]); it.each(ALL_LOCALES)("$locale locale has no empty values", ({ path }) => { diff --git a/src/lib/i18n/source-labels.ts b/src/lib/i18n/source-labels.ts new file mode 100644 index 000000000..9416d5cc1 --- /dev/null +++ b/src/lib/i18n/source-labels.ts @@ -0,0 +1,94 @@ +/** + * One place that says which i18n key labels which provenance enum member. + * + * Three surfaces render a source to the user and each one carried its own + * copy of the map: Settings → Sources, the sleep discrepancy tooltip, and + * the measurement list (filter dropdown + table badge). Every copy was + * written when the enum was shorter, and every copy fell through to the raw + * SCREAMING_SNAKE value for the providers added afterwards — the settings + * ladder listed `GOOGLE_HEALTH`, `OURA`, `POLAR` and `STRAVA` verbatim. + * + * The maps live here so a new provider is one edit, and they are keyed by + * the enum itself so the edit is not optional: leaving a member out is a + * type error, and `measurement-source-label-coverage.test.ts` proves the + * keys resolve in the message bundle. + */ +import { measurementSourceEnum } from "@/lib/validations/measurement"; + +export type MeasurementSourceValue = + (typeof measurementSourceEnum.options)[number]; + +/** + * Settings → Sources ("Source priority by metric") and the sleep + * source-discrepancy tooltip. Reads as a provenance name: "Manual entry", + * "Computed", and the provider's own brand spelling for everything else. + */ +export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record< + MeasurementSourceValue, + string +> = { + WITHINGS: "settings.sections.sources.sourceLabels.WITHINGS", + APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH", + MANUAL: "settings.sections.sources.sourceLabels.MANUAL", + IMPORT: "settings.sections.sources.sourceLabels.IMPORT", + WHOOP: "settings.sections.sources.sourceLabels.WHOOP", + COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED", + FITBIT: "settings.sections.sources.sourceLabels.FITBIT", + NIGHTSCOUT: "settings.sections.sources.sourceLabels.NIGHTSCOUT", + POLAR: "settings.sections.sources.sourceLabels.POLAR", + OURA: "settings.sections.sources.sourceLabels.OURA", + TELEGRAM: "settings.sections.sources.sourceLabels.TELEGRAM", + MCP: "settings.sections.sources.sourceLabels.MCP", + GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH", + STRAVA: "settings.sections.sources.sourceLabels.STRAVA", +}; + +/** + * The measurement list — the source facet in the filter rail and the badge + * in the table's source column. Its own catalogue because the badge column + * is narrow: "Manual" where the settings ladder says "Manual entry". + */ +export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record< + MeasurementSourceValue, + string +> = { + MANUAL: "measurements.sourceManual", + WITHINGS: "measurements.sourceWithings", + IMPORT: "measurements.sourceImport", + APPLE_HEALTH: "measurements.sourceAppleHealth", + COMPUTED: "measurements.sourceComputed", + WHOOP: "measurements.sourceWhoop", + FITBIT: "measurements.sourceFitbit", + NIGHTSCOUT: "measurements.sourceNightscout", + POLAR: "measurements.sourcePolar", + OURA: "measurements.sourceOura", + TELEGRAM: "measurements.sourceTelegram", + MCP: "measurements.sourceMcp", + GOOGLE_HEALTH: "measurements.sourceGoogleHealth", + STRAVA: "measurements.sourceStrava", +}; + +/** + * `IntakeSource` — where a medication intake was logged. Not a Zod enum + * anywhere (the write surfaces validate against narrower literals), so the + * member list is written out and pinned against `prisma/schema.prisma` by + * the coverage guard. + */ +export const INTAKE_SOURCE_VALUES = [ + "WEB", + "API", + "REMINDER", + "IMPORT", + "APPLE_HEALTH", +] as const; + +export type IntakeSourceValue = (typeof INTAKE_SOURCE_VALUES)[number]; + +/** The "via {origin}" caption on a medication intake row. */ +export const INTAKE_SOURCE_LABEL_KEYS: Record = { + WEB: "medications.sourceWeb", + API: "medications.sourceApi", + REMINDER: "medications.sourceReminder", + IMPORT: "medications.sourceImport", + APPLE_HEALTH: "medications.sourceAppleHealth", +}; From 7ee8bc8134ff64d87f00d1c8f81bc3338ba088e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 2 Sep 2026 12:08:38 +0200 Subject: [PATCH 2/2] Fail the suite when a source enum member has no label The four raw values in the sources ladder shipped because nothing checks the catalogues against the enum they render. The new guard reads MeasurementSource and IntakeSource out of prisma/schema.prisma, not out of a copy, and fails naming every member no catalogue labels; it also asserts the key each entry names resolves in all seven bundles, since a key that resolves nowhere renders no better than the enum value. Run red against the tree before the labels landed, it reported seven unlabelled measurement sources per catalogue and one unlabelled intake source. The schema reader now carries each enum's declared members so the guard can read them. --- src/__tests__/helpers/prisma-schema-names.ts | 16 +- .../measurement-source-label-coverage.test.ts | 164 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/measurement-source-label-coverage.test.ts diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts index 310ecaaf3..fd7b5b448 100644 --- a/src/__tests__/helpers/prisma-schema-names.ts +++ b/src/__tests__/helpers/prisma-schema-names.ts @@ -29,6 +29,8 @@ export interface DeclaredEnum { mappedName: string | null; /** The name Prisma puts in the SQL — `@@map` when present, else `name`. */ physicalName: string; + /** The declared members, in declaration order. */ + values: string[]; } export interface DeclaredField { @@ -87,7 +89,19 @@ export function readDeclaredSchema( const clean = stripComments(body); const mappedName = BLOCK_MAP.exec(clean)?.[1] ?? null; if (kind === "enum") { - enums.push({ name, mappedName, physicalName: mappedName ?? name }); + // A member is a bare identifier on its own line; `@@map` and any + // trailing comment are already gone (block attributes start with `@`, + // comment lines were stripped above). + const values = clean + .split("\n") + .map((line) => line.replace(/\/\/.*$/, "").trim()) + .filter((line) => /^\w+$/.test(line)); + enums.push({ + name, + mappedName, + physicalName: mappedName ?? name, + values, + }); } else { rawModels.push({ name, body: clean }); } diff --git a/src/__tests__/measurement-source-label-coverage.test.ts b/src/__tests__/measurement-source-label-coverage.test.ts new file mode 100644 index 000000000..ce1cb991c --- /dev/null +++ b/src/__tests__/measurement-source-label-coverage.test.ts @@ -0,0 +1,164 @@ +/** + * Every provenance enum member a surface renders must have a label. + * + * Settings → Sources listed `GOOGLE_HEALTH`, `OURA`, `POLAR` and `STRAVA` by + * their internal name for a full release line: the label catalogue was + * written when the enum was seven members long, the render path falls back + * to the raw value, and nothing failed when a provider landed without its + * label. Same fall-through in the measurement list (the source facet offers + * every enum member) and on the medication intake caption, where the v1.28 + * Apple Health dose sync reads `via APPLE_HEALTH`. + * + * The member list comes from `prisma/schema.prisma` — the enum itself, not a + * copy of it — so the next provider cannot ship without its label. Each + * catalogue is checked in two steps: the map has an entry for the member, + * and the key that entry names resolves to a real string in EVERY locale + * bundle (a key that resolves nowhere renders as the key, which is no better + * than the enum value it replaced). + * + * Mutation check: drop a member from either map and this goes red naming it; + * point one at a key the bundles do not carry and the resolve arm goes red. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { + INTAKE_SOURCE_LABEL_KEYS, + INTAKE_SOURCE_VALUES, + MEASUREMENT_SOURCE_LIST_LABEL_KEYS, + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS, +} from "@/lib/i18n/source-labels"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import { deviceTypeEnum } from "@/lib/validations/source-priority"; +import { readDeclaredSchema } from "./helpers/prisma-schema-names"; + +const ROOT = join(__dirname, "../.."); +const MESSAGES_DIR = join(ROOT, "messages"); + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => ({ + locale: f.replace(/\.json$/, ""), + bundle: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as unknown, + })); + +function resolveKey(bundle: unknown, key: string): string | undefined { + let node: unknown = bundle; + for (const segment of key.split(".")) { + if (node == null || typeof node !== "object") return undefined; + node = (node as Record)[segment]; + } + return typeof node === "string" && node !== "" ? node : undefined; +} + +const declaredEnums = readDeclaredSchema().enums; + +function membersOf(enumName: string): string[] { + const declared = declaredEnums.find((e) => e.name === enumName); + if (!declared) throw new Error(`${enumName} is not declared in the schema`); + return declared.values; +} + +/** + * Assert the catalogue covers the enum and that every key it names resolves. + * `floor` fails the whole check if the schema reader stopped matching — an + * empty member list would otherwise pass every assertion below vacuously. + */ +function expectLabelled( + members: string[], + floor: number, + catalogue: Record, + catalogueName: string, +): void { + expect( + members.length, + `Read only ${members.length} members from prisma/schema.prisma — the ` + + `schema reader stopped matching, so this guard proves nothing.`, + ).toBeGreaterThanOrEqual(floor); + + const unlabelled = members.filter((member) => !catalogue[member]); + expect( + unlabelled, + `${catalogueName} has no entry for these enum members, so the surface ` + + `renders their raw enum value:\n` + + unlabelled.map((m) => ` ${m}`).join("\n"), + ).toEqual([]); + + const unresolved: string[] = []; + for (const member of members) { + const key = catalogue[member]; + if (!key) continue; + for (const { locale, bundle } of LOCALES) { + if (resolveKey(bundle, key) === undefined) { + unresolved.push(`${member} → ${key} (${locale})`); + } + } + } + expect( + unresolved, + `${catalogueName} names keys that no message bundle carries:\n` + + unresolved.map((s) => ` ${s}`).join("\n"), + ).toEqual([]); +} + +describe("source label coverage", () => { + it("has more than one locale bundle to check against", () => { + expect(LOCALES.length).toBeGreaterThanOrEqual(7); + }); + + it("keeps the Zod source enum in step with the schema", () => { + expect([...measurementSourceEnum.options].sort()).toEqual( + [...membersOf("MeasurementSource")].sort(), + ); + }); + + it("labels every MeasurementSource in Settings → Sources", () => { + expectLabelled( + membersOf("MeasurementSource"), + 14, + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS, + "The settings source catalogue", + ); + }); + + it("labels every MeasurementSource in the measurement list", () => { + expectLabelled( + membersOf("MeasurementSource"), + 14, + MEASUREMENT_SOURCE_LIST_LABEL_KEYS, + "The measurement-list source catalogue", + ); + }); + + it("labels every IntakeSource on the intake history", () => { + const members = membersOf("IntakeSource"); + expect([...INTAKE_SOURCE_VALUES].sort()).toEqual([...members].sort()); + expectLabelled( + members, + 5, + INTAKE_SOURCE_LABEL_KEYS, + "The intake source catalogue", + ); + }); + + /** + * The other enum the sources section renders. It is already closed by its + * own type (`Record` off the Zod enum), so this arm is + * the bundle half only — the keys have to exist for the type to help. + */ + it("labels every device type in Settings → Sources", () => { + const catalogue = Object.fromEntries( + deviceTypeEnum.options.map((d) => [ + d, + `settings.sections.sources.deviceLabels.${d}`, + ]), + ); + expectLabelled( + [...deviceTypeEnum.options], + 7, + catalogue, + "The device catalogue", + ); + }); +});