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", +};