diff --git a/CHANGELOG.md b/CHANGELOG.md index 41471f718..8903e3142 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__/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__/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/__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", + ); + }); +}); 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", +};