diff --git a/messages/de.json b/messages/de.json index 53ed601c2..371a8386b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Gehgeschwindigkeit", "description": "Deine durchschnittliche Gehgeschwindigkeit im Zeitverlauf, in km/h.", - "chartTitle": "Gehgeschwindigkeit (km/h)", + "chartTitle": "Gehgeschwindigkeit", "emptyState": { "title": "Noch keine Gehgeschwindigkeitsdaten", "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Handgelenktemperatur", "description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.", - "chartTitle": "Handgelenktemperatur (°C)", + "chartTitle": "Handgelenktemperatur", "emptyState": { "title": "Noch keine Handgelenktemperatur-Daten", "description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert." @@ -8287,9 +8287,6 @@ "unitSteps": "Schritte", "unitBpm": "S/min", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "Pkt.", "confidenceLow": "Geringe Sicherheit", "confidenceMedium": "Mittlere Sicherheit", @@ -8464,7 +8461,6 @@ "flow": "Stärke", "symptoms": "Symptome", "temperature": "Basaltemperatur", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Messwert als gestört markieren", "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", @@ -8556,7 +8552,7 @@ "empty": "Noch keine Temperaturwerte", "emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.", "ovulationEstimate": "Gesch. Eisprung", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Bestätigter Eisprung" }, "fieldInfo": { diff --git a/messages/en.json b/messages/en.json index 60da33482..718b70a66 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Walking Speed", "description": "Your average walking speed over time, in km/h.", - "chartTitle": "Walking speed (km/h)", + "chartTitle": "Walking speed", "emptyState": { "title": "No walking-speed data yet", "description": "Walking-speed readings appear here once Apple Health syncs." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Wrist Temperature", "description": "Your overnight wrist temperature, tracked against your own baseline.", - "chartTitle": "Wrist temperature (°C)", + "chartTitle": "Wrist temperature", "emptyState": { "title": "No wrist-temperature data yet", "description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear." @@ -8287,9 +8287,6 @@ "unitSteps": "steps", "unitBpm": "bpm", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "pts", "confidenceLow": "Low confidence", "confidenceMedium": "Medium confidence", @@ -8464,7 +8461,6 @@ "flow": "Flow", "symptoms": "Symptoms", "temperature": "Basal body temperature", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Mark reading as disturbed", "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", @@ -8556,7 +8552,7 @@ "empty": "No temperature readings yet", "emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.", "ovulationEstimate": "Est. ovulation", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Confirmed ovulation" }, "fieldInfo": { diff --git a/messages/es.json b/messages/es.json index d4fadc383..5fdcd3a00 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Velocidad al andar", "description": "Tu velocidad media al andar en el tiempo, en km/h.", - "chartTitle": "Velocidad al andar (km/h)", + "chartTitle": "Velocidad al andar", "emptyState": { "title": "Aún no hay datos", "description": "Las lecturas aparecen aquí cuando Apple Health sincroniza." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Temperatura de muñeca", "description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.", - "chartTitle": "Temperatura de muñeca (°C)", + "chartTitle": "Temperatura de muñeca", "emptyState": { "title": "Aún no hay datos de temperatura de muñeca", "description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno." @@ -8287,9 +8287,6 @@ "unitSteps": "pasos", "unitBpm": "lpm", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "pts", "confidenceLow": "Confianza baja", "confidenceMedium": "Confianza media", @@ -8464,7 +8461,6 @@ "flow": "Flujo", "symptoms": "Síntomas", "temperature": "Temperatura basal", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Marcar la medición como alterada", "temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.", "ovulationTest": "Test de ovulación", @@ -8556,7 +8552,7 @@ "empty": "Aún no hay mediciones de temperatura", "emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.", "ovulationEstimate": "Ovulación est.", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Ovulación confirmada" }, "fieldInfo": { diff --git a/messages/fr.json b/messages/fr.json index 0ba1803f6..771801950 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Vitesse de marche", "description": "Votre vitesse de marche moyenne au fil du temps, en km/h.", - "chartTitle": "Vitesse de marche (km/h)", + "chartTitle": "Vitesse de marche", "emptyState": { "title": "Aucune donnée", "description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Température du poignet", "description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.", - "chartTitle": "Température du poignet (°C)", + "chartTitle": "Température du poignet", "emptyState": { "title": "Pas encore de données de température du poignet", "description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne." @@ -8287,9 +8287,6 @@ "unitSteps": "pas", "unitBpm": "bpm", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "pts", "confidenceLow": "Confiance faible", "confidenceMedium": "Confiance moyenne", @@ -8464,7 +8461,6 @@ "flow": "Flux", "symptoms": "Symptômes", "temperature": "Température basale", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Marquer la mesure comme perturbée", "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", @@ -8556,7 +8552,7 @@ "empty": "Pas encore de relevés de température", "emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.", "ovulationEstimate": "Ovulation est.", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Ovulation confirmée" }, "fieldInfo": { diff --git a/messages/it.json b/messages/it.json index ff8d19586..904422f6e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Velocità di cammino", "description": "La tua velocità media di cammino nel tempo, in km/h.", - "chartTitle": "Velocità di cammino (km/h)", + "chartTitle": "Velocità di cammino", "emptyState": { "title": "Nessun dato", "description": "Le letture appaiono qui quando Apple Health si sincronizza." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Temperatura del polso", "description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.", - "chartTitle": "Temperatura del polso (°C)", + "chartTitle": "Temperatura del polso", "emptyState": { "title": "Nessun dato sulla temperatura del polso", "description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno." @@ -8287,9 +8287,6 @@ "unitSteps": "passi", "unitBpm": "bpm", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "pts", "confidenceLow": "Affidabilità bassa", "confidenceMedium": "Affidabilità media", @@ -8464,7 +8461,6 @@ "flow": "Flusso", "symptoms": "Sintomi", "temperature": "Temperatura basale", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Segna la misurazione come disturbata", "temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.", "ovulationTest": "Test di ovulazione", @@ -8556,7 +8552,7 @@ "empty": "Ancora nessuna misurazione della temperatura", "emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.", "ovulationEstimate": "Ovulazione stim.", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Ovulazione confermata" }, "fieldInfo": { diff --git a/messages/ko.json b/messages/ko.json index 76581d070..9b67626fa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "보행 속도", "description": "시간에 따른 평균 보행 속도예요(km/h).", - "chartTitle": "보행 속도(km/h)", + "chartTitle": "보행 속도", "emptyState": { "title": "아직 보행 속도 데이터가 없어요", "description": "Apple Health가 동기화되면 보행 속도 측정값이 여기에 나타나요." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "손목 온도", "description": "내 기준값과 비교한 밤사이 손목 온도예요.", - "chartTitle": "손목 온도 (°C)", + "chartTitle": "손목 온도", "emptyState": { "title": "아직 손목 온도 데이터가 없어요", "description": "Apple Health가 밤사이 착용 기록을 동기화하면 손목 온도 측정값이 여기에 표시돼요." @@ -8287,9 +8287,6 @@ "unitSteps": "걸음", "unitBpm": "bpm", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "점", "confidenceLow": "신뢰도 낮음", "confidenceMedium": "신뢰도 보통", @@ -8464,7 +8461,6 @@ "flow": "생리량", "symptoms": "증상", "temperature": "기초 체온", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "측정값을 방해받음으로 표시", "temperatureDisturbedHint": "발열, 아픈 날, 음주, 늦은 측정 — 체온 평가에서 제외돼요.", "ovulationTest": "배란 테스트", @@ -8556,7 +8552,7 @@ "empty": "아직 체온 기록이 없어요", "emptyHint": "매일 아침 기초 체온을 기록하면 주기의 체온 변화를 볼 수 있어요.", "ovulationEstimate": "예상 배란", - "tooltipTemp": "{temp}°C", + "tooltipTemp": "{temp}{unit}", "ovulationConfirmed": "확인된 배란" }, "fieldInfo": { diff --git a/messages/pl.json b/messages/pl.json index 4bcb40e97..f5eb1ceb2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3739,7 +3739,7 @@ "walkingSpeed": { "title": "Prędkość chodu", "description": "Twoja średnia prędkość chodu w czasie, w km/h.", - "chartTitle": "Prędkość chodu (km/h)", + "chartTitle": "Prędkość chodu", "emptyState": { "title": "Brak danych", "description": "Odczyty pojawią się tutaj po synchronizacji Apple Health." @@ -4142,7 +4142,7 @@ "wristTemperature": { "title": "Temperatura nadgarstka", "description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.", - "chartTitle": "Temperatura nadgarstka (°C)", + "chartTitle": "Temperatura nadgarstka", "emptyState": { "title": "Brak danych o temperaturze nadgarstka", "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie." @@ -8287,9 +8287,6 @@ "unitSteps": "kroki", "unitBpm": "ud./min", "unitMs": "ms", - "unitKg": "kg", - "unitCelsius": "°C", - "unitGlucose": "mg/dL", "unitMood": "pkt", "confidenceLow": "Niska pewność", "confidenceMedium": "Średnia pewność", @@ -8464,7 +8461,6 @@ "flow": "Krwawienie", "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", - "temperaturePlaceholder": "°C", "temperatureDisturbed": "Oznacz pomiar jako zakłócony", "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", @@ -8556,7 +8552,7 @@ "empty": "Brak pomiarów temperatury", "emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.", "ovulationEstimate": "Szac. owulacja", - "tooltipTemp": "{temp} °C", + "tooltipTemp": "{temp} {unit}", "ovulationConfirmed": "Potwierdzona owulacja" }, "fieldInfo": { diff --git a/src/__tests__/helpers/unit-display-mock.ts b/src/__tests__/helpers/unit-display-mock.ts new file mode 100644 index 000000000..60a90a914 --- /dev/null +++ b/src/__tests__/helpers/unit-display-mock.ts @@ -0,0 +1,42 @@ +/** + * A real `UnitDisplay` for one fixed preference, for component tests that + * render without a QueryClient (`useUnitDisplay` reads the account through + * react-query). Built on the production transforms, so a test asserts the + * same numbers and symbols the hook would give. + * + * vi.mock("@/hooks/use-unit-display", async () => { + * const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock"); + * return { useUnitDisplay: () => unitDisplayFor("metric") }; + * }); + */ +import type { UnitDisplay } from "@/hooks/use-unit-display"; +import type { GlucoseUnit } from "@/lib/glucose"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getDisplayTransform, + hasDisplayTransform, + invertDisplayTransform, + type UnitPreference, +} from "@/lib/measurements/display-transform"; + +export function unitDisplayFor( + preference: UnitPreference, + glucoseUnit: GlucoseUnit = "mg/dL", +): UnitDisplay { + // The hook resolves mass, length and temperature through the + // metric/imperial branch alone; glucose is converted where it renders. + const forType = (type: string) => getDisplayTransform(type, preference); + return { + preference, + glucoseUnit, + transformFor: forType, + toDisplay: (type, value) => applyDisplayTransform(value, forType(type)), + fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)), + toDisplayDelta: (type, value) => + applyDisplayTransformDelta(value, forType(type)), + unitFor: (type) => forType(type).displayUnit, + decimalsFor: (type) => forType(type).decimals, + isTransformed: (type) => hasDisplayTransform(type), + }; +} diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index dc9d79fac..897cd03bb 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -87,7 +87,6 @@ const LEGIT_IDENTICAL = new Set([ "common.period", // Clinical abbreviations / SI units — identical across locales. "charts.vo2Max", - "cycle.insights.crosstab.unitGlucose", "dashboard.metric.unit.bmi", "dashboard.metric.unit.mood", "dashboard.metric.unit.glucose", diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 693048d76..b7fe91d41 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -226,10 +226,19 @@ describe("unit-preference display guard", () => { * fails. A listed file that drops a line fails too, so the budget follows * the code down instead of leaving headroom for the next literal. * + * The text the AI and the Coach write is swept the same way: the prompt and + * snapshot builders (`lib/ai/`), the jobs that write stored text + * (`lib/jobs/`), the target and daily-digest producers. A prompt that + * states a threshold or a band in kilograms, °C or mg/dL hands a reader on + * pounds, °F or mmol/L two units for one quantity, and the model mixes + * them. Prose units ("5 metres", "kg/week") are matched as well, because a + * prompt is prose. + * * The limit, stated: this is a line scanner. A unit spelled through a * variable named after it, or a literal split across lines, passes. The * message bundles are not swept either (the fever and cycle-glucose labels - * lived there). + * lived there). The producer check below covers what a scanner cannot: a + * text producer that prints a canonical number with no unit at all. */ const WIDE_SWEEP_ROOTS = [ "components/", @@ -237,6 +246,10 @@ const WIDE_SWEEP_ROOTS = [ "lib/insights/", "lib/doctor-report", "lib/export", + "lib/ai/", + "lib/jobs/", + "lib/targets/", + "lib/daily/", ]; const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [ @@ -252,6 +265,10 @@ const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [ /\s(?:kg|km|°C)(?=["'`<])/, // A per-kilometre pace. /\/km\b/, + // A rate per kilogram written into prose: "kg/week" (BMI's kg/m² excepted). + /\bkg\/(?!m²)/, + // A unit spelled out after a number or an interpolation: "5 metres". + /(?:\d|\})\s?(?:metres?|kilograms?)\b/, ]; /** @@ -346,22 +363,48 @@ const UNIT_LITERAL_ALLOWLIST: Record< lines: 2, reason: "prose naming both glucose units the app offers", }, - // ── Still canonical; the prompt-unit pass for these is open (#1067) ── - "lib/insights/comprehensive-generate.ts": { + // ── The AI and Coach producers ── + "lib/ai/insight-interpretation.ts": { lines: 1, - reason: "comprehensive prompt still states weight in kg", + reason: + "canonical band registry; interpretation-block prints the band in the reader's unit", }, - "lib/insights/signals-of-day.ts": { - lines: 1, - reason: "briefing signal still states weight in kg", + "lib/ai/prompts/shared-contracts.ts": { + lines: 3, + reason: + "the acute red-flag floors, stated in both units on purpose so the rule fires on what the person writes", }, - "lib/insights/weight-status.ts": { - lines: 1, - reason: "weight status snapshot still states kg", + "lib/ai/coach/snapshot-blocks/glucose-block.ts": { + lines: 4, + reason: + "branches on the resolved glucose unit; a mg/dL reader's block stays byte-identical", }, - "lib/insights/glp1-plateau.ts": { + "lib/ai/coach/results/chart-spec.ts": { lines: 2, - reason: "GLP-1 plateau prompt context still states kg", + reason: + "bin widths keyed to a column unit, applied only when the table is in that unit", + }, + "lib/ai/coach/coach-prose-grounding.ts": { + lines: 1, + reason: + "the reply checker's list of unit tokens it recognises in model prose", + }, + "lib/ai/coach/eval/golden-cases.ts": { + lines: 3, + reason: "evaluation fixtures: scripted replies and ideal answers", + }, + "lib/ai/coach/eval/red-team.ts": { + lines: 2, + reason: "adversarial evaluation inputs, written as a person would type", + }, + "lib/targets/target-unit-display.ts": { + lines: 4, + reason: "the target adapter itself: picks the glucose branch", + }, + "lib/targets/vitals-builder.ts": { + lines: 1, + reason: + "canonical targets DTO; every client converts through the target adapter", }, }; @@ -372,7 +415,7 @@ function unitLiteralLines(): Map { !rel.includes("__tests__") && !/\.test\.tsx?$/.test(rel), ); - if (files.length < 1200) { + if (files.length < 1700) { throw new Error( `unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`, ); @@ -424,3 +467,51 @@ describe("unit-preference display guard — the wide sweep", () => { expect(stale).toEqual([]); }); }); + +/** + * The producers of text a model reads or writes from. A line scanner sees a + * unit literal; it cannot see a canonical number printed with no unit at + * all, which is how the Coach's weight block and the briefing's glucose + * signal reached readers on pounds and mmol/L. So each producer that carries + * a mass, length, temperature, speed, distance or glucose figure into a + * prompt, a snapshot or a stored sentence is named here and must call one of + * the resolvers that state a figure in the reader's units. Dropping the call + * (or the file) fails. + */ +const READER_UNIT_PRODUCERS = [ + "lib/ai/coach/reference-grounding.ts", + "lib/ai/coach/snapshot-blocks/core-metrics-block.ts", + "lib/ai/coach/snapshot-blocks/value-series-blocks.ts", + "lib/ai/coach/snapshot-blocks/workouts-block.ts", + "lib/ai/coach/cycle-snapshot.ts", + "lib/ai/coach/results/metric-table-tool.ts", + "lib/ai/prompts/weight.ts", + "lib/insights/features-units.ts", + "lib/insights/comprehensive-generate.ts", + "app/api/insights/generate/route.ts", + "lib/insights/weight-status.ts", + "lib/insights/metric-status.ts", + "lib/insights/narrative/period-narrative.ts", + "lib/insights/glp1-plateau.ts", + "lib/jobs/reaction-line.ts", + "lib/targets/weight-trend.ts", +]; + +const READER_UNIT_RESOLVER = + /\b(?:getReadingTransform|getQuantityTransform|getDisplayTransform|featuresInReaderUnits|weightFeatureInReaderUnits)\(/; + +describe("unit-preference display guard — AI and Coach producers", () => { + it("names enough producers to be a check (a list that shrinks proves nothing)", () => { + expect(READER_UNIT_PRODUCERS.length).toBeGreaterThanOrEqual(16); + }); + + it("every producer states its figures through a reader-unit resolver", () => { + const offenders = READER_UNIT_PRODUCERS.filter( + (rel) => + !READER_UNIT_RESOLVER.test( + stripComments(readFileSync(join(SRC, rel), "utf8")), + ), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index c81ed66ff..f77381dad 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -12,7 +12,7 @@ * - 429 from the provider → 429 (passthrough, not 5xx) * - any other status → 422 (generic provider-connection failure) */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, onTestFinished } from "vitest"; // Mocks must be hoisted before importing the route. vi.mock("@/lib/api-handler", () => ({ @@ -171,6 +171,7 @@ import { FeaturesPayloadTooLargeError, } from "@/lib/insights/features"; import { annotate } from "@/lib/logging/context"; +import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; beforeEach(() => { @@ -460,6 +461,44 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { expect(args.data.insightsCachedLocale).toBe("en"); }); + it("serialises the feature set in the reader's units", async () => { + makeWorkingProvider(); + const user = { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + locale: "en", + unitPreference: "imperial", + glucoseUnit: "mmol/L", + }; + const findUnique = vi.mocked(prisma.user.findUnique); + const original = findUnique.getMockImplementation(); + findUnique.mockResolvedValue(user as never); + onTestFinished(() => { + findUnique.mockImplementation(original!); + }); + vi.mocked(extractFeatures).mockResolvedValueOnce({ + weight: { latest: 80, avg7: 80.5 }, + glucose: { latest: 126, avg30: 99 }, + } as never); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(200); + + const featuresJson = vi.mocked(buildUserPrompt).mock.calls[0][0]; + const features = JSON.parse(featuresJson); + expect(features.weight).toMatchObject({ + unit: "lb", + latest: 176.4, + avg7: 177.5, + }); + expect(features.glucose).toMatchObject({ + unit: "mmol/L", + latest: 7, + avg30: 5.5, + }); + }); + it("refuses a stale cache write when AI profile inclusion changes during generation", async () => { makeWorkingProvider(); selfContextText diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 266d77959..2180ace0c 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -87,6 +87,8 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { normalizeLocale } from "@/lib/insights/status-shared"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { featuresInReaderUnits } from "@/lib/insights/features-units"; +import { resolveUnitPreferences } from "@/lib/measurements/display-transform"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; // v1.28.25 — the comparison-snapshot builder was a private near-copy of // the lib export (drifted only in comments); the route now shares the @@ -340,6 +342,10 @@ export const POST = apiHandler((request: NextRequest) => // v1.25 — per-user response-timeout (seconds), threaded onto the // generation calls so a slow self-hosted backend honours the setting. aiResponseTimeoutSeconds: true, + // The reader's units; the feature set is converted into them before + // it is serialised, hashed and checked for grounding. + unitPreference: true, + glucoseUnit: true, }, }); const modeAtRead = dbUser?.insightsPrivacyMode ?? "aggregated"; @@ -610,6 +616,13 @@ export const POST = apiHandler((request: NextRequest) => // BEFORE serialisation so the model never sees the dropped blocks. const excludeList = dbUser?.insightsExcludeMetrics ?? []; features = applyInsightsExcludeFilter(features, excludeList); + // The reader's units, after all canonical arithmetic: the prompt, the + // stored hash and the grounding check read the same converted figures. + const units = resolveUnitPreferences({ + unitPreference: dbUser?.unitPreference, + glucoseUnit: dbUser?.glucoseUnit, + }); + features = featuresInReaderUnits(features, units); // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries // labelled-empty sections (`"sleep": []`, `"medications": []`, // etc.). Prevents the model from narrating "there are no @@ -651,7 +664,11 @@ export const POST = apiHandler((request: NextRequest) => comparisonSnapshot ?? undefined, ); if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + userPrompt += buildGlp1PlateauPrompt( + plateauContext, + locale, + units.system, + ); } // v1.15.20 — fold the user-authored "about me" self-description // (Settings → AI) into the briefing as a delimited, user-provided diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index e2adcb629..52c9fc140 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -1,7 +1,20 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { BbtChart } from "../bbt-chart"; +// `useUnitDisplay` reads the account through react-query; these static +// renders have no QueryClient, so the hook is a real display for a fixed +// preference the test sets. +const reader = vi.hoisted(() => ({ + preference: "metric" as "metric" | "imperial", +})); +vi.mock("@/hooks/use-unit-display", async () => { + const { unitDisplayFor } = + await import("@/__tests__/helpers/unit-display-mock"); + return { useUnitDisplay: () => unitDisplayFor(reader.preference) }; +}); + +import { BbtChart, BbtTooltip, bbtPoints } from "../bbt-chart"; +import { unitDisplayFor } from "@/__tests__/helpers/unit-display-mock"; import { I18nProvider } from "@/lib/i18n/context"; import type { CalendarDay } from "../types"; @@ -82,3 +95,46 @@ describe("", () => { expect(html).toContain('data-slot="cycle-bbt-caption"'); }); }); + +describe(" — the reader's temperature unit", () => { + it("plots each reading in °F for an imperial reader", () => { + const display = unitDisplayFor("imperial"); + const points = bbtPoints({ + days: [ + day("2026-06-05", { basalBodyTempC: 36.5 }), + day("2026-06-09", { basalBodyTempC: 36.9 }), + ], + fromDate: "2026-06-01", + today: "2026-06-10", + rawChartMode: false, + toDisplay: (c) => display.toDisplay("BODY_TEMPERATURE", c), + }); + expect(points.map((p) => p.temp)).toEqual([97.7, 98.4]); + }); + + it("labels the tooltip with the reader's unit", () => { + reader.preference = "imperial"; + try { + const html = render( + , + ); + expect(html).toContain("98.4 °F"); + expect(html).not.toContain("°C"); + } finally { + reader.preference = "metric"; + } + }); +}); diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx index ece977814..cedad4149 100644 --- a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -7,6 +7,14 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +// The BBT chart reads the unit preference through react-query; no +// QueryClient here, so a fixed metric display stands in. +vi.mock("@/hooks/use-unit-display", async () => { + const { unitDisplayFor } = + await import("@/__tests__/helpers/unit-display-mock"); + return { useUnitDisplay: () => unitDisplayFor("metric") }; +}); + vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), })); diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index 78e280933..85bb575f5 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import { buildDayLogPatch, buildDayLogInput, + bbtTextToCelsius, symptomsCount, temperatureCount, intimacyCount, @@ -330,3 +331,30 @@ describe("sheetDayContext (#1004)", () => { expect(ctx.periodEndable).toBe(true); }); }); + +describe("bbtTextToCelsius — the field reads in the reader's unit", () => { + const fromFahrenheit = (f: number) => (f - 32) / 1.8; + + it("stores a typed Fahrenheit reading as Celsius", () => { + expect(bbtTextToCelsius("98.2", null, fromFahrenheit)).toBe(36.78); + }); + + it("returns the stored value for text the sheet seeded, so a save never drifts it", () => { + const seed = { text: "98.2", celsius: 36.75 }; + expect(bbtTextToCelsius("98.2", seed, fromFahrenheit)).toBe(36.75); + expect(bbtTextToCelsius("98.4", seed, fromFahrenheit)).toBe(36.89); + }); + + it("is null for an empty or unreadable field", () => { + expect(bbtTextToCelsius("", null, fromFahrenheit)).toBeNull(); + expect(bbtTextToCelsius("abc", null, fromFahrenheit)).toBeNull(); + }); + + it("feeds the payload through the converter it is given", () => { + const patch = buildDayLogPatch({ ...blank(), bbt: "98.2" }, (text) => + bbtTextToCelsius(text, null, fromFahrenheit), + ); + expect(patch.basalBodyTempC).toBe(36.78); + expect(patch.temperatureExcluded).toBe(false); + }); +}); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx index 75471ce7d..30416c4fb 100644 --- a/src/components/cycle/bbt-chart.tsx +++ b/src/components/cycle/bbt-chart.tsx @@ -40,6 +40,7 @@ import { import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { cn } from "@/lib/utils"; import type { CalendarDay, CervicalMucus, OvulationTest } from "./types"; @@ -81,6 +82,39 @@ function ymdToMs(d: string): number { return Date.parse(`${d}T12:00:00Z`); } +/** + * The plotted readings of the window, oldest first, each temperature in the + * reader's unit (`toDisplay` converts the stored degrees Celsius). + */ +export function bbtPoints(args: { + days: CalendarDay[]; + fromDate: string; + today: string; + rawChartMode: boolean; + toDisplay: (celsius: number) => number; +}): BbtPoint[] { + const { days, fromDate, today, rawChartMode, toDisplay } = args; + const fromMs = ymdToMs(fromDate); + const todayMs = ymdToMs(today); + return days + .filter((d) => { + if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) { + return false; + } + const ms = ymdToMs(d.date); + return ms >= fromMs && ms <= todayMs; + }) + .map((d) => ({ + t: ymdToMs(d.date), + temp: toDisplay(d.basalBodyTempC as number), + phaseHue: + rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase], + mucus: d.cervicalMucus, + ovulationTest: d.ovulationTest, + })) + .sort((a, b) => a.t - b.t); +} + export function BbtChart({ days, today, @@ -91,6 +125,9 @@ export function BbtChart({ }: BbtChartProps) { const { t } = useTranslations(); const fmt = useFormatters(); + // Stored in °C, read in the reader's unit: every point, the padding and + // the empty-state domain go through the same transform. + const unitDisplay = useUnitDisplay(); const animationsEnabled = !prefersReducedMotion(); // Scope to the current cycle using the server's resolved start; fall back to @@ -101,27 +138,18 @@ export function BbtChart({ return shiftDateKey(today, -FALLBACK_WINDOW_DAYS); }, [cycleStartDate, today]); - const points = useMemo(() => { - const fromMs = ymdToMs(fromDate); - const todayMs = ymdToMs(today); - return days - .filter((d) => { - if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) { - return false; - } - const ms = ymdToMs(d.date); - return ms >= fromMs && ms <= todayMs; - }) - .map((d) => ({ - t: ymdToMs(d.date), - temp: d.basalBodyTempC as number, - phaseHue: - rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase], - mucus: d.cervicalMucus, - ovulationTest: d.ovulationTest, - })) - .sort((a, b) => a.t - b.t); - }, [days, fromDate, today, rawChartMode]); + const points = useMemo( + () => + bbtPoints({ + days, + fromDate, + today, + rawChartMode, + toDisplay: (celsius) => + unitDisplay.toDisplay("BODY_TEMPERATURE", celsius), + }), + [days, fromDate, today, rawChartMode, unitDisplay], + ); const ovulationMs = useMemo(() => { if (rawChartMode || !predictedOvulation) return null; @@ -133,16 +161,23 @@ export function BbtChart({ }, [predictedOvulation, points, rawChartMode]); const yDomain = useMemo<[number, number]>(() => { - if (points.length === 0) return [36, 37.5]; + if (points.length === 0) { + return [ + unitDisplay.toDisplay("BODY_TEMPERATURE", 36), + unitDisplay.toDisplay("BODY_TEMPERATURE", 37.5), + ]; + } const temps = points.map((p) => p.temp); const min = Math.min(...temps); const max = Math.max(...temps); - // Pad ±0.15 °C so the shift is legible and the line never hugs an edge. + // Pad by 0.15 °C (in the reader's unit) so the shift is legible and the + // line never hugs an edge. + const pad = unitDisplay.toDisplayDelta("BODY_TEMPERATURE", 0.15); return [ - Math.floor((min - 0.15) * 10) / 10, - Math.ceil((max + 0.15) * 10) / 10, + Math.floor((min - pad) * 10) / 10, + Math.ceil((max + pad) * 10) / 10, ]; - }, [points]); + }, [points, unitDisplay]); const ticks = useMemo(() => { if (points.length < 2) return []; @@ -279,12 +314,13 @@ function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) { } /** Tooltip restating the temperature + any fertility signs logged that day. */ -function BbtTooltip(props: { +export function BbtTooltip(props: { active?: boolean; payload?: { payload: BbtPoint }[]; }) { const { t } = useTranslations(); const fmt = useFormatters(); + const unitDisplay = useUnitDisplay(); if (!props.active || !props.payload || props.payload.length === 0) return null; const p = props.payload[0].payload; @@ -298,7 +334,10 @@ function BbtTooltip(props: { {fmt.dateShortSmart(new Date(p.t))}

- {t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })} + {t("cycle.bbt.tooltipTemp", { + temp: fmt.number(p.temp), + unit: unitDisplay.unitFor("BODY_TEMPERATURE"), + })}

{p.mucus ? (

diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 0a9fe400d..9dd962d90 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -55,14 +55,16 @@ const METRIC_LABEL_KEY: Record = { mood: "cycle.insights.crosstab.metricMood", }; -const UNIT_KEY: Record = { +/** + * Bundle labels for the displays no preference changes. Weight, the + * temperatures and glucose take their symbol from the reader's transform + * (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on. + */ +const UNIT_KEY: Partial> = { hours: "cycle.insights.crosstab.unitHours", steps: "cycle.insights.crosstab.unitSteps", bpm: "cycle.insights.crosstab.unitBpm", ms: "cycle.insights.crosstab.unitMs", - kg: "cycle.insights.crosstab.unitKg", - celsius: "cycle.insights.crosstab.unitCelsius", - glucose: "cycle.insights.crosstab.unitGlucose", mood: "cycle.insights.crosstab.unitMood", }; diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 8d7c5411f..3cf04f61d 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -45,6 +45,7 @@ import { import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveIntlLocale } from "@/lib/format-locale"; import type { Locale } from "@/lib/i18n/config"; import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; @@ -188,6 +189,23 @@ function resolveBbt(bbt: string): number | null { return n != null && Number.isFinite(n) ? n : null; } +/** + * The temperature field's text, typed in the reader's unit, as the stored + * degrees Celsius (two decimals, the field's own step). Text equal to what + * the sheet seeded returns the stored value untouched: converting it there + * and back would move a reading the person never edited. + */ +export function bbtTextToCelsius( + text: string, + seed: { text: string; celsius: number } | null, + toCelsius: (typed: number) => number, +): number | null { + const typed = resolveBbt(text); + if (typed == null) return null; + if (seed && text === seed.text) return seed.celsius; + return Math.round(toCelsius(typed) * 100) / 100; +} + function symptomList( symptoms: Map, ): CycleSymptomSelection[] { @@ -202,13 +220,22 @@ function symptomList( * deselected so the server clears it (the POST merge can only add/keep — QA * W-2). Pure + exported for the unit test. */ -export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch { +export function buildDayLogPatch( + s: DayLogFormState, + /** + * The typed temperature (in the reader's unit) as stored degrees Celsius. + * The sheet passes the preference-bound conversion; the default reads the + * field as Celsius. + */ + bbtToCelsius: (bbt: string) => number | null = resolveBbt, +): CycleDayLogPatch { + const bbtC = bbtToCelsius(s.bbt); return { flow: s.flow ?? null, intermenstrualBleeding: s.intermenstrual, - basalBodyTempC: resolveBbt(s.bbt), + basalBodyTempC: bbtC, // Only meaningful with a BBT present; a cleared reading resets the flag. - temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false, + temperatureExcluded: bbtC != null ? s.bbtDisturbed : false, ovulationTest: s.opk ?? null, cervicalMucus: s.mucus ?? null, cervixPosition: s.cervixPosition ?? null, @@ -229,8 +256,10 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch { export function buildDayLogInput( s: DayLogFormState, date: string, + /** See `buildDayLogPatch`. */ + bbtToCelsius: (bbt: string) => number | null = resolveBbt, ): CycleDayLogInput { - const bbtVal = resolveBbt(s.bbt); + const bbtVal = bbtToCelsius(s.bbt); return { date, loggedAt: new Date().toISOString(), @@ -619,6 +648,18 @@ export function LogDaySheet({ new Map(), ); const [bbt, setBbt] = useState(""); + // The temperature field reads and writes in the reader's unit; storage is + // degrees Celsius. The seeded text keeps its stored value, so saving an + // untouched reading never drifts it through a round trip of rounding. + const unitDisplay = useUnitDisplay(); + const [bbtSeed, setBbtSeed] = useState<{ + text: string; + celsius: number; + } | null>(null); + const bbtToCelsius = (text: string): number | null => + bbtTextToCelsius(text, bbtSeed, (typed) => + unitDisplay.fromDisplay("BODY_TEMPERATURE", typed), + ); const [bbtDisturbed, setBbtDisturbed] = useState(false); const [opk, setOpk] = useState(null); const [mucus, setMucus] = useState(null); @@ -666,7 +707,16 @@ export function LogDaySheet({ setFlow((dto?.flow ?? null) as FlowLevel | null); setIntermenstrual(dto?.intermenstrualBleeding ?? false); setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity]))); - setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : ""); + const seededBbt = + dto?.basalBodyTempC != null + ? String(unitDisplay.toDisplay("BODY_TEMPERATURE", dto.basalBodyTempC)) + : ""; + setBbt(seededBbt); + setBbtSeed( + dto?.basalBodyTempC != null + ? { text: seededBbt, celsius: dto.basalBodyTempC } + : null, + ); setBbtDisturbed(dto?.temperatureExcluded ?? false); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); @@ -753,10 +803,12 @@ export function LogDaySheet({ // chip actually CLEARS (the POST merge can only add/keep — QA W-2). await patchDay.mutateAsync({ id: currentRowId, - patch: buildDayLogPatch(formState), + patch: buildDayLogPatch(formState, bbtToCelsius), }); } else { - await logDay.mutateAsync(buildDayLogInput(formState, date)); + await logDay.mutateAsync( + buildDayLogInput(formState, date, bbtToCelsius), + ); } onOpenChange(false); } catch { @@ -1041,11 +1093,11 @@ export function LogDaySheet({ type="number" inputMode="decimal" step="0.01" - min={30} - max={45} + min={unitDisplay.toDisplay("BODY_TEMPERATURE", 30)} + max={unitDisplay.toDisplay("BODY_TEMPERATURE", 45)} value={bbt} onChange={(e) => setBbt(e.target.value)} - placeholder={t("cycle.sheet.temperaturePlaceholder")} + placeholder={unitDisplay.unitFor("BODY_TEMPERATURE")} className="w-32" aria-label={t("cycle.sheet.temperature")} /> diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts index 800cba90b..06634c8c7 100644 --- a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -11,6 +11,7 @@ * Dates are fixed and `now` is injected, so the fixture cannot slide out of its * window as the calendar moves. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -118,6 +119,7 @@ function snapshot(sections: Record): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts index 25869b76d..9b4aa84fc 100644 --- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -191,3 +191,32 @@ describe("buildCycleSnapshotBlock", () => { ); }); }); + +describe("buildCycleSnapshotBlock — the reader's units", () => { + it("states a weight contrast in pounds for an imperial reader", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + prismaMock.user.findUnique.mockResolvedValue({ + sourcePriorityJson: null, + unitPreference: "imperial", + }); + const weight = rhrMeasurements().map((row) => ({ + ...row, + type: "WEIGHT", + value: (row.value as number) + 10, + })); + prismaMock.measurement.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(weight); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + const insight = block!.phaseInsight!; + expect(insight.metric).toBe("weight"); + expect(insight.unit).toBe("lb"); + // Luteal ~73 kg, follicular ~67 kg: both means and the gap in pounds. + expect(insight.lutealAvg).toBeGreaterThan(155); + expect(insight.follicularAvg).toBeGreaterThan(145); + expect(insight.delta).toBeGreaterThan(12); + expect(insight.interpretation).toMatch(/runs about [\d.]+ lb higher/); + expect(insight.interpretation).not.toContain("kg"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts index 8881d7b52..f867e815c 100644 --- a/src/lib/ai/coach/__tests__/reference-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -113,6 +113,39 @@ describe("buildReferenceGroundingBlock", () => { expect(outOfGoal).toContain("outside the typical diabetes management goal"); }); + it("states the glucose bands in the reader's glucose unit and still places on mg/dL", () => { + const units = { system: "metric", glucoseUnit: "mmol/L" } as const; + const general = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }], + hasDiabetes: false, + units, + })!; + expect(general).toContain("≤5.5 mmol/L"); + expect(general).not.toContain("mg/dL"); + // 92 mg/dL is inside the fasting band: the placement is canonical. + expect(general).toContain("sits inside the general reference band"); + + const goal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }], + hasDiabetes: true, + units, + })!; + expect(goal).toContain("4.4–7.2 mmol/L"); + expect(goal).not.toContain("mg/dL"); + expect(goal).toContain("outside the typical diabetes management goal"); + }); + + it("states the body-temperature band in °F for an imperial reader", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BODY_TEMPERATURE", value: 36.8 }], + hasDiabetes: false, + units: { system: "imperial", glucoseUnit: "mg/dL" }, + })!; + expect(block).toContain("96.3–99.3 °F"); + expect(block).not.toContain("°C"); + expect(block).toContain("sits inside the general reference band"); + }); + it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => { // The headline band rendered must be the 18.5–24.9 normal range — the // index-0 bug surfaced the Underweight "≤18.5" ceiling instead. diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index e2355d6e8..29611e146 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -322,6 +322,7 @@ describe("buildCoachSnapshot", () => { ], "weekly": [], }, + "unit": "kg/m²", }, "dayStrain": { "days": 2, @@ -410,6 +411,7 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "°C", }, "steps": { "timeline": { @@ -428,14 +430,23 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "steps", }, "weight": { "aggregate": { + "allTimeAvg": undefined, + "allTimeMax": undefined, + "allTimeMin": undefined, + "avg30": undefined, + "avg7": undefined, + "avg90": undefined, "bmi": 24.7, "coverage": { "count": 2, }, "latest": 80.8, + "slope30": undefined, + "unit": "kg", }, "timeline": { "recent": [ @@ -453,6 +464,7 @@ describe("buildCoachSnapshot", () => { }, ], }, + "unit": "kg", }, }, "snapshotJson": "{ @@ -490,12 +502,14 @@ describe("buildCoachSnapshot", () => { } }, "weight": { + "unit": "kg", "aggregate": { "latest": 80.8, "bmi": 24.7, "coverage": { "count": 2 - } + }, + "unit": "kg" }, "timeline": { "recent": [ @@ -563,6 +577,7 @@ describe("buildCoachSnapshot", () => { } }, "bodyMassIndex": { + "unit": "kg/m²", "timeline": { "recent": [ { @@ -580,6 +595,7 @@ describe("buildCoachSnapshot", () => { } }, "steps": { + "unit": "steps", "timeline": { "recent": [ { @@ -598,6 +614,7 @@ describe("buildCoachSnapshot", () => { } }, "skinTemperature": { + "unit": "°C", "timeline": { "recent": [ { @@ -643,6 +660,10 @@ describe("buildCoachSnapshot", () => { "timelineRecentDays": 14 } }", + "units": { + "glucoseUnit": "mg/dL", + "system": "metric", + }, } `); } finally { @@ -657,6 +678,55 @@ describe("buildCoachSnapshot", () => { expect(parsed.scope.window).toBe("last30days"); }); + it("states weight, temperature and the grounding bands in an imperial reader's units", async () => { + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + unitPreference: "imperial", + glucoseUnit: "mmol/L", + hasDiabetes: false, + }); + featuresMock.mockResolvedValue({ + weight: { + latest: 80, + avg7: 80.5, + bmi: 24.7, + coverage: { count: 2 }, + }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(4, 80, "WEIGHT"), + daysAgo(2, 81, "WEIGHT"), + daysAgo(3, 36.6, "BODY_TEMPERATURE"), + daysAgo(1, 37, "BODY_TEMPERATURE"), + ]); + + const out = await buildCoachSnapshot("imperial-user", { + window: "last30days", + sources: ["weight", "body_temp"], + }); + const parsed = JSON.parse(out.snapshotJson); + expect(parsed.weight.unit).toBe("lb"); + expect(parsed.weight.aggregate).toMatchObject({ + unit: "lb", + latest: 176.4, + avg7: 177.5, + bmi: 24.7, + }); + expect( + parsed.weight.timeline.recent.map((r: { value: number }) => r.value), + ).toEqual([176.4, 178.6]); + expect(parsed.bodyTemperature.unit).toBe("°F"); + expect( + parsed.bodyTemperature.timeline.recent.map( + (r: { value: number }) => r.value, + ), + ).toEqual([97.9, 98.6]); + // The band beside it is in °F too, placed on the canonical °C mean. + expect(out.referenceGrounding).toContain("96.3–99.3 °F"); + expect(out.referenceGrounding).not.toContain("°C"); + }); + it("includes day-level BP rows with weekday labels for the recent window", async () => { featuresMock.mockResolvedValue({ bloodPressure: { diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index 50208afb9..d40440bc0 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -113,6 +113,22 @@ describe("getCoachSystemPrompt — EN", () => { expect(prompt).toMatch(/isSpotEstimate/); }); + it("names the time-in-range band through the block, never in one fixed unit", () => { + // The glucose block is in the reader's unit; a band stated here in + // mg/dL would sit beside mmol/L numbers for a mmol/L reader. + expect(prompt).toMatch(/"tirRange"/); + expect(prompt).not.toMatch(/70–180 mg\/dL/); + }); + + it("tells the model every block is already in the reader's unit", () => { + expect(prompt).toMatch( + /A block's "unit" \(and the workouts block's "distanceUnit"\)/, + ); + expect(prompt).toMatch(/never convert them/); + // No fixed list that offers kg (or both glucose units) for one quantity. + expect(prompt).not.toMatch(/"unit" is one of/); + }); + it("documents the WHOOP-native dayStrain block vs the computed proxy", () => { // v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy. expect(prompt).toMatch(/dayStrain/); @@ -197,12 +213,16 @@ describe("getCoachSystemPrompt — DE", () => { expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); }); - it("lists glucose + duration units in the evidence-block enumeration", () => { - // v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a - // German coach can cite glucose + duration values with a valid token. - expect(prompt).toMatch(/mg\/dL/); - expect(prompt).toMatch(/mmol\/L/); - expect(prompt).toMatch(/min[\s\S]*\(Schlafschuld/); + it("takes the evidence-block unit from the snapshot, not from a fixed list", () => { + // The list used to be fixed (mmHg, kg, …, mg/dL, mmol/L): a reader on + // pounds had no sanctioned token, and both glucose units were offered + // for one quantity. The unit now comes from the block that holds the + // value; minutes stay named for the sleep-debt figures. + expect(prompt).toMatch( + /"unit" ist die Einheit, die der Snapshot für diesen Wert angibt/, + ); + expect(prompt).toMatch(/min für Schlafschuld/); + expect(prompt).not.toMatch(/"unit" ist eines aus/); }); it("carries the v1.21.2 narrative-presence clauses (A4/A5/A6/A7)", () => { @@ -303,3 +323,14 @@ describe("conditional trajectory ground rule (v1.11.0 Epic B, Pillar 3)", () => expect(de).toMatch(/datiertes\s+Ereignis/i); }); }); + +describe("getCoachSystemPrompt — native locales name the reader's unit", () => { + it.each(["fr", "es", "it", "pl"] as const)( + "%s carries the block-unit rule and no fixed unit list", + (locale) => { + const prompt = getCoachSystemPrompt(locale); + expect(prompt).toContain('"distanceUnit"'); + expect(prompt).not.toMatch(/mmHg, kg, bpm/); + }, + ); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index ff751c4ab..e7d22ab59 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -4,6 +4,7 @@ * structured `{ present: false }` for an absent domain — the hallucination * audit asserts the model can always tell "no data" from "data". */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { describe, expect, it, vi, beforeEach } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -86,6 +87,7 @@ function snapshot( sections, provenance: { windows: [], metrics: [] }, referenceGrounding, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index f4d9a3947..2206540fb 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -4,6 +4,7 @@ * render a compact, brand-free, grounding-honest block. The model reads this to * know what is fetchable so it never invents a metric. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { describe, expect, it, vi, beforeEach } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -37,6 +38,7 @@ function snapshot( ...(counts ? { counts: counts as never } : {}), }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 510638c23..fe6ee6202 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -21,6 +21,14 @@ * day" claim. */ import { prisma } from "@/lib/db"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; import { @@ -60,13 +68,16 @@ const METRIC_LABEL: Record = { skinTemperature: "skin temperature", }; +/** + * Labels for the display kinds no preference changes. Weight, the + * temperatures and glucose are read in the reader's units instead, through + * the measurement type's transform (see `headlineInReaderUnits`). + */ const UNIT_LABEL: Record = { hours: "h", steps: "steps", bpm: "bpm", ms: "ms", - kg: "kg", - celsius: "°C", }; interface PhaseInsightBlock { @@ -108,12 +119,42 @@ export interface CycleSnapshotBlock { phaseInsight: PhaseInsightBlock | null; } +/** + * The headline contrast's figures in the reader's units. The two means take + * the full transform, the difference the factor alone; a kind no preference + * touches keeps its numbers and its fixed label. + */ +function headlineInReaderUnits( + row: PhaseMetricCrosstabRow, + units: UnitPreferences, +): { lutealAvg: number; follicularAvg: number; delta: number; unit: string } { + const type = PHASE_CROSSTAB_METRICS[row.metricKey].type; + if (!isUnitSensitiveType(type)) { + return { + lutealAvg: row.lutealAvg, + follicularAvg: row.follicularAvg, + delta: row.delta, + unit: UNIT_LABEL[row.display] ?? "", + }; + } + const transform = getReadingTransform(type, units); + return { + lutealAvg: applyDisplayTransform(row.lutealAvg, transform), + follicularAvg: applyDisplayTransform(row.follicularAvg, transform), + delta: applyDisplayTransformDelta(row.delta, transform), + unit: transform.displayUnit, + }; +} + /** Descriptive, never-causal interpretation of the headline contrast. */ -function interpretHeadline(row: PhaseMetricCrosstabRow): string { +function interpretHeadline( + row: PhaseMetricCrosstabRow, + shown: { delta: number; unit: string }, +): string { const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey; - const dir = row.delta >= 0 ? "higher" : "lower"; - const mag = Math.abs(row.delta); - const unit = UNIT_LABEL[row.display] ?? ""; + const dir = shown.delta >= 0 ? "higher" : "lower"; + const mag = Math.abs(shown.delta); + const unit = shown.unit; return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`; } @@ -212,7 +253,11 @@ export async function buildCycleSnapshotBlock( }), prisma.user.findUnique({ where: { id: userId }, - select: { sourcePriorityJson: true }, + select: { + sourcePriorityJson: true, + unitPreference: true, + glucoseUnit: true, + }, }), ]); @@ -301,18 +346,24 @@ export async function buildCycleSnapshotBlock( let phaseInsight: PhaseInsightBlock | null = null; if (headlineRow) { - const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey]; + const shown = headlineInReaderUnits( + headlineRow, + resolveUnitPreferences({ + unitPreference: userRow?.unitPreference, + glucoseUnit: userRow?.glucoseUnit, + }), + ); phaseInsight = { metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey, - unit: UNIT_LABEL[cfg.display] ?? "", - lutealAvg: headlineRow.lutealAvg, - follicularAvg: headlineRow.follicularAvg, - delta: headlineRow.delta, + unit: shown.unit, + lutealAvg: shown.lutealAvg, + follicularAvg: shown.follicularAvg, + delta: shown.delta, confidence: headlineRow.confidence, qValue: headlineRow.qValue, lutealDays: headlineRow.lutealDays, follicularDays: headlineRow.follicularDays, - interpretation: interpretHeadline(headlineRow), + interpretation: interpretHeadline(headlineRow, shown), }; } diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts index 00af268fe..166405427 100644 --- a/src/lib/ai/coach/reference-grounding.ts +++ b/src/lib/ai/coach/reference-grounding.ts @@ -47,6 +47,12 @@ import { type ReferenceMetric, type ReferencePlacement, } from "@/lib/reference-ranges"; +import { + applyDisplayTransform, + DEFAULT_UNIT_PREFERENCES, + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; /** * One metric the snapshot carries, paired with the user's current @@ -74,6 +80,44 @@ export interface ReferenceGroundingInput { * for the glucose line ONLY. Never inferred from a reading. */ readonly hasDiabetes: boolean; + /** + * The reader's two unit choices. The band text is printed in them, so the + * line sits beside the glucose block (already in the reader's glucose unit) + * without handing the model a second unit for the same quantity. The + * placement is still classified on the canonical value. Defaults to metric + * and mg/dL. + */ + readonly units?: UnitPreferences; +} + +/** + * The reference metrics whose band is a stored measurement read in a + * preference-dependent unit, keyed to that measurement type. Every other + * band (mmHg, bpm, %, h, kg/m²) reads the same for everyone. + */ +const UNIT_SENSITIVE_REFERENCE_TYPE: Partial> = + { + BLOOD_GLUCOSE: "BLOOD_GLUCOSE", + BODY_TEMPERATURE: "BODY_TEMPERATURE", + }; + +/** A canonical band edge and the unit symbol, in the reader's units. */ +function bandEdges( + metric: ReferenceMetric, + units: UnitPreferences, +): { edge: (value: number) => number; unit: string } { + const type = UNIT_SENSITIVE_REFERENCE_TYPE[metric]; + if (!type) { + return { + edge: (value) => value, + unit: getReferenceRange(metric)?.unit ?? "", + }; + } + const transform = getReadingTransform(type, units); + return { + edge: (value) => applyDisplayTransform(value, transform), + unit: transform.displayUnit, + }; } /** @@ -140,17 +184,20 @@ function placementPhrase(placement: ReferencePlacement): string { * pulse-pressure Narrow). Open-ended bounds render with a leading/trailing * ≤ / ≥. */ -function headlineBandText(metric: ReferenceMetric): string | null { +function headlineBandText( + metric: ReferenceMetric, + units: UnitPreferences, +): string | null { const range = getReferenceRange(metric); if (!range || range.bands.length === 0) return null; const idx = normalBandIndex(metric); const band = range.bands[idx]; - const unit = range.unit; + const { edge, unit } = bandEdges(metric, units); if (band.low != null && band.high != null) { - return `${band.low}–${band.high} ${unit}`; + return `${edge(band.low)}–${edge(band.high)} ${unit}`; } - if (band.high != null) return `≤${band.high} ${unit}`; - if (band.low != null) return `≥${band.low} ${unit}`; + if (band.high != null) return `≤${edge(band.high)} ${unit}`; + if (band.low != null) return `≥${edge(band.low)} ${unit}`; return null; } @@ -160,9 +207,14 @@ function headlineBandText(metric: ReferenceMetric): string | null { * GOAL band when the user declared diabetes. The diabetic branch frames the * band as a clinician-set GOAL, never a screening threshold. */ -function glucoseLine(value: number | null, hasDiabetes: boolean): string { +function glucoseLine( + value: number | null, + hasDiabetes: boolean, + units: UnitPreferences, +): string { const label = METRIC_LABEL.BLOOD_GLUCOSE; if (hasDiabetes) { + const { edge, unit } = bandEdges("BLOOD_GLUCOSE", units); const { low, high } = ADA_DIABETIC_FASTING_GOAL; // Placement against the diabetic GOAL band, framed as a target the // user's clinician individualises — never a diagnostic call. @@ -174,11 +226,11 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string { } else { where = "is outside the typical diabetes management goal"; } - return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel( + return `- ${label}: a common diabetes management goal is ${edge(low)}–${edge(high)} ${unit} fasting (${referenceLabel( "BLOOD_GLUCOSE", )} — clinician-set goal, individualised, not a screening line). Yours ${where}.`; } - const band = headlineBandText("BLOOD_GLUCOSE"); + const band = headlineBandText("BLOOD_GLUCOSE", units); const placement = classifyReference("BLOOD_GLUCOSE", value); return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel( "BLOOD_GLUCOSE", @@ -186,9 +238,13 @@ function glucoseLine(value: number | null, hasDiabetes: boolean): string { } /** Build one standard (non-glucose) grounding line for a metric. */ -function standardLine(metric: ReferenceMetric, value: number | null): string { +function standardLine( + metric: ReferenceMetric, + value: number | null, + units: UnitPreferences, +): string { const label = METRIC_LABEL[metric]; - const band = headlineBandText(metric); + const band = headlineBandText(metric, units); const placement = classifyReference(metric, value); const cite = referenceLabel(metric); // Blood pressure cites ESH 2023 by construction (the reference-range @@ -234,10 +290,11 @@ export function buildReferenceGroundingBlock( } if (ordered.length === 0) return null; + const units = input.units ?? DEFAULT_UNIT_PREFERENCES; const lines = ordered.map((m) => m.metric === "BLOOD_GLUCOSE" - ? glucoseLine(m.value, input.hasDiabetes) - : standardLine(m.metric, m.value), + ? glucoseLine(m.value, input.hasDiabetes, units) + : standardLine(m.metric, m.value, units), ); // The preamble + closing caveat carry the firm general-guidance framing. diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index 3ea6c0338..e7d2fe1f6 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -4,6 +4,7 @@ * turn belongs to, the older tools' projections, and the table never in * what the model reads. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; @@ -59,6 +60,7 @@ function snapshot(sections: Record): CoachSnapshotResult { sections, provenance: { windows: [], metrics: [] }, referenceGrounding: null, + units: DEFAULT_UNIT_PREFERENCES, }; } diff --git a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts index 3441728d8..715886bac 100644 --- a/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts +++ b/src/lib/ai/coach/results/__tests__/metric-table-tool.test.ts @@ -3,6 +3,7 @@ * own days, one row per period with absence kept as null, weeks folded the * way the chart folds them, and the bounded summary the model reads. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { beforeEach, describe, expect, it, vi } from "vitest"; const readDailySeries = vi.fn(); @@ -172,6 +173,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table).not.toBeNull(); @@ -214,6 +216,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const chart = bucketTimeSeries( @@ -243,6 +246,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table).toBeNull(); @@ -263,6 +267,7 @@ describe("readMetricTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const rows = new Map(table!.rows.map((row) => [row[0], row])); @@ -298,6 +303,7 @@ describe("summariseTable", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }))!; } @@ -388,6 +394,7 @@ describe("totals and levels", () => { timeZone: tz, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: WEST_NOW, }); } @@ -408,6 +415,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1]).toMatchObject({ @@ -439,6 +447,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1].labelKey).toBe("coach.result.column.mean"); @@ -536,6 +545,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); const day = table!.rows.find((row) => row[0] === "2026-09-25")!; @@ -556,6 +566,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns[1].decimals).toBe(2); @@ -581,6 +592,7 @@ describe("totals and levels", () => { timeZone: TZ, locale: "en", ref: "r1", + units: DEFAULT_UNIT_PREFERENCES, now: NOW, }); expect(table!.columns.map((c) => c.key)).toEqual([ @@ -606,3 +618,50 @@ describe("totals and levels", () => { }); }); }); + +describe("readMetricTable — the reader's units", () => { + const read = ( + metric: "weight" | "distance" | "walking_speed", + units: { system: "metric" | "imperial"; glucoseUnit: "mg/dL" }, + ) => + readMetricTable({ + userId: "u1", + metric, + window: "last7days", + period: "current", + granularity: undefined, + timeZone: TZ, + locale: "en", + ref: "r1", + units, + now: NOW, + }); + + it("states weight in pounds for an imperial reader", async () => { + readDailySeries.mockResolvedValue([dayRow("WEIGHT", "2026-09-27", 80)]); + const table = await read("weight", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(table!.columns[1]).toMatchObject({ unit: "lb", decimals: 1 }); + expect(table!.rows.at(-1)).toEqual(["2026-09-27", 176.4, 1]); + }); + + it("states walking distance in miles and kilometres, never in metres", async () => { + readDailySeries.mockResolvedValue([ + dayRow("WALKING_RUNNING_DISTANCE", "2026-09-27", 8000), + ]); + const imperial = await read("distance", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(imperial!.columns[1]).toMatchObject({ unit: "mi", decimals: 2 }); + expect(imperial!.rows.at(-1)).toEqual(["2026-09-27", 4.97, 1]); + const metric = await read("distance", { + system: "metric", + glucoseUnit: "mg/dL", + }); + expect(metric!.columns[1]).toMatchObject({ unit: "km" }); + expect(metric!.rows.at(-1)).toEqual(["2026-09-27", 8, 1]); + }); +}); diff --git a/src/lib/ai/coach/results/metric-table-tool.ts b/src/lib/ai/coach/results/metric-table-tool.ts index 23d230ed9..1ea4b50a2 100644 --- a/src/lib/ai/coach/results/metric-table-tool.ts +++ b/src/lib/ai/coach/results/metric-table-tool.ts @@ -48,6 +48,13 @@ import { annotate } from "@/lib/logging/context"; import { bucketTimeSeries } from "@/lib/charts/bucket-time-series"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { getUnitForType } from "@/lib/validations/measurement"; +import { + applyDisplayTransform, + getReadingTransform, + hasDisplayTransform, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; import { COACH_RESULT_COLUMN_KEYS, @@ -212,10 +219,16 @@ export function periodKeys( /** One value series of a table: its column and its values by day. */ interface DaySeries { column: Omit; - /** Local day key → the day's value. */ + /** Local day key → the day's value, canonical. */ byDay: Map; /** How days fold into a week or month: a total sums, a level averages. */ fold: "sum" | "mean"; + /** + * The reader's-unit transform, applied once to each folded period value + * (the column's unit and decimals already name it). Absent for a series + * no preference touches. + */ + transform?: DisplayTransform; } /** Per-day values and reading counts for a metric, before bucketing. */ @@ -227,7 +240,6 @@ interface MetricDays { const UNIT_TOKENS: Readonly> = { minutes: "min", - celsius: "°C", }; /** @@ -281,6 +293,7 @@ const SERIES_SUFFIX: Readonly>> = { type MeasurementColumn = DaySeries["column"] & { type: MeasurementType; suffix?: string; + transform?: DisplayTransform; }; /** @@ -302,6 +315,7 @@ function measurementColumns( metric: CoachScopeSource, types: readonly MeasurementType[], granularity: CoachResultGranularity, + units: UnitPreferences, ): MeasurementColumn[] { if (metric === "bp") { return [ @@ -330,16 +344,24 @@ function measurementColumns( ? COACH_RESULT_COLUMN_KEYS.total : COACH_RESULT_COLUMN_KEYS.mean; return types.map((type) => { - const unit = unitTokenFor(type); const suffix = types.length > 1 ? (SERIES_SUFFIX[type] ?? type) : undefined; + // A mass, length, temperature, speed or distance is read in the + // reader's units, at the decimals that unit is read at. + const transform = hasDisplayTransform(type) + ? getReadingTransform(type, units) + : undefined; + const unit = transform ? transform.displayUnit : unitTokenFor(type); return { key: suffix ? suffix.toLowerCase() : "value", kind: "number" as const, labelKey, ...(unit ? { unit } : {}), - decimals: decimalsForType(type, unit ?? ""), + decimals: transform + ? transform.decimals + : decimalsForType(type, unit ?? ""), type, ...(suffix ? { suffix } : {}), + ...(transform ? { transform } : {}), }; }); } @@ -374,10 +396,11 @@ async function readMeasurementDays(args: { granularity: CoachResultGranularity; range: TableRange; timeZone: string; + units: UnitPreferences; }): Promise }> { - const { userId, metric, granularity, range, timeZone } = args; + const { userId, metric, granularity, range, timeZone, units } = args; const types = COACH_SOURCE_MEASUREMENT_TYPES[metric]; - const columns = measurementColumns(metric, types, granularity); + const columns = measurementColumns(metric, types, granularity, units); const priorityJson = await loadUserSourcePriority(userId); const rowsByType = await Promise.all( columns.map((column) => @@ -403,12 +426,13 @@ async function readMeasurementDays(args: { counts.set(day, (counts.get(day) ?? 0) + count); } if (column.suffix) suffixes.set(column.key, column.suffix); - const { type: _type, suffix: _suffix, ...rest } = column; + const { type: _type, suffix: _suffix, transform, ...rest } = column; return { column: rest, byDay, fold: total ? ("sum" as const) : ("mean" as const), counts, + ...(transform ? { transform } : {}), }; }); const { ordered, counts } = orderAndCount(metric, read); @@ -443,14 +467,15 @@ async function readMeasurementMonths(args: { userId: string; metric: CoachScopeSource; range: TableRange; + units: UnitPreferences; }): Promise<{ series: DaySeries[]; periods: PeriodValues; suffixes: Map; }> { - const { userId, metric, range } = args; + const { userId, metric, range, units } = args; const types = COACH_SOURCE_MEASUREMENT_TYPES[metric]; - const columns = measurementColumns(metric, types, "month"); + const columns = measurementColumns(metric, types, "month", units); const total = aggregationKind(metric) === "total"; const priorityJson = await loadUserSourcePriority(userId); // The first whole UTC month of the range: a bucket that starts before @@ -512,7 +537,7 @@ async function readMeasurementMonths(args: { const suffixes = new Map(); const read = columns.map((column, index) => { if (column.suffix) suffixes.set(column.key, column.suffix); - const { type: _type, suffix: _suffix, ...rest } = column; + const { type: _type, suffix: _suffix, transform, ...rest } = column; const byDay = new Map(); const counts = new Map(); for (const [month, { value, count }] of byType[index]) { @@ -524,6 +549,7 @@ async function readMeasurementMonths(args: { byDay, fold: total ? ("sum" as const) : ("mean" as const), counts, + ...(transform ? { transform } : {}), }; }); const { ordered, counts } = orderAndCount(metric, read); @@ -743,9 +769,14 @@ export async function readMetricTable(args: { timeZone: string; locale: Locale; ref: string; + /** + * The reader's units. The person sees the table and the model reads its + * summary, so a mass, temperature, speed or distance is stated in them. + */ + units: UnitPreferences; now?: Date; }): Promise { - const { userId, metric, window, timeZone, locale, ref } = args; + const { userId, metric, window, timeZone, locale, ref, units } = args; const now = args.now ?? new Date(); const period = window === "allTime" ? "current" : args.period; const granularity = effectiveGranularity(window, args.granularity); @@ -765,7 +796,7 @@ export async function readMetricTable(args: { series = days.series; folded = foldIntoPeriods({ days, granularity, timeZone }); } else if (window === "allTime") { - const read = await readMeasurementMonths({ userId, metric, range }); + const read = await readMeasurementMonths({ userId, metric, range, units }); series = read.series; folded = read.periods; suffixes = read.suffixes; @@ -777,6 +808,7 @@ export async function readMetricTable(args: { granularity, range, timeZone, + units, }); series = read.series; folded = foldIntoPeriods({ days: read, granularity, timeZone }); @@ -826,9 +858,10 @@ export async function readMetricTable(args: { const cells = values.get(key); return [ key, - ...series.map((_, index) => { + ...series.map((s, index) => { const value = cells?.[index]; - return value === undefined || !Number.isFinite(value) ? null : value; + if (value === undefined || !Number.isFinite(value)) return null; + return s.transform ? applyDisplayTransform(value, s.transform) : value; }), cells ? (counts.get(key) ?? 0) : null, ]; diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts new file mode 100644 index 000000000..d42febc45 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/glucose-block-units.test.ts @@ -0,0 +1,62 @@ +/** + * The glucose block states its time-in-range band in the block's own unit, + * so the prompt never has to name the band in mg/dL beside mmol/L numbers. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { buildGlucoseBlock } from "../glucose-block"; +import type { GlucoseUnit } from "@/lib/glucose"; + +const NOW = new Date("2026-06-14T12:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; +const VALUES = [ + 60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105, + 115, 125, 135, 145, +]; + +function build(glucoseUnit: GlucoseUnit) { + const rows = VALUES.map((value, i) => ({ + type: "BLOOD_GLUCOSE", + value, + measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY), + glucoseContext: null, + })); + const snapshot: Record = {}; + buildGlucoseBlock({ + measurementRows: rows, + glucoseCutoff: new Date(NOW.getTime() - 60 * DAY), + glucoseClinicalRows: rows.map(({ value, measuredAt }) => ({ + value, + measuredAt, + })), + glucoseUnit, + recentCutoff: new Date(NOW.getTime() - 7 * DAY), + userTz: "UTC", + now: NOW, + snapshot, + metrics: new Set(), + counts: {}, + registerBlock: () => {}, + groundingValues: new Map(), + }); + return snapshot.glucose as { + unit: string; + clinical: { stillLearning: boolean; tirRange?: unknown }; + }; +} + +describe("buildGlucoseBlock — time-in-range band", () => { + it("carries the band in mmol/L for a mmol/L reader", () => { + const block = build("mmol/L"); + expect(block.unit).toBe("mmol/L"); + expect(block.clinical.stillLearning).toBe(false); + expect(block.clinical.tirRange).toEqual({ low: 3.9, high: 10 }); + }); + + it("carries the band in mg/dL for a mg/dL reader", () => { + const block = build("mg/dL"); + expect(block.clinical.tirRange).toEqual({ low: 70, high: 180 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts new file mode 100644 index 000000000..9ed3415a4 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -0,0 +1,54 @@ +/** + * Workout distances reach the Coach in the reader's distance unit, named once + * on the block, instead of raw metres under a metre-named key. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { buildWorkoutsBlock } from "../workouts-block"; +import type { UnitPreference } from "@/lib/measurements/display-transform"; + +function build(unitPreference: UnitPreference) { + const snapshot: Record = {}; + buildWorkoutsBlock({ + workoutRows: [ + { + sportType: "running", + startedAt: new Date("2026-09-20T07:00:00Z"), + durationSec: 1800, + totalEnergyKcal: 300, + totalDistanceM: 5000, + avgHeartRate: 150, + maxHeartRate: 170, + source: "APPLE_HEALTH", + }, + ], + sourcePriorityJson: null, + userTz: "UTC", + snapshot, + metrics: new Set(), + counts: {}, + registerBlock: () => {}, + unitPreference, + }); + return snapshot.workouts as { + distanceUnit: string; + recent: Array>; + }; +} + +describe("buildWorkoutsBlock — distance unit", () => { + it("states distance in miles for an imperial reader", () => { + const block = build("imperial"); + expect(block.distanceUnit).toBe("mi"); + expect(block.recent[0]).toMatchObject({ distance: 3.11 }); + expect(block.recent[0]).not.toHaveProperty("distanceM"); + }); + + it("states distance in kilometres for a metric reader", () => { + const block = build("metric"); + expect(block.distanceUnit).toBe("km"); + expect(block.recent[0]).toMatchObject({ distance: 5 }); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index 2a3e23493..eda25227a 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -1,10 +1,16 @@ import type { AggregatedFeatures } from "@/lib/insights/features"; +import { weightFeatureInReaderUnits } from "@/lib/insights/features-units"; +import { + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { bpBandFromRows, bucketWeekly, buildDailyBpRows, buildDailyValueRows, + timelineInUnit, type CoarseTimelineTail, } from "../snapshot-series"; import type { @@ -38,6 +44,8 @@ interface CoreMetricsBlockContext { counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** The reader's units; the weight block is stated in them. */ + units: UnitPreferences; } export function buildCoreMetricsBlocks( @@ -93,16 +101,24 @@ export function buildCoreMetricsBlocks( if (ctx.sources.has("weight") && ctx.features.weight) { const rows = byType("WEIGHT"); + const aggregate = weightFeatureInReaderUnits( + ctx.features.weight, + ctx.units, + ); ctx.snapshot.weight = { - aggregate: ctx.features.weight, - timeline: { - recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz), - weekly: bucketWeekly( - rows.filter((row) => row.measuredAt < ctx.recentCutoff), - ctx.userTz, - ), - ...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}), - }, + unit: aggregate.unit, + aggregate, + timeline: timelineInUnit( + { + recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz), + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + ...(ctx.coarseTails.weight ? { coarse: ctx.coarseTails.weight } : {}), + }, + getReadingTransform("WEIGHT", ctx.units), + ), }; ctx.metrics.add("weight"); ctx.windows.add("last7days"); diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index cd8ac806f..e95d4e3b3 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -20,6 +20,7 @@ import { import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, + TIME_IN_RANGE_MGDL, } from "@/lib/analytics/glucose-metrics"; import { annotate } from "@/lib/logging/context"; import type { ReferenceMetric } from "@/lib/reference-ranges"; @@ -162,6 +163,12 @@ export function buildGlucoseBlock(ctx: Readonly): void { tirPercent: clinicalRaw.distribution ? Math.round(clinicalRaw.distribution.tir * 100) : null, + // The band the percentage counts, in the block's unit, so the + // prompt never states it in a unit the reader does not use. + tirRange: { + low: convertGlucose(TIME_IN_RANGE_MGDL.low, glucoseUnit), + high: convertGlucose(TIME_IN_RANGE_MGDL.high, glucoseUnit), + }, timeBelowPercent: clinicalRaw.distribution ? Math.round(clinicalRaw.distribution.tbrLevel1 * 100) : null, diff --git a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts index df6c25b88..a31da2d04 100644 --- a/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts +++ b/src/lib/ai/coach/snapshot-blocks/value-series-blocks.ts @@ -1,7 +1,15 @@ import { annotate } from "@/lib/logging/context"; +import { + getReadingTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { ReferenceMetric } from "@/lib/reference-ranges"; import { sourceCluster } from "../clusters"; -import { bucketWeekly, buildDailyValueRows } from "../snapshot-series"; +import { + bucketWeekly, + buildDailyValueRows, + timelineInUnit, +} from "../snapshot-series"; import type { CoachProvenance, CoachProvenanceMetric, @@ -241,8 +249,13 @@ interface ValueSeriesBlocksContext { counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; groundingValues: Map; + /** The reader's units; every block is stated and labelled in them. */ + units: UnitPreferences; } +/** Unit tokens that are not a unit a reader could be quoted. */ +const UNLABELLED_UNITS: ReadonlySet = new Set(["", "unknown", "score"]); + export function buildValueSeriesBlocks( ctx: Readonly, ): void { @@ -270,14 +283,26 @@ export function buildValueSeriesBlocks( } const recentRows = buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz); + // Aggregated on the canonical value (the grounding below reads that), + // stated in the reader's unit. The two HRV estimators share one unit. + const transform = getReadingTransform( + typeof block.type === "string" ? block.type : block.type[0], + ctx.units, + ); ctx.snapshot[block.snapshotKey] = { - timeline: { - recent: recentRows, - weekly: bucketWeekly( - rows.filter((row) => row.measuredAt < ctx.recentCutoff), - ctx.userTz, - ), - }, + ...(UNLABELLED_UNITS.has(transform.displayUnit) + ? {} + : { unit: transform.displayUnit }), + timeline: timelineInUnit( + { + recent: recentRows, + weekly: bucketWeekly( + rows.filter((row) => row.measuredAt < ctx.recentCutoff), + ctx.userTz, + ), + }, + transform, + ), }; ctx.metrics.add(block.metric); ctx.counts[block.metric] = rows.length; diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index 2a396428d..c44a8be52 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -20,6 +20,11 @@ * the UI. Run the SAME picker here, keyed off the user's own * `sourcePriorityJson`, so both surfaces agree. */ +import { + applyDisplayTransform, + getQuantityTransform, + type UnitPreference, +} from "@/lib/measurements/display-transform"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import type { MeasurementSource } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; @@ -57,6 +62,8 @@ interface WorkoutsBlockContext { metrics: Set; counts: NonNullable; registerBlock: (key: string, source: CoachScopeSource) => void; + /** The reader's metric/imperial choice; distances are stated in it. */ + unitPreference: UnitPreference; } export function buildWorkoutsBlock(ctx: Readonly): void { @@ -68,7 +75,9 @@ export function buildWorkoutsBlock(ctx: Readonly): void { metrics, counts, registerBlock, + unitPreference, } = ctx; + const distanceTransform = getQuantityTransform("distance", unitPreference); // The workout sessions are read in parallel by the builder, raw // (every source, every duplicate). Collapse cross-source duplicates // with the same picker the UI uses before narrating anything. @@ -92,7 +101,10 @@ export function buildWorkoutsBlock(ctx: Readonly): void { sport: w.sportType, durationMin: Math.round(w.durationSec / 60), energyKcal: w.totalEnergyKcal ?? null, - distanceM: w.totalDistanceM ?? null, + distance: + w.totalDistanceM == null + ? null + : applyDisplayTransform(w.totalDistanceM, distanceTransform), avgHr: w.avgHeartRate ?? null, maxHr: w.maxHeartRate ?? null, })); @@ -121,6 +133,7 @@ export function buildWorkoutsBlock(ctx: Readonly): void { })) .sort((a, b) => b.count - a.count); snapshot.workouts = { + distanceUnit: distanceTransform.displayUnit, recent: recentList, perSport: sportRollup, totalInWindow: workoutRows.length, diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index 4df20de6e..f06fc3328 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -8,6 +8,10 @@ * is one-way (snapshot → series), never back. */ import { userDayKey } from "@/lib/tz/format"; +import { + applyDisplayTransform, + type DisplayTransform, +} from "@/lib/measurements/display-transform"; import { buildTieredSeries } from "@/lib/rollups/tiered-context"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildBaselineBand } from "@/lib/insights/derived/baseline"; @@ -257,6 +261,62 @@ export async function buildCoarseTimelineTail( }; } +/** + * A built timeline in the reader's unit. The series are aggregated on the + * canonical value and converted once at the end, so a mean in pounds is the + * converted mean of the kilograms, not a mean of rounded pound readings. + * Identity for every transform that does not rescale. + */ +export function timelineInUnit( + timeline: { + recent: DailyValueRow[]; + weekly: WeeklyBucket[]; + coarse?: CoarseTimelineTail; + }, + transform: DisplayTransform, +): { + recent: DailyValueRow[]; + weekly: WeeklyBucket[]; + coarse?: CoarseTimelineTail; +} { + const v = (value: number) => applyDisplayTransform(value, transform); + return { + recent: timeline.recent.map((row) => ({ ...row, value: v(row.value) })), + weekly: timeline.weekly.map((bucket) => ({ + ...bucket, + mean: v(bucket.mean), + })), + ...(timeline.coarse + ? { + coarse: { + monthly: timeline.coarse.monthly.map( + ([start, mean, min, max]) => + [start, v(mean), v(min), v(max)] as [ + string, + number, + number, + number, + ], + ), + yearly: timeline.coarse.yearly.map( + ([start, mean, min, max]) => + [start, v(mean), v(min), v(max)] as [ + string, + number, + number, + number, + ], + ), + anomalies: timeline.coarse.anomalies.map((a) => ({ + ...a, + value: v(a.value), + })), + }, + } + : {}), + }; +} + /** * Pair systolic + diastolic into one row per day. Days with only one * side measured are dropped — the Coach is asked about "BP" as a pair, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index be95be3fc..666ae143c 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,7 +23,10 @@ import { } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { locales, defaultLocale, type Locale } from "@/lib/i18n/config"; -import { resolveGlucoseUnit } from "@/lib/glucose"; +import { + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; @@ -126,6 +129,12 @@ export interface CoachSnapshotResult { * from `src/lib/reference-ranges.ts`; carries no commercial brand name. */ referenceGrounding: string | null; + /** + * The reader's unit choices the snapshot was written in. A tool that reads + * beyond the snapshot (the metric table) states its figures in the same + * units, so a reply never mixes the two. + */ + units: UnitPreferences; } /** @@ -303,6 +312,9 @@ async function buildCoachSnapshotImpl( // display unit so the Coach reads the same number every other surface // shows. Read it on this existing prefs hop (no extra round-trip). glucoseUnit: true, + // The metric/imperial choice: the grounding bands and the blocks + // that carry a mass, length or temperature are written in it. + unitPreference: true, // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects // the tighter ADA glycemic GOAL band for the glucose reference-grounding // line only; never inferred from a reading, never a diagnosis. Read on @@ -326,7 +338,11 @@ async function buildCoachSnapshotImpl( clusterDefault, ); const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; - const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null); + const units = resolveUnitPreferences({ + unitPreference: prefsRow?.unitPreference, + glucoseUnit: prefsRow?.glucoseUnit, + }); + const glucoseUnit = units.glucoseUnit; // v1.18.0 — fold disabled data-domain modules into the system exclusion. // `moduleMap[key] === false` means the user turned that module off; the // gate has already resolved every delegation (cycle/coach) so this map @@ -875,6 +891,7 @@ async function buildCoachSnapshotImpl( counts, registerBlock, groundingValues, + units, }); // Medication compliance lives outside the structured features — the block // is assembled in `snapshot-blocks/compliance-block.ts` from the ledger @@ -904,6 +921,7 @@ async function buildCoachSnapshotImpl( counts, registerBlock, groundingValues, + units, }); // ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── // Assembled in `snapshot-blocks/sleep-block.ts` from the dedicated @@ -969,6 +987,7 @@ async function buildCoachSnapshotImpl( metrics, counts, registerBlock, + unitPreference: units.system, }); } @@ -1206,6 +1225,7 @@ async function buildCoachSnapshotImpl( const referenceGrounding = buildReferenceGroundingBlock({ metrics: groundingMetrics, hasDiabetes: prefsRow?.hasDiabetes ?? false, + units, }); if (referenceGrounding) { annotate({ @@ -1232,6 +1252,7 @@ async function buildCoachSnapshotImpl( counts: Object.keys(counts).length > 0 ? counts : undefined, }, referenceGrounding, + units, }; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 939b0ae4c..349a100a3 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -337,13 +337,16 @@ ISO-week means. - The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse, mood, and medication, it MAY carry body-composition, activity, workouts, sleep (with per-stage minutes), glucose (split by - context: fasting / postprandial / random / bedtime, carrying its - own display "unit" of mg/dL or mmol/L), mobility & + context: fasting / postprandial / random / bedtime), mobility & gait, and environmental-exposure blocks — but only when the user enabled the cluster AND has data. A block that is absent means the user has no such data or did not share it; never invent or assume a cluster the SNAPSHOT does not contain. The "workouts" block carries the recent sessions plus a per-sport rollup, not every session. +- A block's "unit" (and the workouts block's "distanceUnit") is the + unit the user reads their numbers in, and every figure in that block + is already in it. Quote the figures in that unit; never convert them + and never name a different unit for the same quantity. - The SNAPSHOT MAY carry a "derived" block: compact wellness summaries the app already computed (readiness, recovery, sleep score, stress, @@ -382,7 +385,8 @@ ISO-week means. - The glucose block MAY carry a "clinical" sub-key with the same panel numbers the app's glucose page renders over a fixed 30-day window — so cite them as the user sees them and never recompute. It holds: - - "tirPercent": time-in-range (70–180 mg/dL) as a percentage. + - "tirPercent": time-in-range as a percentage — the share of readings + inside "tirRange", whose bounds are in the block's "unit". - "gmi": the glucose-management indicator (a %, an HbA1c estimate derived from mean glucose) and "estimatedA1c": the eA1C in % — read both as estimates, never as a lab HbA1c. @@ -459,8 +463,8 @@ Rules: or a day-pin ("Tue 6 May"). - "value" is a pre-formatted display string ("138/85", "84.2", "4.1"). Format mood as N/5 when numeric. -- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (sleep - debt / social jetlag). +- "unit" is the unit the snapshot gives for that value (e.g. mmHg, + bpm, /5, %, min for sleep debt / social jetlag). - "window" is one of last7days, last30days, last90days, allTime; omit it for day-level pins. - Omit the entire block (no ---KEYVALUES--- line, no ---END--- line) @@ -916,6 +920,10 @@ ISO-Wochenmittel zusammen. das der SNAPSHOT nicht enthält. Der "workouts"-Block führt die jüngsten Einheiten plus eine Zusammenfassung je Sportart, nicht jede Einheit. +- Die "unit" eines Blocks (und "distanceUnit" im "workouts"-Block) + ist die Einheit, in der der Nutzer seine Werte liest; jede Zahl im + Block steht schon in ihr. Nenne die Zahlen in dieser Einheit, rechne + sie nie um und nenne nie eine andere Einheit für dieselbe Größe. - Der "memory"-Block des SNAPSHOT kann eine "facts"-Liste tragen — dauerhafte Dinge, die du über diesen Nutzer gelernt hast (stabile Vorlieben, vom Nutzer selbst genannte gesundheitliche Umstände, @@ -986,8 +994,8 @@ Regeln: oder ein Tages-Pin ("Di 6. Mai"). - "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2", "4,1"). Stimmung als N/5 formatieren, wenn numerisch. -- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min - (Schlafschuld / sozialer Jetlag). +- "unit" ist die Einheit, die der Snapshot für diesen Wert angibt + (z. B. mmHg, bpm, /5, %, min für Schlafschuld / sozialen Jetlag). - "window" ist eines aus last7days, last30days, last90days, allTime; bei Tages-Pins weglassen. - Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index ed4b09da2..814842567 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -39,6 +39,7 @@ * still gets a correct, distinct build (the rare case); the common path * collapses to one build. */ +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; import { z } from "zod/v4"; import { annotate } from "@/lib/logging/context"; @@ -839,6 +840,7 @@ async function getMetricTable( timeZone, locale: turn?.locale ?? "en", ref: "r0", + units: gate.units, now: turn?.now, }); if (!table) { @@ -862,6 +864,7 @@ async function getMetricTable( timeZone, locale: turn?.locale ?? "en", ref: "r0", + units: gate.units, now: turn?.now, }); comparison = current ? compareWithCurrent(table, current) : null; @@ -1067,6 +1070,8 @@ async function withProjectedTable( timeZone: await resolveUserTimezone(userId), locale: turn.locale, ref: "r0", + // Sleep is read in minutes for every reader; no preference applies. + units: DEFAULT_UNIT_PREFERENCES, now: turn.now, }); table = diff --git a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts index 252ff888f..7c60c41d1 100644 --- a/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts +++ b/src/lib/ai/prompts/__tests__/shared-contracts-coverage.test.ts @@ -220,4 +220,17 @@ describe("safetyAcute numbers are bound to clinical-floors", () => { String(FEVER_RED_FLAG_C).replace(".", ","), ); }); + + // A person reading mmol/L or °F describes a crisis in that unit. A clause + // that names only mg/dL and °C cannot match "my sugar is 2.9" or "103 °F". + it("states every glucose and fever floor in both units", () => { + expect(safetyAcute.en).toContain("below 54 mg/dL (3.0 mmol/L)"); + expect(safetyAcute.en).toContain("below 70 mg/dL (3.9 mmol/L)"); + expect(safetyAcute.en).toContain("250 mg/dL (13.9 mmol/L)"); + expect(safetyAcute.en).toContain("38.5 °C (101.3 °F)"); + expect(safetyAcute.de).toContain("unter 54 mg/dL (3,0 mmol/L)"); + expect(safetyAcute.de).toContain("unter 70 mg/dL (3,9 mmol/L)"); + expect(safetyAcute.de).toContain("250 mg/dL (13,9 mmol/L)"); + expect(safetyAcute.de).toContain("38,5 °C (101,3 °F)"); + }); }); diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 631347eb0..7dbf8106d 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -259,12 +259,12 @@ Synthese statt Aufzählung: die Geschichte dessen, was die Daten bedeuten, zähl id: "examples", en: `EXAMPLES — note the DIFFERENT shapes (verdict-led, trend-led, one-liner). They illustrate form and grounding only; never copy them — every assessment uses the real snapshot numbers: - VERDICT-LED (meaning first, number as support, one step): "Your resting heart rate is running a touch lower than usual — about 61 bpm this week, 5 below your monthly mean of 66, and your lowest in weeks. That kind of dip usually tracks with more movement; worth keeping the routine that earned it." -- TREND-LED (direction first, then where it stands): "Your weight has been easing down steadily for three weeks now, sitting around 82.4 kg — about 1.1 kg under your 30-day average. Nothing dramatic, just a consistent direction." +- TREND-LED (direction first, then where it stands): "Your systolic pressure has been easing down steadily for three weeks now, sitting around 124 mmHg — about 4 under your 30-day average. Nothing dramatic, just a consistent direction." - ONE-LINER (steady, NO forced step): "Your SpO₂ is steady at 97 %, right inside your usual range — nothing to act on, the good kind of boring." - BAD (banned filler, ungrounded — do NOT write this): "Your numbers look good. Make sure to get enough sleep and keep up regular exercise."`, de: `BEISPIELE — beachte die UNTERSCHIEDLICHEN Formen (urteil-zuerst, trend-zuerst, Einzeiler). Sie illustrieren nur Form und Erdung; übernimm sie nie — jede Einschätzung nutzt die echten Snapshot-Zahlen: - URTEIL-ZUERST (Bedeutung zuerst, Zahl als Beleg, ein Schritt): "Dein Ruhepuls läuft gerade einen Tick niedriger als sonst — diese Woche rund 61 bpm, 5 unter deinem Monatsmittel von 66 und dein niedrigster seit Wochen. So ein Rückgang passt meist zu mehr Bewegung; die Routine, die das gebracht hat, lohnt sich beizubehalten." -- TREND-ZUERST (Richtung zuerst, dann der Stand): "Dein Gewicht geht seit drei Wochen ruhig nach unten, aktuell rund 82,4 kg — etwa 1,1 kg unter deinem 30-Tage-Schnitt. Nichts Dramatisches, einfach eine stetige Richtung." +- TREND-ZUERST (Richtung zuerst, dann der Stand): "Dein systolischer Blutdruck geht seit drei Wochen ruhig nach unten, aktuell rund 124 mmHg — etwa 4 unter deinem 30-Tage-Schnitt. Nichts Dramatisches, einfach eine stetige Richtung." - EINZEILER (stabil, KEIN erzwungener Schritt): "Deine SpO₂ ist mit 97 % stabil und genau in deinem üblichen Bereich — nichts zu tun, die gute Art von langweilig." - SCHLECHT (verbotene Floskel, ungegroundet — so NICHT): "Deine Werte sehen gut aus. Achte auf ausreichend Schlaf und regelmäßige Bewegung."`, }, diff --git a/src/lib/ai/prompts/insight-generator.ts b/src/lib/ai/prompts/insight-generator.ts index 3fd4c4c37..b4f36a865 100644 --- a/src/lib/ai/prompts/insight-generator.ts +++ b/src/lib/ai/prompts/insight-generator.ts @@ -386,7 +386,7 @@ gib die obige Verweigerung zurück.`, "consider increasing to Y mg", "stop at Z mg", or any variation. A plateau finding ALWAYS frames the next decision as a conversation with the prescribing clinician — pattern: - "Weight has settled around for three weeks at — + "Weight has settled around for three weeks at — typical mid-titration. Worth mentioning at the next visit if it persists." This is a SAFETY contract; treat any dose-prescriptive instinct as a sign the response is @@ -605,7 +605,7 @@ gib die obige Verweigerung zurück.`, mg" oder eine Variante davon enthalten. Ein Plateau-Befund rahmt die nächste Entscheidung IMMER als Gespräch mit der behandelnden Ärztin — Muster: "Das Gewicht hat sich seit drei - Wochen bei auf eingependelt — typische + Wochen bei auf eingependelt — typische mid-titration Phase. Lohnt sich beim nächsten Termin anzusprechen, falls es darüber hinaus persistiert." Das ist ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven diff --git a/src/lib/ai/prompts/native-prompts.ts b/src/lib/ai/prompts/native-prompts.ts index 8f8e76d77..aa0a79503 100644 --- a/src/lib/ai/prompts/native-prompts.ts +++ b/src/lib/ai/prompts/native-prompts.ts @@ -83,21 +83,25 @@ const DAY_LEVEL_BODY: Record = { - Pour une question portant sur un jour ou un jour-de-semaine précis ("pourquoi la tension était-elle plus élevée lundi dernier ?"), répondez depuis "timeline.recent" — citez la lecture réelle de ce jour avec la date et le jour-de-semaine. - Quand aucune ligne n'existe pour le jour mentionné, dites-le clairement ("je n'ai pas de lecture pour lundi 5 mai") et proposez le jour disponible le plus proche. - Les questions plus anciennes (>14 jours) basculent sur "timeline.weekly" ou, à défaut, sur l'agrégat. +- Le "unit" d'un bloc (et le "distanceUnit" du bloc "workouts") est l'unité dans laquelle l'utilisateur lit ses valeurs, et chaque chiffre du bloc y est déjà. Citez les chiffres dans cette unité ; ne les convertissez jamais et ne nommez jamais une autre unité pour la même grandeur. - L'objet "scope" nomme la fenêtre choisie et les sources. Quand l'utilisateur interroge une métrique absente de scope.sources, dites qu'elle n'est pas dans le périmètre de cette conversation.`, es: `Cada métrica en el SNAPSHOT lleva un objeto "timeline" junto al bloque "aggregate". "timeline.recent" es una lista ordenada de filas-día de los últimos 14 días; cada fila lleva una "date" (YYYY-MM-DD), una etiqueta "weekday" (Lun/Mar/Mié/...) y los valores de la métrica para ese día. "timeline.weekly" agrupa los días más antiguos en medias ISO-semanales. - Ante preguntas sobre un día concreto o un día de la semana ("¿por qué la tensión estuvo más alta el lunes pasado?"), responda desde "timeline.recent" — cite la lectura real de ese día con fecha y día de la semana. - Cuando no exista fila para el día mencionado, dígalo claramente ("no tengo lectura del lunes 5 de mayo") y ofrezca el día más cercano disponible. - Las preguntas más antiguas (>14 días) acuden a "timeline.weekly" o, si no, al agregado. +- La "unit" de un bloque (y la "distanceUnit" del bloque "workouts") es la unidad en la que el usuario lee sus valores, y cada cifra del bloque ya está en ella. Cite las cifras en esa unidad; nunca las convierta ni nombre otra unidad para la misma magnitud. - El objeto "scope" nombra la ventana seleccionada y las fuentes. Si el usuario pregunta por una métrica que no está en scope.sources, indique que está fuera del alcance de esta conversación.`, it: `Ogni metrica nello SNAPSHOT porta un oggetto "timeline" accanto al blocco "aggregate". "timeline.recent" è una lista ordinata di righe-giorno per gli ultimi 14 giorni; ogni riga porta una "date" (YYYY-MM-DD), un'etichetta "weekday" (Lun/Mar/Mer/...) e i valori della metrica per quel giorno. "timeline.weekly" raggruppa i giorni più vecchi in medie ISO-settimanali. - Per domande su un giorno o giorno-della-settimana specifico ("perché la pressione era più alta lunedì scorso?"), risponda da "timeline.recent" — citi la lettura reale di quel giorno con data e giorno-della-settimana. - Quando non esiste una riga per il giorno nominato, lo dica chiaramente ("non ho una lettura per lunedì 5 maggio") e proponga il giorno più vicino disponibile. - Le domande più vecchie (>14 giorni) ripiegano su "timeline.weekly" o, in mancanza, sull'aggregato. +- La "unit" di un blocco (e la "distanceUnit" del blocco "workouts") è l'unità in cui l'utente legge i propri valori, e ogni cifra del blocco è già in essa. Citi le cifre in quell'unità; non le converta mai e non nomini mai un'altra unità per la stessa grandezza. - L'oggetto "scope" nomina la finestra scelta dall'utente e le fonti. Se l'utente chiede di una metrica non presente in scope.sources, dica che non rientra nello scopo di questa conversazione.`, pl: `Każda metryka w SNAPSHOT zawiera obiekt "timeline" obok bloku "aggregate". "timeline.recent" to uporządkowana lista wierszy-dnia za ostatnie 14 dni; każdy wiersz ma "date" (YYYY-MM-DD), etykietę "weekday" (Pn/Wt/Śr/...) i wartości metryki dla tego dnia. "timeline.weekly" grupuje starsze dni w średnie ISO-tygodniowe. - Przy pytaniach o konkretny dzień lub dzień tygodnia ("dlaczego ciśnienie było wyższe w zeszły poniedziałek?"), proszę odpowiadać z "timeline.recent" — cytować rzeczywisty odczyt z tego dnia z datą i dniem tygodnia. - Gdy nie ma wiersza dla wymienionego dnia, proszę powiedzieć to wprost ("nie mam odczytu z poniedziałku 5 maja") i zaproponować najbliższy dostępny dzień. - Starsze pytania (>14 dni) sięgają do "timeline.weekly" lub, w razie braku, do agregatu. +- "unit" bloku (oraz "distanceUnit" bloku "workouts") to jednostka, w której użytkownik odczytuje swoje wartości, i każda liczba w bloku jest już w niej podana. Proszę cytować liczby w tej jednostce; nigdy ich nie przeliczać i nigdy nie podawać innej jednostki dla tej samej wielkości. - Obiekt "scope" wymienia wybrane okno i źródła. Jeśli użytkownik pyta o metrykę nieobecną w scope.sources, proszę powiedzieć, że nie należy ona do zakresu tej rozmowy.`, }; @@ -115,7 +119,7 @@ Règles : - Chaque ligne : "