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 : "