Merge pull request #889 from MBombeck/fix/source-priority-labels

fix(i18n): name every measurement source instead of showing its enum
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-02 12:28:06 +02:00
commit 71a6dd0e2a
17 files changed
+448 -79

No files matched your search

+17
View File
@@ -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
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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",
+16 -1
View File
@@ -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": "워치",
+16 -1
View File
@@ -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",
+15 -1
View File
@@ -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 });
}
@@ -110,12 +110,15 @@ const LEGIT_IDENTICAL = new Set<string>([
"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.
@@ -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<string, unknown>)[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<string, string>,
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<DeviceType, string>` 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",
);
});
});
@@ -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<string, 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",
};
/**
* 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<string, string> = {
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;
}
@@ -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<typeof useTranslations>["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;
}
/**
@@ -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<IntakeSource, string> = useMemo(
() => ({
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
}),
const sourceLabels: Record<IntakeSourceValue, string> = useMemo(
() =>
Object.fromEntries(
Object.entries(INTAKE_SOURCE_LABEL_KEYS).map(([source, key]) => [
source,
t(key),
]),
) as Record<IntakeSourceValue, string>,
[t],
);
+2 -14
View File
@@ -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<SourcePriorityMetricKey, string> = {
sleepDebt: "settings.sections.sources.metrics.sleepDebt",
};
const SOURCE_LABEL_KEYS: Record<string, 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",
// 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<DeviceType, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
band: "settings.sections.sources.deviceLabels.band",
@@ -355,7 +343,7 @@ export function SourcesSection() {
{index + 1}.
</span>
<span className="flex-1 text-sm">
{t(SOURCE_LABEL_KEYS[source] ?? source)}
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* v1.4.27 R3d MB2 — stack up/down vertically on
narrow viewports so each button keeps the
@@ -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 }) => {
+94
View File
@@ -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<IntakeSourceValue, string> = {
WEB: "medications.sourceWeb",
API: "medications.sourceApi",
REMINDER: "medications.sourceReminder",
IMPORT: "medications.sourceImport",
APPLE_HEALTH: "medications.sourceAppleHealth",
};