mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
17 files changed
+448
-79
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
Reference in new issue
Block a user