Merge remote-tracking branch 'origin/main' into fix/v1397-derive

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:34:17 +02:00
commit da8cc7a6ee
74 files changed
+2304 -292

No files matched your search

+7 -1
View File
@@ -55791,11 +55791,14 @@ components:
properties:
value:
type: number
description: The scored value in its canonical unit (fasting glucose in mg/dL).
unit:
type: string
description: The canonical unit of `value`.
label:
type: string
description: The complete display value, paired or panel values included.
description: The complete display value, paired or panel values included, written in the reader's units (fasting glucose
in the account's glucose unit).
asOf:
type: string
format: date-time
@@ -55824,12 +55827,15 @@ components:
anyOf:
- type: number
- type: "null"
description: Lower band edge, canonical unit.
high:
anyOf:
- type: number
- type: "null"
description: Upper band edge, canonical unit.
label:
type: string
description: The band as display text, in the reader's units.
source:
type: string
required:
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
"statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})",
"statSummaryWithUnit": "zuletzt {latest} {unit} (Ø {avg} {unit}, Bereich {min}–{max} {unit})",
"adherence": "{rate} Therapietreue",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Stärke {severity}",
"noSymptoms": "Keine Symptome erfasst.",
"impact": "Befinden: {impact}",
"fever": "Temperatur: {value} °C",
"fever": "Temperatur: {value} {unit}",
"empty": "Noch keine Tage erfasst."
},
"loadError": "Diese Episode konnte nicht geladen werden.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
"statSummary": "latest {latest} (avg {avg}, range {min}–{max})",
"statSummaryWithUnit": "latest {latest} {unit} (avg {avg} {unit}, range {min}–{max} {unit})",
"adherence": "{rate} adherence",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Severity {severity}",
"noSymptoms": "No symptoms logged.",
"impact": "How you functioned: {impact}",
"fever": "Temperature: {value} °C",
"fever": "Temperature: {value} {unit}",
"empty": "No days logged yet."
},
"loadError": "Could not load this episode.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
"statSummary": "último {latest} (prom. {avg}, rango {min}–{max})",
"statSummaryWithUnit": "último {latest} {unit} (prom. {avg} {unit}, rango {min}–{max} {unit})",
"adherence": "{rate} de adherencia",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Intensidad {severity}",
"noSymptoms": "Sin síntomas registrados.",
"impact": "Cómo funcionaste: {impact}",
"fever": "Temperatura: {value} °C",
"fever": "Temperatura: {value} {unit}",
"empty": "Todavía no hay días registrados."
},
"loadError": "No se pudo cargar este episodio.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
"statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})",
"statSummaryWithUnit": "dernier {latest} {unit} (moy. {avg} {unit}, plage {min}–{max} {unit})",
"adherence": "{rate} d'observance",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Intensité {severity}",
"noSymptoms": "Aucun symptôme enregistré.",
"impact": "Votre forme : {impact}",
"fever": "Température : {value} °C",
"fever": "Température : {value} {unit}",
"empty": "Aucun jour enregistré pour l’instant."
},
"loadError": "Impossible de charger cet épisode.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
"statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})",
"statSummaryWithUnit": "ultimo {latest} {unit} (media {avg} {unit}, intervallo {min}–{max} {unit})",
"adherence": "{rate} di aderenza",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Intensità {severity}",
"noSymptoms": "Nessun sintomo registrato.",
"impact": "Come ti sei sentito: {impact}",
"fever": "Temperatura: {value} °C",
"fever": "Temperatura: {value} {unit}",
"empty": "Nessun giorno registrato finora."
},
"loadError": "Impossibile caricare questo episodio.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "복약 및 복약 순응도",
"bmi": "체질량지수(BMI)",
"statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})",
"statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})",
"adherence": "복약 순응도 {rate}",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "심각도 {severity}",
"noSymptoms": "기록한 증상이 없어요.",
"impact": "활동 정도: {impact}",
"fever": "체온: {value}°C",
"fever": "체온: {value}{unit}",
"empty": "아직 기록한 날이 없어요."
},
"loadError": "이 에피소드를 불러오지 못했어요.",
+2 -1
View File
@@ -8168,6 +8168,7 @@
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
"statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})",
"statSummaryWithUnit": "ostatnio {latest} {unit} (śr. {avg} {unit}, zakres {min}–{max} {unit})",
"adherence": "{rate} przestrzegania",
"noAdherence": "—",
"glucose": {
@@ -9406,7 +9407,7 @@
"severity": "Nasilenie {severity}",
"noSymptoms": "Nie zapisano objawów.",
"impact": "Jak funkcjonowałeś: {impact}",
"fever": "Temperatura: {value} °C",
"fever": "Temperatura: {value} {unit}",
"empty": "Nie zapisano jeszcze żadnych dni."
},
"loadError": "Nie udało się wczytać tego epizodu.",
@@ -52,7 +52,7 @@ const REGISTRY = "lib/measurements/display-transform.ts";
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
/**
* The surfaces allowed to read a transform's raw `factor` / `offset`. All four
* The surfaces allowed to read a transform's raw `factor` / `offset`. All five
* do it for the same reason: the chart takes the multiplier as a prop, folds
* it into its own series at the single read boundary, and formats the result
* itself. Everything those files RENDER directly goes through the rounding
@@ -61,6 +61,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
*/
const RAW_SCALE_SURFACES = [
"app/page-client.tsx",
// The insights trends row: a single transformed series hands its scale
// and offset to the mini chart, which formats every value itself.
"components/insights/trends-row.tsx",
"app/insights/weight/page.tsx",
"components/insights/healthkit-metric-page.tsx",
"components/insights/device-score-tile.tsx",
@@ -13,7 +13,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
import { describe, it, expect } from "vitest";
@@ -207,3 +207,220 @@ describe("unit-preference display guard", () => {
}
});
});
/**
* The wide sweep (#1067). The block above guards the surfaces that were
* wired for metric/imperial; the class kept reappearing everywhere else — a
* clinician view without units, status notes and narratives pinned to mg/dL
* and kilograms, a glucose threshold editor in mg/dL for an mmol/L account,
* workout distances in kilometres for an imperial reader. Each one was a
* unit literal in a display string.
*
* So every display-bearing tree is swept for the literals a preference
* changes: the two glucose units anywhere in code, and kilograms,
* kilometres and degrees Celsius where they sit in a display string (a bare
* quoted unit, a unit after an interpolation or a number, a unit closing a
* string, a per-kilometre pace). Comments are stripped first. A file that
* legitimately carries one is listed below with the exact number of matching
* lines and the reason; any other file, or one more line in a listed file,
* 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 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).
*/
const WIDE_SWEEP_ROOTS = [
"components/",
"app/",
"lib/insights/",
"lib/doctor-report",
"lib/export",
];
const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
// Glucose, anywhere in code.
/mg\/dL|mmol\/L/,
// A bare quoted unit: "kg", 'km', `°C`.
/(["'`])\s*(?:kg|km|°C)\s*\1/,
// A unit after an interpolation: `${x} kg`, {value} km in JSX.
/\}\s?(?:kg|km|°C)(?![\w/²])/,
// A unit after a number: "75 kg", "37.0 °C".
/\d\s?(?:kg|km|°C)(?![\w/²])/,
// A unit closing a string or a JSX text run: " km<", " kg\"".
/\s(?:kg|km|°C)(?=["'`<])/,
// A per-kilometre pace.
/\/km\b/,
];
/**
* Files allowed to carry a unit literal, with the exact number of matching
* lines and why. `kg/m²` (BMI) never matches; every other hit is listed.
*/
const UNIT_LITERAL_ALLOWLIST: Record<
string,
{ lines: number; reason: string }
> = {
// ── The conversion and the choice themselves ──
"components/settings/glucose-unit-select.tsx": {
lines: 2,
reason: "the glucose unit picker: its options are the two units",
},
"components/onboarding/units-screen.tsx": {
lines: 3,
reason: "the onboarding unit picker: its options are the two units",
},
"components/measurements/measurement-form.tsx": {
lines: 11,
reason:
"input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save",
},
"components/measurements/measurement-list.tsx": {
lines: 2,
reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL",
},
"components/settings/import-panel/import-examples.ts": {
lines: 3,
reason: "input parsing: CSV examples showing the units an import accepts",
},
"components/onboarding/first-result-screen.tsx": {
lines: 1,
reason: "decimals chosen by the resolved glucose unit, not a label",
},
"app/insights/blood-glucose/page.tsx": {
lines: 1,
reason: "branches on the resolved glucose unit to scale the chart",
},
"components/insights/glucose/glucose-clinical-panel.tsx": {
lines: 1,
reason: "branches on the resolved glucose unit for its decimals",
},
"components/insights/health-score-pillar-detail.ts": {
lines: 2,
reason:
"branches on the canonical observed unit before converting to the reader's",
},
// ── Display-kind tags, converted through the preference at render ──
"components/cycle/cycle-phase-crosstab.tsx": {
lines: 1,
reason: "a display-kind tag; rows convert through getReadingTransform",
},
"lib/insights/mood-crosstab.ts": {
lines: 2,
reason: "a display-kind tag; the weight row converts through the hook",
},
"components/insights/mood/mood-factor-metric-crosstab.tsx": {
lines: 2,
reason: "a display-kind tag; the weight row converts through the hook",
},
// ── Canonical by contract ──
"lib/insights/metric-status-registry.ts": {
lines: 11,
reason:
"canonical registry units; metric-status.ts converts to the reader's before writing",
},
"lib/export.ts": {
lines: 1,
reason: "canonical default when an export caller passes no glucose unit",
},
"app/api/export/route.ts": {
lines: 1,
reason: "the legacy export endpoint's canonical storage-unit contract",
},
"app/api/measurements/series/route.ts": {
lines: 4,
reason:
"canonical wire units; glucose is overridden to the reader's unit per request",
},
"lib/doctor-report/stat-display.ts": {
lines: 3,
reason:
"SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)",
},
"components/insights/workout-detail/splits.tsx": {
lines: 1,
reason: "kilometre splits are cut server-side; their pace is per km",
},
"app/privacy/page.tsx": {
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": {
lines: 1,
reason: "comprehensive prompt still states weight in kg",
},
"lib/insights/signals-of-day.ts": {
lines: 1,
reason: "briefing signal still states weight in kg",
},
"lib/insights/weight-status.ts": {
lines: 1,
reason: "weight status snapshot still states kg",
},
"lib/insights/glp1-plateau.ts": {
lines: 2,
reason: "GLP-1 plateau prompt context still states kg",
},
};
function unitLiteralLines(): Map<string, string[]> {
const files = walkSourceFiles(SRC, { floor: 1400 }).filter(
(rel) =>
WIDE_SWEEP_ROOTS.some((root) => rel.startsWith(root)) &&
!rel.includes("__tests__") &&
!/\.test\.tsx?$/.test(rel),
);
if (files.length < 1200) {
throw new Error(
`unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`,
);
}
const hits = new Map<string, string[]>();
for (const rel of files) {
const lines = stripComments(readFileSync(join(SRC, rel), "utf8"))
.split("\n")
.filter((line) => UNIT_LITERAL_PATTERNS.some((p) => p.test(line)))
.map((line) => line.trim());
if (lines.length > 0) hits.set(rel, lines);
}
return hits;
}
describe("unit-preference display guard — the wide sweep", () => {
const hits = unitLiteralLines();
it("finds the literals it is meant to find (a sweep that matches nothing proves nothing)", () => {
const total = [...hits.values()].reduce((n, l) => n + l.length, 0);
expect(total).toBeGreaterThanOrEqual(40);
expect(hits.size).toBeGreaterThanOrEqual(15);
});
it("no display surface carries a unit literal outside the allowlist", () => {
const offenders: string[] = [];
for (const [rel, lines] of hits) {
const allowed = UNIT_LITERAL_ALLOWLIST[rel];
if (!allowed) {
offenders.push(`${rel}: ${lines.join(" | ")}`);
} else if (lines.length > allowed.lines) {
offenders.push(
`${rel}: ${lines.length} unit-literal lines, allowlisted ${allowed.lines}: ${lines.join(" | ")}`,
);
}
}
expect(offenders).toEqual([]);
});
it("every allowlist entry still matches exactly its budget, with a reason", () => {
const stale: string[] = [];
for (const [rel, allowed] of Object.entries(UNIT_LITERAL_ALLOWLIST)) {
expect(allowed.reason.length).toBeGreaterThan(10);
const found = hits.get(rel)?.length ?? 0;
if (found !== allowed.lines) {
stale.push(`${rel}: allowlisted ${allowed.lines}, found ${found}`);
}
}
expect(stale).toEqual([]);
});
});
+2 -1
View File
@@ -34,7 +34,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
decisionForEvidence,
PATTERN_FAMILIES,
@@ -619,6 +619,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: glucoseEnabled,
@@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -56,6 +60,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote glucose in this unit; re-warm
// them so today's stored text does not keep the old one.
if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.glucose-unit.patch" },
meta: { glucoseUnit: next },
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +47,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote mass, length and temperature in
// this branch; re-warm them so today's stored text follows the switch.
if (resolve(previous?.unitPreference) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.unit-preference.patch" },
meta: { unitPreference: next },
@@ -41,6 +41,7 @@ import {
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -230,6 +231,9 @@ export const PATCH = apiHandler(
},
});
invalidateUserProfile(access.recordId);
if (patch.unitPreference !== undefined) {
void refreshTextsAfterUnitChange(access.recordId);
}
if (patch.timezone !== undefined) {
invalidateUserMedications(access.recordId, { evict: true });
}
@@ -11,6 +11,11 @@ import {
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
getQuantityTransform,
} from "@/lib/measurements/display-transform";
/**
* Workout elevation profile — a small cumulative-distance / altitude
@@ -30,7 +35,15 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
const distance = getQuantityTransform("distance", preference);
const elevation = getQuantityTransform("elevation", preference);
const data = points.map((p) => ({
dist: applyDisplayTransform(p.distanceM, distance),
alt: applyDisplayTransform(p.altitude, elevation),
}));
return (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({
opacity={0.4}
/>
<XAxis
dataKey="km"
dataKey="dist"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
@@ -80,9 +93,11 @@ export function WorkoutElevationChart({
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
labelFormatter={(d) =>
`${Number(d).toFixed(2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} m`,
`${Math.round(Number(value))} ${elevation.displayUnit}`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
@@ -225,6 +225,49 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain(">WEIGHT<");
});
it("prints every measurement group with its unit", () => {
const html = render(
makeReport({
stats: {
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
// Per-night asleep minutes; the report reads them in hours.
SLEEP_DURATION: {
avg: 450,
min: 420,
max: 480,
count: 7,
latest: 450,
},
},
}),
);
expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)");
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)");
});
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
const html = render(
makeReport({
glucoseUnit: "mmol/L",
stats: {
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
glucoseStats: {
FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 },
},
}),
);
// 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3.
expect(html).toContain(
"latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)",
);
expect(html).toContain(
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
);
expect(html).not.toContain("mg/dL");
expect(html).not.toContain("latest 99");
});
it("omits the wellness card when there are no scores", () => {
const html = render(makeReport({ wellnessScores: [] }));
expect(html).not.toContain("Wellness scores");
+39 -10
View File
@@ -15,6 +15,11 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
reportStatUnit,
reportStatValue,
} from "@/lib/doctor-report/stat-display";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
@@ -212,6 +217,13 @@ export function MeasurementGroups({
if (groups.length === 0) return null;
// The same unit and the same number the PDF prints for the row: SI for
// mass, length and temperature, the owner's unit for glucose, hours for
// sleep. A clinician must never read a figure without knowing its unit.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (type: string, value: number) =>
fmtNum(reportStatValue(type, value, glucoseUnit));
return (
<>
{groups.map((group) => (
@@ -224,11 +236,11 @@ export function MeasurementGroups({
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest),
avg: show(type, stat!.avg),
min: show(type, stat!.min),
max: show(type, stat!.max),
})}
/>
))}
@@ -238,6 +250,20 @@ export function MeasurementGroups({
);
}
/**
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
* count) keeps the unit-less sentence rather than printing a dangling space.
*/
function statSummary(
t: Translate,
unit: string,
values: { latest: number; avg: number; min: number; max: number },
): string {
return unit
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
: t("clinicianView.statSummary", values);
}
/**
* Per-context glucose stats. The heading used to read "Lab values", which was
* the wrong name for it even before the record's actual lab results reached
@@ -258,6 +284,9 @@ export function GlucoseSection({
const entries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
return (
<LeafSection
t={t}
@@ -270,11 +299,11 @@ export function GlucoseSection({
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
// QueryClient, so mock the hook to a real metric-preference implementation —
// metric is the identity on values and yields the canonical display symbols,
// so the assertions below (kg, °C, unchanged numbers) hold.
const reader = vi.hoisted(() => ({
glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L",
}));
vi.mock("@/hooks/use-unit-display", async () => {
const dt = await import("@/lib/measurements/display-transform");
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
return {
useUnitDisplay: () => ({
preference: "metric" as const,
glucoseUnit: reader.glucoseUnit,
transformFor: forType,
toDisplay: (t: string, v: number) =>
dt.applyDisplayTransform(v, forType(t)),
@@ -107,4 +112,26 @@ describe("<CyclePhaseCrosstab>", () => {
const html = render(<CyclePhaseCrosstab rows={[unknown]} />);
expect(html).not.toContain("undefined");
});
it("states a glucose row in mmol/L for an mmol/L reader", () => {
reader.glucoseUnit = "mmol/L";
try {
const glucose: CyclePhaseCrosstabRow = {
...row("bloodGlucose", "glucose"),
lutealAvg: 108,
follicularAvg: 90,
delta: 18,
};
const html = render(<CyclePhaseCrosstab rows={[glucose]} />);
// 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L.
expect(html).toContain("+1.0 mmol/L");
expect(html).toContain("6.0");
expect(html).not.toContain("mg/dL");
expect(render(<CyclePhaseHeadline headline={glucose} />)).toContain(
"mmol/L",
);
} finally {
reader.glucoseUnit = "mg/dL";
}
});
});
+52 -21
View File
@@ -1,7 +1,12 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { cn } from "@/lib/utils";
/**
@@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
glucose: "BLOOD_GLUCOSE",
};
/**
* Unit symbol and conversions for one row, from the reader's preferences.
* Glucose follows the glucose unit (it used to print the mg/dL label from
* the bundle beside raw mg/dL values for every reader); weight and
* temperature follow metric/imperial; every other display keeps its bundle
* label and its raw value.
*/
function rowUnits(
display: CyclePhaseCrosstabDisplay,
unitDisplay: UnitDisplay,
t: (key: string) => string,
): {
unit: string;
abs: (v: number) => number;
delta: (v: number) => number;
} {
const type = DISPLAY_TO_TYPE[display];
if (!type) {
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const unitKey = UNIT_KEY[display];
return {
unit: unitKey ? t(unitKey) : "",
abs: (v) => v,
delta: (v) => v,
};
}
const transform = getReadingTransform(type, {
system: unitDisplay.preference,
glucoseUnit: unitDisplay.glucoseUnit,
});
return {
unit: transform.displayUnit,
abs: (v) => applyDisplayTransform(v, transform),
delta: (v) => applyDisplayTransformDelta(v, transform),
};
}
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -120,11 +164,7 @@ export function CyclePhaseHeadline({
const metricLabel = t(
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t` (whose
// resolver does `key.split(".")`). An empty unit is the honest degrade.
const mt = DISPLAY_TO_TYPE[headline.display];
const unitKey = UNIT_KEY[headline.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
const up = headline.delta >= 0;
const dir = t(
up
@@ -134,12 +174,7 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(
Math.abs(
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
),
headline.display,
),
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
unit,
dir,
};
@@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const mt = DISPLAY_TO_TYPE[row.display];
const unitKey = UNIT_KEY[row.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const absConv = (v: number) =>
mt ? unitDisplay.toDisplay(mt, v) : v;
const deltaConv = (v: number) =>
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
@@ -0,0 +1,70 @@
/**
* A logged fever reads in the reader's temperature unit. The timeline and the
* day sheet printed a hardcoded °C, so an imperial account read its own
* fever in Celsius.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const account = vi.hoisted(() => ({
user: { unitPreference: "imperial", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManageDomain: () => true }),
}));
vi.mock("../use-illness", () => ({
useIllnessDayLogList: () => ({
isLoading: false,
isError: false,
data: {
dayLogs: [
{
id: "d1",
episodeId: "e1",
date: "2026-09-30",
functionalImpact: null,
feverC: 38.5,
symptoms: [],
note: null,
updatedAt: "2026-09-30T08:00:00.000Z",
},
],
},
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { IllnessDayTimeline } from "../illness-day-timeline";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<IllnessDayTimeline episodeId="e1" onLogDay={() => {}} />
</I18nProvider>,
);
}
describe("<IllnessDayTimeline> fever unit", () => {
it("shows the fever in °F for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
const html = render();
// 38.5 °C = 101.3 °F.
expect(html).toContain("Temperature: 101.3 °F");
expect(html).not.toContain("°C");
});
it("keeps °C for a metric reader", () => {
account.user = { unitPreference: "metric", glucoseUnit: null };
expect(render()).toContain("Temperature: 38.5 °C");
});
});
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
@@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null {
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { toDisplay, unitFor } = useUnitDisplay();
return (
<div className="border-border/60 space-y-2 border-l-2 pl-3">
@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
{log.feverC !== null ? (
<p className="text-foreground text-sm">
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
{t("illness.timeline.fever", {
value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1),
unit: unitFor("BODY_TEMPERATURE"),
})}
</p>
) : null}
+16 -4
View File
@@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
@@ -140,6 +141,10 @@ export function LogDaySheet({
onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
// The fever is stored in °C and typed in the reader's temperature unit:
// seeded through the transform, inverted on save.
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
const feverUnit = unitFor("BODY_TEMPERATURE");
// The day being logged — seeded from `date` (today), but the picker lets the
// user backdate within [onset, today] for the retrospective journal.
@@ -178,7 +183,11 @@ export function LogDaySheet({
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
);
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(dto?.feverC != null ? String(dto.feverC) : "");
setFever(
dto?.feverC != null
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
: "",
);
setNote(dto?.note ?? "");
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
@@ -213,7 +222,10 @@ export function LogDaySheet({
const input: IllnessDayLogInput = {
date: selectedDate,
functionalImpact,
feverC: feverValue,
feverC:
feverValue == null
? null
: Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity: severity ?? undefined,
@@ -333,7 +345,7 @@ export function LogDaySheet({
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} °C
{feverValue.toFixed(1)} {feverUnit}
</span>
) : null
}
@@ -345,7 +357,7 @@ export function LogDaySheet({
<Input
id="illness-fever"
inputMode="decimal"
placeholder="37.0"
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = ({ title }: { title?: string }) => (
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
const Stub = ({
title,
unit,
valueScale,
valueOffset,
}: {
title?: string;
unit?: string;
valueScale?: number;
valueOffset?: number;
}) => (
<div
data-slot="trends-row-chart-stub"
data-unit={unit}
data-scale={valueScale}
data-offset={valueOffset}
>
{title ?? "chart"}
</div>
);
Stub.displayName = "TrendsRowChartStub";
return Stub;
},
}));
const account = vi.hoisted(() => ({
user: null as Record<string, unknown> | null,
}));
vi.mock("@/hooks/use-auth", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/use-auth")>()),
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
import { TrendsRow } from "../trends-row";
import type { DailyBriefing } from "@/lib/ai/schema";
@@ -306,4 +332,27 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/data-metric="steps"/);
expect(html).toContain('href="/insights/steps"');
});
it("plots a transformed slot in the reader's unit, scale and offset included", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<TrendsRow briefing={briefing(["weight", "distance", "body_temp"])} />,
);
expect(html).toContain('data-unit="lb"');
expect(html).toContain('data-unit="mi"');
expect(html).toContain('data-unit="°F"');
expect(html).toContain('data-offset="32"');
expect(html).not.toContain('data-unit="kg"');
expect(html).not.toContain('data-unit="km"');
} finally {
account.user = null;
}
});
it("plots distance in kilometres, scaled from metres, for a metric reader", () => {
const html = render(<TrendsRow briefing={briefing(["distance"])} />);
expect(html).toContain('data-unit="km"');
expect(html).toContain('data-scale="0.001"');
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("148 bpm");
});
it("states distance, pace, elevation and the average line in imperial units", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<WorkoutDetailStats
workout={{
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
}}
/>,
);
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
expect(html).toContain("3.23 mi");
expect(html).toContain("9:17 /mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
expect(html).not.toContain(" km");
expect(html).not.toContain("/km");
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(header).toContain("3.23 mi");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expect(html).toContain("5.20 km");
expect(html).toContain("5:46 /km");
expect(html).toContain("12.5 m");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
@@ -1,10 +1,23 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WorkoutList } from "../workout-list";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
expect(html).toContain("320 kcal");
});
it("states the distance in miles for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(<WorkoutList workouts={[ROW]} />);
// 5200 m = 3.23 mi.
expect(html).toContain("3.23 mi");
expect(html).not.toContain(" km");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("renders aggregate average HR without claiming an HR series", () => {
const html = render(
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
@@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { RangeBar } from "@/components/targets/range-bar";
@@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { getTargetSourceLink } from "@/lib/targets/source-link";
import { apiGet } from "@/lib/api/api-fetch";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
/**
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
@@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
// mg/dL values to the user's display unit. The route's per-context
// labels are i18n keys, so resolve them.
if (isGlucose) {
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayUnit);
// The payload stays canonical mg/dL here; each panel converts through
// the shared target adapter, which owns the glucose unit as it owns
// pounds, so the panel and its edit sheet cannot convert twice.
const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
data.targets.find((entry) => entry.type === type),
)
.filter(
(entry): entry is TargetItem => entry != null && entry.range != null,
)
.map((entry) => ({
...entry,
label: t(entry.label),
unit: displayUnit,
current: convert(entry.current),
average30: convert(entry.average30),
range: entry.range
? {
min: convertGlucose(entry.range.min, displayUnit),
max: convertGlucose(entry.range.max, displayUnit),
}
: null,
}));
.map((entry) => ({ ...entry, label: t(entry.label) }));
if (panels.length === 0) return null;
@@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
key={panel.type}
target={panel}
heading={panel.label}
glucoseUnit={glucoseUnit}
/>
))}
</div>
@@ -212,6 +202,12 @@ interface TargetReferencePanelProps {
bpDiastolic?: TargetsResponse["bpDiastolic"];
/** Optional sub-heading shown above the panel (used for glucose contexts). */
heading?: string;
/**
* The record's glucose unit as the targets payload resolved it. The
* glucose panels pass it so the band reads in the unit the server
* resolved for this record; every other panel leaves it to the hook.
*/
glucoseUnit?: GlucoseUnit;
}
/**
@@ -232,21 +228,26 @@ function TargetReferencePanel({
target,
bpDiastolic,
heading,
glucoseUnit: payloadGlucoseUnit,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
const adjust = useTargetAdjust();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
// v1.32.27 — the panel renders in the user's preferred unit. The
// `/api/insights/targets` payload is canonical (kg for weight); the
// adapter converts it once, here, and everything downstream — the
// range bar, the status pill, the 30-day average, and the edit
// sheet's seed — consumes the converted pair. Glucose contexts are
// pre-converted by the caller above and carry no display transform,
// so they take the adapter's identity path and are never scaled
// twice. For a metric account the adapter is the exact identity, so
// this panel is byte-identical to the previous release.
const units = resolveTargetUnitAdapter(target.type, target.unit, preference);
// sheet's seed — consumes the converted pair. The glucose contexts
// arrive canonical too and convert here to the reader's glucose unit.
// On the default preferences the adapter is the exact identity.
const units = resolveTargetUnitAdapter(
target.type,
target.unit,
preference,
glucoseUnit,
);
const unit = units.unit;
const range = target.range
? {
+19 -5
View File
@@ -20,6 +20,7 @@ import {
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
import { apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
@@ -264,6 +265,7 @@ export function TrendDescriptorCaption({
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
const isMood = kind === "mood";
const primaryType = types[0] ?? "";
@@ -323,18 +325,30 @@ export function TrendDescriptorCaption({
}
// The numeric series is already server-bounded to the trailing
// 30-day window by the fetch, so every returned row is in-window.
// Described in the reader's unit, the same one the chart above plots.
return (numericQuery.data ?? []).map((row) => ({
timestamp: new Date(row.measuredAt).getTime(),
value: row.value,
value: toDisplay(primaryType, row.value),
}));
}, [isMood, moodQuery.data, numericQuery.data]);
}, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]);
const descriptor = useMemo(() => {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
// A slot that converts its series converts its noise floor with it: the
// floor is declared canonically and is a difference (factor only).
const config = isMood
? MOOD_DESCRIPTOR_CONFIG
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
: meta?.unitType
? {
...meta.config,
absoluteFloor: toDisplayDelta(
meta.unitType,
meta.config.absoluteFloor,
),
}
: meta?.config;
return computeTrendDescriptor(points, config);
}, [points, isMood, metric]);
}, [points, isMood, metric, toDisplayDelta]);
// Tier 4 — genuinely too few points (also covers the in-flight window
// before the small series lands). Surface the real empty hint so the
@@ -361,7 +375,7 @@ export function TrendDescriptorCaption({
// copy defensively (should not happen for the legacy triple).
const copy = isMood
? moodDescriptorCopy(descriptor)
: (numericDescriptorCopy(metric, descriptor) ??
: (numericDescriptorCopy(metric, descriptor, unitFor) ??
moodDescriptorCopy(descriptor));
return (
+15 -2
View File
@@ -22,6 +22,7 @@ import {
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* Trends row — a small chart per metric above a one-sentence
@@ -116,17 +117,29 @@ function MetricChart({
title: string;
}) {
const { user } = useAuth();
const { transformFor, isTransformed } = useUnitDisplay();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini />;
}
// A single transformed series (weight, distance, temperature) plots in
// the reader's unit: the chart folds scale and offset in at its read
// boundary, exactly as the metric's own insights page does. Distance
// used to plot raw metres under a "km" label for every reader.
const primary = config.types[0] ?? "";
const transform =
config.types.length === 1 && isTransformed(primary)
? transformFor(primary)
: null;
return (
<HealthChartDynamicMini
types={config.types}
title={title}
colors={config.colors}
unit={config.unit}
yAxisUnit={config.yAxisUnit}
unit={transform ? transform.displayUnit : config.unit}
yAxisUnit={transform ? undefined : config.yAxisUnit}
valueScale={transform?.factor}
valueOffset={transform?.offset}
mini
userTimezone={userTimezone}
/>
@@ -1,4 +1,10 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import {
applyDisplayTransform,
getQuantityTransform,
paceSecondsPerDistanceUnit,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
hourCycleOptions,
type TimeFormatPreference,
@@ -27,12 +33,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
return formatNumber(minutes, locale);
}
export function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
/**
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
* "3.60 mi". The workout list, the detail header, the stat tile and the
* sport-average line all print through this, so none of them can keep
* kilometres for an imperial reader.
*/
export function formatDistance(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("distance", preference);
const value = applyDisplayTransform(meters, transform);
const formatted = getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
minimumFractionDigits: value < 10 ? 2 : 1,
}).format(value);
return `${formatted} ${transform.displayUnit}`;
}
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
export function formatElevation(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("elevation", preference);
return `${formatNumber(
applyDisplayTransform(meters, transform),
locale,
transform.decimals,
)} ${transform.displayUnit}`;
}
export function formatNumber(
@@ -46,21 +78,36 @@ export function formatNumber(
}).format(value);
}
export function formatPaceMinPerKm(
/**
* Average pace per kilometre or per mile, by the reader's preference. Only
* meaningful for run / walk / hike / ride; the caller gates on distance +
* sport.
*/
export function formatPace(
durationSec: number,
meters: number,
preference: UnitPreference,
): string {
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
// the caller gates on distance + sport.
const secPerKm = (durationSec / meters) * 1000;
return formatPaceSeconds(secPerKm);
return formatPaceSeconds(
paceSecondsPerDistanceUnit(durationSec, meters, preference),
getQuantityTransform("distance", preference).displayUnit,
);
}
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
export function formatPaceSeconds(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} /km`;
/**
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
* explicitly: its rows are kilometre segments cut server-side, so their
* pace is per kilometre whatever the preference.
*/
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
let m = Math.floor(secPerUnit / 60);
let s = Math.round(secPerUnit % 60);
// 359.6 s is 6:00, not 5:60.
if (s === 60) {
m += 1;
s = 0;
}
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
}
export function formatDateRange(
@@ -3,8 +3,9 @@
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
import { formatDuration, formatDistance, formatDateRange } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
</div>
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
{formatPaceSeconds(s.paceSecPerKm, "km")}
</td>
</tr>
))}
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import type { UnitPreference } from "@/lib/measurements/display-transform";
import {
formatDuration,
formatDistanceKm,
formatDistance,
formatElevation,
formatNumber,
formatPaceMinPerKm,
formatPace,
formatDurationMinutes,
} from "./format";
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
function sportAverageLine(
workout: WorkoutDetailPayload,
locale: string,
preference: UnitPreference,
): string | null {
const ctx = workout.sportContext;
if (!ctx || ctx.count < 2) return null;
const parts: string[] = [];
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
}
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
@@ -83,6 +87,7 @@ function sportAverageLine(
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const { preference } = useUnitDisplay();
const tiles: StatTileProps[] = [];
tiles.push({
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
value: formatDistance(workout.distanceM, locale, preference),
});
}
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
value: formatElevation(workout.elevationM, locale, preference),
});
}
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
value: formatPace(workout.durationSec, workout.distanceM, preference),
});
}
const averageLine = sportAverageLine(workout, locale);
const averageLine = sportAverageLine(workout, locale, preference);
return (
<Card data-slot="workout-detail-stats">
+4 -9
View File
@@ -13,6 +13,8 @@ import {
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
/**
* v1.4.32 — workout list row primitive.
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
return `${m}m`;
}
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
@@ -86,6 +80,7 @@ export function WorkoutList({
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
return (
<ul
@@ -150,7 +145,7 @@ export function WorkoutList({
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
{workout.activeEnergyKcal != null ? (
@@ -0,0 +1,76 @@
/**
* The settings threshold editor reads glucose in the reader's unit.
*
* It used to pass every glucose threshold through the identity path, so an
* mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields
* bounded at 40–400, while the insights sheet for the same threshold already
* spoke mmol/L. The stored value stays canonical mg/dL either way.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const authUser = vi.hoisted(() => ({
current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: authUser.current, isAuthenticated: true }),
useAccountOnceMounted: () => authUser.current,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
isLoading: false,
data: {
effective: {
BLOOD_GLUCOSE_FASTING: {
range: { greenMin: 72, greenMax: 108 },
isOverride: true,
default: { greenMin: 70, greenMax: 99 },
bounds: { min: 40, max: 400, unit: "mg/dL" },
},
},
overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } },
},
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ThresholdsEditorSection } from "../thresholds-editor-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ThresholdsEditorSection id="thresholds" />
</I18nProvider>,
);
}
describe("<ThresholdsEditorSection> glucose unit", () => {
it("states the default, the fields and the bounds in mmol/L", () => {
authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" };
const html = render();
// 70–99 mg/dL is 3.9–5.5 mmol/L.
expect(html).toMatch(/3\.9–5\.5 mmol\/L/);
expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
// The stored 72–108 mg/dL override seeds 4–6 mmol/L.
expect(html).toContain('value="4"');
expect(html).toContain('value="6"');
// Inward-rounded guardrail of 40–400 mg/dL.
expect(html).toContain('min="2.3"');
expect(html).toContain('max="22.1"');
});
it("keeps mg/dL for an mg/dL reader", () => {
authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" };
const html = render();
expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
expect(html).toContain('value="72"');
expect(html).toContain('min="40"');
});
});
@@ -237,7 +237,7 @@ function MetricRow({
}: MetricRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit } = useUnitDisplay();
const canonicalBounds = METRIC_BOUNDS[metric];
// v1.32.27 — the row reads and writes in the user's preferred unit
@@ -246,12 +246,14 @@ function MetricRow({
// canonical, so it converts on the way into the fields and inverts on
// the way back out in `onSave`. Guardrails round inward so a value
// typed at the displayed limit still passes the server's canonical
// check. Metrics without a transform — and every metric for a metric
// user — take the adapter's identity path untouched.
// check. The glucose thresholds read in the reader's glucose unit the
// same way. Metrics without a transform — and every metric on the default
// preferences — take the adapter's identity path untouched.
const units = resolveTargetUnitAdapter(
metric,
canonicalBounds.unit,
preference,
glucoseUnit,
);
const bounds = { ...units.bounds(canonicalBounds), unit: units.unit };
+26 -39
View File
@@ -17,8 +17,11 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import {
isGlucoseTargetMetric,
resolveTargetUnitAdapter,
} from "@/lib/targets/target-unit-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
@@ -129,49 +132,33 @@ function TargetEditSheetBody({
const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null;
const isDerivedMetric = !isBp && metric == null;
// Glucose is the one metric whose display unit can differ from the
// canonical storage unit: HealthLog stores mg/dL, but a user on the
// mmol/L preference sees — and types — mmol/L. The parent already
// hands us `unit="mmol/L"` and a `initialRange` pre-converted to that
// unit. We must therefore (a) seed the mg/dL persisted override into
// the display unit, (b) validate the typed value against bounds
// expressed in the display unit, and (c) convert the typed value back
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
const isGlucoseMmol =
metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L";
// v1.32.27 — the same coupled treatment now covers the metric/imperial
// preference. Weight-class thresholds are stored in kg; an imperial
// user seeds, validates, and types in lb, and the adapter inverts the
// typed number back to kg before the PUT. Glucose keeps its own
// mg/dL ↔ mmol/L dialect (it carries no display transform, so the two
// never compose); every other metric takes the adapter's identity
// path and is numerically untouched.
const { preference } = useUnitDisplay();
// One adapter owns every direction of the unit conversion: an imperial
// user seeds, validates and types weight-class thresholds in pounds, an
// mmol/L user types glucose thresholds in mmol/L, and the typed number is
// inverted back to the canonical kg / mg/dL before the PUT. The parent
// hands `unit` and `initialRange` already in the display unit; the
// persisted override and `METRIC_BOUNDS` are canonical and go through the
// adapter. Every other metric takes the identity path.
// A glucose sheet speaks the unit its panel registered, so the sheet and
// the band it edits can never disagree.
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? "");
const units = resolveTargetUnitAdapter(
isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""),
adapterKey,
unit,
preference,
isGlucoseTargetMetric(adapterKey)
? resolveGlucoseUnit(unit)
: accountGlucoseUnit,
);
const toDisplay = (canonical: number) =>
isGlucoseMmol
? convertGlucose(canonical, "mmol/L")
: units.toDisplay(canonical);
const toCanonical = (displayValue: number) =>
isGlucoseMmol
? toCanonicalMgdl(displayValue, "mmol/L")
: units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the
// display unit. Glucose projects its mg/dL window through the same
// conversion the fields use; every other metric rounds the window
// INWARD so a value typed at the display bound never inverts to a
// canonical value the server rejects.
const toDisplay = (canonical: number) => units.toDisplay(canonical);
const toCanonical = (displayValue: number) => units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the display
// unit and rounded INWARD so a value typed at the display bound never
// inverts to a canonical value the server rejects.
const toDisplayBounds = (canonical: { min: number; max: number }) =>
isGlucoseMmol
? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) }
: units.bounds(canonical);
units.bounds(canonical);
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
+15 -5
View File
@@ -23,13 +23,21 @@ import {
getDisplayTransform,
invertDisplayTransform,
hasDisplayTransform,
resolveUnitPreference,
type DisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
export interface UnitDisplay {
/** Resolved preference ("metric" by default / on a stale payload). */
preference: UnitPreference;
/**
* Resolved glucose unit ("mg/dL" by default). Carried beside the
* metric/imperial branch so a surface that edits or renders glucose reads
* both preferences from the one hook.
*/
glucoseUnit: GlucoseUnit;
/** The full transform for a type under the current preference. */
transformFor: (type: string) => DisplayTransform;
/** Convert a raw canonical ABSOLUTE value to the display value. */
@@ -48,7 +56,7 @@ export interface UnitDisplay {
export function useUnitDisplay(): UnitDisplay {
const { user } = useAuth();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
/**
@@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay {
*/
export function useUnitDisplayOnceMounted(): UnitDisplay {
const user = useAccountOnceMounted();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
function useDisplayForPreference(
rawPreference: string | null | undefined,
rawGlucoseUnit: string | null | undefined,
): UnitDisplay {
const preference: UnitPreference =
rawPreference === "imperial" ? "imperial" : "metric";
const preference = resolveUnitPreference(rawPreference);
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
return useMemo<UnitDisplay>(() => {
const transformFor = (type: string) =>
getDisplayTransform(type, preference);
return {
preference,
glucoseUnit,
transformFor,
toDisplay: (type, rawValue) =>
applyDisplayTransform(rawValue, transformFor(type)),
@@ -94,5 +104,5 @@ function useDisplayForPreference(
decimalsFor: (type) => transformFor(type).decimals,
isTransformed: (type) => hasDisplayTransform(type),
};
}, [preference]);
}, [preference, glucoseUnit]);
}
@@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => {
expect(text).not.toContain("450 h");
});
it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => {
// The measurement table used to take BLOOD_GLUCOSE's canonical unit and
// number, so an mmol/L account read "99.0 mg/dL" two lines above its own
// context rows in mmol/L.
const data = makeData({
glucoseUnit: "mmol/L",
stats: {
...makeData().stats,
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
});
const bytes = renderDoctorReportPdfBytes(data, {
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("5.5 mmol/L");
expect(text).toContain("6.0 mmol/L");
expect(text).not.toContain("mg/dL");
});
it("omits the sleep vitals row when there are no sleep stats", async () => {
const bytes = renderDoctorReportPdfBytes(makeData(), {
timeFormat: "AUTO",
@@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest";
import { buildInterpretationBlock } from "../interpretation-block";
describe("buildInterpretationBlock", () => {
it("classifies on the canonical value but prints in the reader's unit", () => {
const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10;
const block = buildInterpretationBlock({
metricKey: "BODY_TEMPERATURE",
value: 36.8,
sex: null,
locale: "en",
display: { unit: "°F", convert: toF },
});
expect(block).toBeDefined();
expect(block).toContain("Current value 98.2 °F");
expect(block).not.toContain("°C");
// Same band as the canonical block names.
const canonical = buildInterpretationBlock({
metricKey: "BODY_TEMPERATURE",
value: 36.8,
sex: null,
locale: "en",
})!;
const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1];
expect(band(block!)).toBe(band(canonical));
});
it("renders the band position for visceral fat 2.7 (the headline case)", () => {
const en = buildInterpretationBlock({
metricKey: "VISCERAL_FAT",
+21 -11
View File
@@ -130,9 +130,17 @@ function proximityPhrase(
*/
export function buildInterpretationBlock(args: {
metricKey: string;
/** The value in the guideline's own (canonical) unit — what is classified. */
value: number;
sex: "MALE" | "FEMALE" | null | undefined;
locale: Locale;
/**
* Print the block in the reader's unit. Classification always runs on the
* canonical value against the canonical bands; only the printed numbers and
* the unit symbol are converted, so a prompt whose snapshot reads °F does
* not carry a band table in °C beside it.
*/
display?: { unit: string; convert: (canonical: number) => number };
}): string | undefined {
const resolved = resolveInterpretation(args.metricKey, args.sex ?? null);
if (!resolved) return undefined;
@@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: {
const loc: "en" | "de" = instructionLocale(args.locale);
const pos = classifyBandPosition(args.value, resolved.bands);
const rows = resolved.bands
const show = (n: number) => (args.display ? args.display.convert(n) : n);
const unit = args.display ? args.display.unit : resolved.unit;
const shownBands = resolved.bands.map((band) => ({
...band,
upTo: band.upTo === null ? null : show(band.upTo),
}));
const rows = shownBands
.map((band, i) =>
bandRow(
band,
i > 0 ? resolved.bands[i - 1].upTo : null,
resolved.unit,
loc,
),
bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc),
)
.map((r) => ` - ${r}`)
.join("\n");
const proximity = proximityPhrase(
pos.proximity,
pos.nearestEdge,
resolved.unit,
pos.nearestEdge === null ? null : show(pos.nearestEdge),
unit,
loc,
);
const valence = valencePhrase(pos.band.valence, loc);
@@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: {
if (loc === "en") {
return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute):
- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}.
- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}.
- Guideline bands (${resolved.source}; ${direction}):
${rows}
- Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat}
- Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`;
}
return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen):
- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}.
- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}.
- Leitlinien-Bänder (${resolved.source}; ${direction}):
${rows}
- Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat}
@@ -240,6 +240,7 @@ function score(args: {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: args.modules,
healthScoreConfigJson: args.healthScoreConfigJson,
@@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: {
glucose: true,
@@ -14,6 +14,7 @@ const live = {
source: "live" as const,
readFailed: false,
timezone: "Europe/Berlin",
glucoseUnit: "mg/dL" as const,
};
function days(count: number, value: number) {
@@ -160,6 +161,41 @@ describe("reference-score pillars", () => {
expect(eligible.status).toBe("ok");
});
it("writes the fasting labels in the reader's glucose unit, value and band canonical", () => {
const fasting = days(8, 99).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
}));
const mmol = computeGlycaemiaPillar({
...live,
glucoseUnit: "mmol/L",
asOf: NOW,
hba1c: [],
fastingGlucose: fasting,
});
const mgdl = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [],
fastingGlucose: fasting,
});
expect(mmol.status).toBe("ok");
expect(mgdl.status).toBe("ok");
if (mmol.status !== "ok" || mgdl.status !== "ok") return;
// These labels reach the app as finished text.
expect(mmol.value.observed.label).toBe("Fasting median 5.5 mmol/L");
expect(mmol.value.reference.label).toBe("3.9–5.5 mmol/L fasting");
expect(mgdl.value.observed.label).toBe("Fasting median 99 mg/dL");
expect(mgdl.value.reference.label).toBe("70–99 mg/dL fasting");
// The score and the numbers the web converts from do not move.
expect(mmol.value.observed.value).toBe(99);
expect(mmol.value.observed.unit).toBe("mg/dL");
expect(mmol.value.reference.low).toBe(70);
expect(mmol.value.reference.high).toBe(99);
expect(mmol.value.score).toBe(mgdl.value.score);
});
it("does not use fasting fallback when the preferred lab read failed", () => {
const result = computeGlycaemiaPillar({
...live,
@@ -137,6 +137,7 @@ describe("score reader bounds and source parity", () => {
timezone: "Europe/Berlin",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: {
glucose: true,
@@ -233,6 +233,7 @@ const live = {
source: "live" as const,
readFailed: false,
timezone: "Europe/Berlin",
glucoseUnit: "mg/dL" as const,
};
function days(count: number, value: number) {
+3
View File
@@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveModuleMap } from "@/lib/modules/gate";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { computeAndRecordUserHealthScore } from "./record";
import type { CompositeValue } from "./types";
@@ -23,6 +24,7 @@ export async function computeHealthScoreDerived(
timezone: true,
thresholdsJson: true,
healthScoreConfigJson: true,
glucoseUnit: true,
},
});
const now = new Date();
@@ -59,6 +61,7 @@ export async function computeHealthScoreDerived(
timezone,
sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: modules.glucose !== false,
+6 -2
View File
@@ -1,4 +1,5 @@
import type { Derived } from "@/lib/insights/derived/types";
import { convertGlucose } from "@/lib/glucose";
import type { GlycaemiaPillarInput, PillarValue } from "./types";
import {
@@ -153,10 +154,13 @@ export function computeGlycaemiaPillar(
FASTING_GLUCOSE_REFERENCE.low,
FASTING_GLUCOSE_REFERENCE.high,
),
// `value`, `unit` and the band stay canonical: the score is computed on
// them and the web converts from them. The labels are finished text a
// client prints as-is, so they are written in the reader's unit.
observed: {
value: observed,
unit: "mg/dL",
label: `Fasting median ${Math.round(observed)} mg/dL`,
label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`,
asOf: fasting.at(-1)!.at.toISOString(),
sources: uniqueSources(fasting.map((row) => row.source)),
},
@@ -164,7 +168,7 @@ export function computeGlycaemiaPillar(
kind: "clinical-threshold",
low: FASTING_GLUCOSE_REFERENCE.low,
high: FASTING_GLUCOSE_REFERENCE.high,
label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`,
label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`,
source: "ADA 2025; Selvin 2010",
},
noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)),
+8
View File
@@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types";
import { computeWeightGoal } from "./weight-goal";
import { DAY_MS, uniqueSources } from "./shared";
import type { DerivedProvenanceSource } from "@/lib/insights/derived/types";
import type { GlucoseUnit } from "@/lib/glucose";
const HISTORY_OFFSET_DAYS = 14;
const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
@@ -52,6 +53,12 @@ export interface ScoreReaderProfile {
timezone: string;
sourcePriorityJson: unknown;
thresholdsJson: unknown;
/**
* The record's glucose unit, for the glycaemia pillar's display labels.
* Required: those labels are served as finished text (the app prints them
* verbatim), and a caller that forgets the unit must not get mg/dL.
*/
glucoseUnit: GlucoseUnit;
}
export interface UserHealthScoreInput {
@@ -513,6 +520,7 @@ function scoreInputsFor(args: {
},
GLYCAEMIA: {
source: "live",
glucoseUnit: input.profile.glucoseUnit,
readFailed:
(!input.modules.glucose || !args.glucose.ok) &&
(!input.modules.labs || !args.labs.ok),
+7
View File
@@ -8,6 +8,7 @@ import type {
// runtime cycle. The tier's definition belongs beside the rule that
// produces it; its NAME belongs here, where the wire shape is declared.
import type { ScoreBreadthTier } from "./breadth";
import type { GlucoseUnit } from "@/lib/glucose";
/**
* The scoring method's identity.
@@ -346,6 +347,12 @@ export interface GlucoseReading {
export interface GlycaemiaPillarInput extends DomainReadState {
asOf: Date;
/**
* The unit the fasting-glucose labels are written in. The scored value,
* its `unit` and the reference band stay canonical mg/dL; only the two
* human-readable labels follow the reader.
*/
glucoseUnit: GlucoseUnit;
hba1cReadFailed?: boolean;
fastingReadFailed?: boolean;
hba1c: HbA1cReading[];
+2 -1
View File
@@ -49,7 +49,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -788,6 +788,7 @@ async function buildExtras(
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: modules.glucose !== false,
+11 -25
View File
@@ -19,7 +19,11 @@ import {
REPORT_GROUPS,
} from "./report-selection/catalogue";
import { getUnitForType } from "./validations/measurement";
import {
DOCTOR_REPORT_TYPE_UNIT_KEYS,
reportStatUnit,
} from "./doctor-report/stat-display";
import { resolveGlucoseUnit } from "./glucose";
import {
makeFormatters,
type DateFormatPreference,
@@ -175,18 +179,9 @@ export interface DoctorReportRenderOptions {
includeCharts?: boolean;
}
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
// The unit map moved beside the clinician view's reader of it; re-exported so
// the enum-coverage test keeps one import.
export { DOCTOR_REPORT_TYPE_UNIT_KEYS };
/**
* The measurement table is driven by the selection, not by a whitelist.
@@ -238,18 +233,9 @@ export function buildDoctorReportPdfDocument(
const footerTz =
userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
const unitFor = (type: string): string => {
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
// Every other type falls back to the canonical unit the measurement
// validation layer already records. A type with no recorded unit prints
// none — an absent unit is absent, not a guessed symbol.
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
};
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
const unitFor = (type: string): string =>
reportStatUnit(type, glucoseUnit, t);
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
patchPdfTextSanitiser(doc);
@@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import { reportStatValue } from "../doctor-report/stat-display";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import {
@@ -235,12 +236,9 @@ export function buildMeasurementsChartsSection(
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
// layer reconstructs them for exactly this row); the vitals unit is
// hours (`h`), so convert to keep value and unit consistent — the same
// hours value the FHIR sleep Observation emits.
const conv =
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
// Sleep minutes print as hours and glucose in the owner's unit; the
// clinician view reads the same helper, so the two cannot drift.
const conv = (v: number) => reportStatValue(type, v, glucoseUnit);
groupRows.push([
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
`${num(conv(s.latest))} ${unit}`.trim(),
+79
View File
@@ -0,0 +1,79 @@
/**
* How a measurement statistic reads in the clinician-facing artefacts: the
* PDF report and the shared clinician view.
*
* Both describe the same record to the same reader, so they take the unit and
* the displayed number from here and nowhere else. Before this module the PDF
* carried its own unit map and the clinician view printed bare numbers, so a
* doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with
* no unit at all, and for an account on mmol/L the glucose rows of the PDF's
* measurement table still printed raw mg/dL beside a mmol/L glucose panel.
*
* The policy, which `display-transform.ts` states for the metric/imperial
* preference: a document handed to a clinician stays SI for mass, length and
* temperature regardless of the owner's imperial preference. Glucose is the
* exception, because mmol/L versus mg/dL is not an imperial question — it is
* the clinical convention of the country the record lives in — so glucose
* follows the owner's `glucoseUnit` here as it does everywhere a person reads
* it.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
/**
* Report-specific unit symbols, where the report's own reading differs from
* the validation layer's canonical unit. `null` marks a unit that has to be
* translated (steps). Everything absent falls back to `getUnitForType`.
*/
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
/**
* The unit symbol a report statistic of `type` is printed with.
*
* `translate` resolves the one translated unit (steps). A type with no
* recorded unit prints none — an absent unit is absent, not a guessed symbol.
*/
export function reportStatUnit(
type: string,
glucoseUnit: GlucoseUnit,
translate: (key: string) => string,
): string {
if (type === "BLOOD_GLUCOSE") return glucoseUnit;
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return translate("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
}
/**
* The number a report statistic of `type` is printed as, in the unit
* {@link reportStatUnit} names.
*
* SLEEP_DURATION statistics are per-night asleep totals in minutes (the data
* layer reconstructs them for exactly this row) while the unit is hours, so
* they are divided — the same hours value the FHIR sleep Observation emits.
* Glucose is stored in mg/dL and converted to the owner's unit.
*/
export function reportStatValue(
type: string,
value: number,
glucoseUnit: GlucoseUnit,
): number {
if (type === "SLEEP_DURATION") return value / 60;
if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit);
return value;
}
@@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => {
expect(result.text).toContain("Steady.");
});
});
describe("generateMetricStatus — the note is written in the reader's units", () => {
function seed(values: (day: number) => number, days = 120) {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < days; day++) {
records.push({
value: values(day),
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.measurement.count).mockResolvedValue(days as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
}
function snapshotOf(userPrompt: string) {
return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]);
}
it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "metric",
glucoseUnit: "mmol/L",
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("unit: mmol/L");
// 70–140 mg/dL is 3.9–7.8 mmol/L.
expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L");
// The shared safety contract quotes its own fixed thresholds; the metric
// block itself must not carry the canonical band.
expect(captured.systemPrompt).not.toContain("70–140 mg/dL");
const snapshot = snapshotOf(captured.userPrompt!);
expect(snapshot.metric.unit).toBe("mmol/L");
expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 });
const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map(
(b: { mean: number }) => b.mean,
);
expect(recentMeans.length).toBeGreaterThan(0);
for (const mean of recentMeans) {
expect(mean).toBeGreaterThan(5);
expect(mean).toBeLessThan(6.5);
}
expect(captured.userPrompt).not.toContain("mg/dL");
});
it("states body temperature in °F for an imperial reader, guideline band included", async () => {
seed((day) => 36.6 + (day % 3) * 0.1);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "imperial",
glucoseUnit: null,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BODY_TEMPERATURE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("unit: °F");
expect(captured.systemPrompt).not.toContain("36.1–37.2 °C");
// The interpretation block classifies in °C but prints in °F.
expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT");
expect(captured.userPrompt).not.toContain("°C");
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F");
});
it("writes the no-provider floor in the reader's glucose unit", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "metric",
glucoseUnit: "mmol/L",
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never);
const result = await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(result.text).toMatch(/\d\.\d mmol\/L/);
expect(result.text).not.toContain("mg/dL");
});
it("leaves a metric reader on the default units byte-identical", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: null,
glucoseUnit: null,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("70–140 mg/dL");
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL");
});
});
@@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => {
});
describe("numericDescriptorCopy", () => {
it("states a preference-bound slot in the unit the caller resolves", () => {
const d = computeTrendDescriptor(series([181.6, 178.5]), {
absoluteFloor: 0.66,
relativeFloor: 0.01,
decimals: 1,
})!;
const copy = numericDescriptorCopy("weight", d, (type) =>
type === "WEIGHT" ? "lb" : "?",
);
expect(copy?.params.unit).toBe(" lb");
expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe(
"WALKING_RUNNING_DISTANCE",
);
expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe(
"BODY_TEMPERATURE",
);
});
it("resolves the rising template with a signed delta and spaced unit", () => {
const d = computeTrendDescriptor(series([120, 128]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
})!;
const copy = numericDescriptorCopy("bp", d);
const copy = numericDescriptorCopy("bp", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.delta).toBe("+8");
expect(copy?.params.unit).toBe(" mmHg");
@@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.05,
decimals: 0,
})!;
const copy = numericDescriptorCopy("steps", d);
const copy = numericDescriptorCopy("steps", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.unit).toBe("");
});
@@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.01,
decimals: 1,
})!;
const copy = numericDescriptorCopy("weight", d);
const copy = numericDescriptorCopy("weight", d, () => "kg");
expect(copy?.key).toBe("insights.trendDescriptor.falling");
expect(copy?.params.delta).toBe("−1.4");
});
it("returns null for a slot with no numeric meta (mood)", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
expect(numericDescriptorCopy("mood", d)).toBeNull();
expect(numericDescriptorCopy("mood", d, () => "")).toBeNull();
});
it("covers every numeric slot in the descriptor meta", () => {
@@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0,
decimals: 1,
})!;
expect(numericDescriptorCopy(metric, d)).not.toBeNull();
expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull();
}
});
});
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
insightNarrative: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-invalidation", () => ({
enqueueStatusRefillForUser: vi.fn(),
}));
vi.mock("@/lib/jobs/period-narrative-shared", () => ({
enqueueNarrativeWarm: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
import { refreshTextsAfterUnitChange } from "../unit-change-refresh";
beforeEach(() => {
vi.resetAllMocks();
});
describe("refreshTextsAfterUnitChange", () => {
it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
locale: "de",
} as never);
vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12);
vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([
{ period: "week", locale: "de" },
{ period: "month", locale: "en" },
{ period: "year", locale: "de" },
{ period: "week", locale: "xx" },
] as never);
await refreshTextsAfterUnitChange("user-1");
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de");
expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2);
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
userId: "user-1",
period: "week",
locale: "de",
});
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
userId: "user-1",
period: "month",
locale: "en",
});
});
it("never throws into the preference write", async () => {
vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down"));
await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe(
undefined,
);
});
});
+29
View File
@@ -598,6 +598,35 @@ export function scaleGradedSeries(
};
}
/**
* Re-express a canonical graded series in a display unit: absolute bucket
* values (`min / max / mean`) take the full affine conversion, the yearly
* slope — a rate, not a level — takes the factor alone, exactly as
* `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are
* untouched. Used where a series is narrated in the reader's unit; the
* series it is built from stays canonical.
*/
export function convertGradedSeries(
series: GradedSeries,
conversion: { factor: number; offset?: number },
digits = 2,
): GradedSeries {
const offset = conversion.offset ?? 0;
const abs = (v: number) => round(v * conversion.factor + offset, digits);
const bucket = <T extends { min: number; max: number; mean: number }>(
b: T,
): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) });
return {
recent: series.recent.map(bucket),
weekly: series.weekly.map(bucket),
monthly: series.monthly.map(bucket),
yearly: series.yearly.map((b) => ({
...bucket(b),
slope: b.slope === null ? null : round(b.slope * conversion.factor, 4),
})),
};
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
+129 -16
View File
@@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
convertGradedSeries,
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
@@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
import {
applyDisplayTransform,
getReadingTransform,
invertDisplayTransform,
isUnitSensitiveType,
resolveUnitPreferences,
transformRescales,
type DisplayTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
export interface MetricStatusResult {
hasProvider: boolean;
@@ -222,9 +234,40 @@ export async function generateMetricStatus(args: {
const user = await prisma.user.findUnique({
where: { id: args.userId },
select: { dateOfBirth: true, gender: true },
select: {
dateOfBirth: true,
gender: true,
unitPreference: true,
glucoseUnit: true,
},
});
// The note is written in the reader's units. The series, the reference band
// and the signal are converted once, here, before any of them reaches the
// prompt or the deterministic floor; the registry entry, the rollups and
// the guideline classification stay canonical. An untransformed metric (and
// every metric on the default preferences that does not rescale) passes
// through unchanged, so its snapshot hash does not move.
const reading = readingDisplay(
meta,
resolveUnitPreferences({
unitPreference: user?.unitPreference,
glucoseUnit: user?.glucoseUnit,
}),
);
const shownMeta: MetricStatusMeta = {
...meta,
unit: reading.unit,
...(meta.normalRange
? {
normalRange: {
low: reading.toDisplay(meta.normalRange.low),
high: reading.toDisplay(meta.normalRange.high),
},
}
: {}),
};
const now = new Date();
// Bounded raw read for the derived recent stats; the monthly/yearly
@@ -295,7 +338,10 @@ export async function generateMetricStatus(args: {
})
.then((r) => r.reverse());
for (const m of rows) {
points.push({ measuredAt: m.measuredAt, value: m.value });
points.push({
measuredAt: m.measuredAt,
value: reading.toDisplay(m.value),
});
}
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
@@ -309,11 +355,13 @@ export async function generateMetricStatus(args: {
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
const graded = isSleep
? buildGradedSeriesFromPoints(points, now, userTz)
: await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
userTz,
: reading.convertGraded(
await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
userTz,
),
);
const summary = summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
@@ -344,7 +392,14 @@ export async function generateMetricStatus(args: {
// metric + profile; otherwise keep the existing flat anchor (strictly
// additive, never a regression). Age + sex only — no ancestry/region.
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
const normalRange = sharpenedRange ?? meta.normalRange;
const canonicalRange = sharpenedRange ?? meta.normalRange;
const normalRange = canonicalRange
? {
...canonicalRange,
low: reading.toDisplay(canonicalRange.low),
high: reading.toDisplay(canonicalRange.high),
}
: canonicalRange;
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
// normal-swing verdict, computed server-side so the model states it rather
@@ -353,7 +408,7 @@ export async function generateMetricStatus(args: {
// when the recent window is empty, in which case it is omitted.
const signal = buildMetricSignal({
metric: meta.displayName,
unit: meta.unit,
unit: shownMeta.unit,
direction: meta.direction,
graded,
normalRange: normalRange ?? null,
@@ -369,7 +424,7 @@ export async function generateMetricStatus(args: {
metric: {
id: meta.id,
displayName: meta.displayName,
unit: meta.unit,
unit: shownMeta.unit,
direction: meta.direction,
...(normalRange ? { normalRange } : {}),
...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}),
@@ -477,14 +532,24 @@ export async function generateMetricStatus(args: {
// window exists); absent for uncovered metrics (fail-soft to personal-
// relative). Sex-split bands (waist) resolve only when the profile carries a
// sex.
// The guideline bands are canonical, so the band position is classified on
// the canonical value; the block then prints in the reader's unit.
const interpretationValue = signal?.current ?? latest?.value ?? null;
const interpretationBlock =
interpretationValue !== null
? buildInterpretationBlock({
metricKey: meta.id,
value: interpretationValue,
value: reading.toCanonical(interpretationValue),
sex,
locale,
...(reading.converts
? {
display: {
unit: reading.unit,
convert: reading.toDisplay,
},
}
: {}),
})
: undefined;
@@ -502,9 +567,9 @@ export async function generateMetricStatus(args: {
userId: args.userId,
cacheAction,
capability: "statusText",
systemPrompt: getMetricArchetypeSystemPrompt(meta, locale),
systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale),
userPrompt: getMetricArchetypeUserPrompt(
meta,
shownMeta,
snapshotJson,
todayKey,
locale,
@@ -526,7 +591,7 @@ export async function generateMetricStatus(args: {
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMetricStatusText(locale, signal),
text: getNoKeyMetricStatusText(locale, signal, reading.decimals),
cached: true,
updatedAt: null,
};
@@ -537,7 +602,7 @@ export async function generateMetricStatus(args: {
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal),
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
@@ -553,7 +618,7 @@ export async function generateMetricStatus(args: {
reason: "screened",
userId: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal),
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
const text = screened.text;
@@ -577,3 +642,51 @@ export function resolveMetricStatusLocale(
): SupportedLocale {
return normalizeLocale(locale);
}
/**
* How one metric's numbers are written for this reader: the unit symbol, the
* conversions in both directions, and the decimals a value is read at.
*
* Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the
* metric/imperial set) converts; every other metric keeps its registry unit
* and its stored numbers. A transform that does not rescale (`converts`
* false) is the identity, so the default preferences leave a snapshot
* byte-identical for the metrics whose registry unit already is the display
* unit.
*/
function readingDisplay(
meta: MetricStatusMeta,
preferences: UnitPreferences,
): {
unit: string;
decimals: number | undefined;
converts: boolean;
toDisplay: (canonical: number) => number;
toCanonical: (display: number) => number;
convertGraded: (series: GradedSeries) => GradedSeries;
} {
if (!isUnitSensitiveType(meta.measurementType)) {
return {
unit: meta.unit,
decimals: undefined,
converts: false,
toDisplay: (v) => v,
toCanonical: (v) => v,
convertGraded: (series) => series,
};
}
const transform: DisplayTransform = getReadingTransform(
meta.measurementType,
preferences,
);
const converts = transformRescales(transform);
return {
unit: transform.displayUnit,
decimals: transform.decimals,
converts,
toDisplay: (v) => applyDisplayTransform(v, transform),
toCanonical: (v) => invertDisplayTransform(v, transform),
convertGraded: (series) =>
converts ? convertGradedSeries(series, transform) : series,
};
}
@@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
expect(sleepDelta).toBeDefined();
// The pre-fix local `toDailyMeans` twin would have averaged the three
// segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no
// clinical meaning. The correct grain is the night's TOTAL (410).
expect(sleepDelta!.current).toBe(410);
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0);
// clinical meaning. The correct grain is the night's TOTAL (410 min),
// narrated in the hours its unit names: this used to read "410 h".
expect(sleepDelta!.unit).toBe("h");
expect(sleepDelta!.current).toBe(6.83);
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1);
});
it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => {
@@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => {
bandTransitions: [
{
type: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
center: 140,
bandLow: 110,
bandHigh: 130,
@@ -9,6 +9,9 @@ import type {
DailySeriesPoint,
NamedSeries,
} from "@/lib/insights/correlation-discovery";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { buildDeterministicNarrative } from "../period-narrative-deterministic";
import { buildNarrativeUserPrompt } from "../period-narrative-generate";
/**
* Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point
@@ -42,6 +45,7 @@ function monthInput(
discoverySeries,
locale: "en",
computedAt: "2026-04-30T12:00:00.000Z",
units: DEFAULT_UNIT_PREFERENCES,
};
}
@@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => {
});
});
describe("assemblePeriodNarrativeContext — the reader's units", () => {
function glucoseAndWeight() {
const m = new Map<string, DailySeriesPoint[]>();
// Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg.
m.set(
"BLOOD_GLUCOSE",
seriesEndingAt(
[...Array(30).fill(90), ...Array(30).fill(108)],
"2026-04-30",
),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
return m;
}
it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => {
const ctx = assertReady(
assemblePeriodNarrativeContext({
...monthInput(glucoseAndWeight()),
units: { system: "imperial", glucoseUnit: "mmol/L" },
}),
);
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
expect(g.unit).toBe("mmol/L");
expect(g.current).toBe(6);
expect(g.prior).toBe(5);
expect(g.delta).toBe(1);
// Percent is unit-free.
expect(g.deltaPercent).toBe(20);
const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!;
expect(w.unit).toBe("lb");
expect(w.current).toBe(180.8);
expect(w.delta).toBe(4.4);
const prose = buildDeterministicNarrative(ctx, "en");
expect(prose).toContain("mmol/L");
expect(prose).toContain("lb");
expect(prose).not.toContain("mg/dL");
expect(prose).not.toContain("kg");
const prompt = buildNarrativeUserPrompt(ctx, "en");
expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L");
expect(prompt).not.toContain("mg/dL");
});
it("keeps mg/dL and kilograms on the default preferences", () => {
const ctx = assertReady(
assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())),
);
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
expect(g.unit).toBe("mg/dL");
expect(g.current).toBe(108);
expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg");
});
it("narrates sleep minutes as hours, the unit it always claimed", () => {
const m = new Map<string, DailySeriesPoint[]>();
m.set(
"SLEEP_DURATION",
seriesEndingAt(
[...Array(30).fill(420), ...Array(30).fill(450)],
"2026-04-30",
),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m)));
const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!;
expect(s.unit).toBe("h");
expect(s.current).toBe(7.5);
expect(s.delta).toBe(0.5);
});
it("states a band transition with its unit, converted", () => {
const m = new Map<string, DailySeriesPoint[]>();
const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3));
m.set(
"BLOOD_GLUCOSE",
seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
const ctx = assertReady(
assemblePeriodNarrativeContext({
...monthInput(m),
units: { system: "metric", glucoseUnit: "mmol/L" },
}),
);
const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!;
expect(t.unit).toBe("mmol/L");
expect(t.center).toBe(7.8);
expect(t.movedOut).toBe(true);
expect(buildNarrativeUserPrompt(ctx, "en")).toContain(
"BLOOD_GLUCOSE: 7.8 mmol/L",
);
});
});
describe("assemblePeriodNarrativeContext — band transitions", () => {
it("flags a vital whose current center moved above its prior band", () => {
const m = new Map<string, DailySeriesPoint[]>();
@@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt(
: locale === "de"
? "im Bereich"
: "in range";
const unit = b.unit ? ` ${b.unit}` : "";
lines.push(
`- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`,
`- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`,
);
}
}
+112 -25
View File
@@ -56,6 +56,14 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { resolveModuleMap } from "@/lib/modules/gate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
isUnitSensitiveType,
resolveUnitPreferences,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -74,20 +82,80 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3;
/** A band must rest on at least this many prior-period days to be trusted. */
export const MIN_BASELINE_DAYS = 7;
/** The metrics the period-delta beat scans, with their display unit. */
const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [
{ type: "WEIGHT", unit: "kg" },
{ type: "BLOOD_PRESSURE_SYS", unit: "mmHg" },
{ type: "BLOOD_PRESSURE_DIA", unit: "mmHg" },
{ type: "PULSE", unit: "bpm" },
{ type: "RESTING_HEART_RATE", unit: "bpm" },
{ type: "HEART_RATE_VARIABILITY", unit: "ms" },
{ type: "SLEEP_DURATION", unit: "h" },
{ type: "ACTIVITY_STEPS", unit: "" },
{ type: "BODY_FAT", unit: "%" },
{ type: "BLOOD_GLUCOSE", unit: "mg/dL" },
/**
* The metrics the period-delta beat scans. Their unit is not listed here: a
* reading is narrated in the reader's unit (see `narratedUnit`), and the list
* used to pin kilograms and mg/dL for everyone.
*/
const DELTA_METRICS: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BODY_FAT",
"BLOOD_GLUCOSE",
];
/**
* Unit symbols of the narrated types whose unit no preference changes. The
* sleep channel is a per-night total in minutes and is narrated in hours.
*/
const FIXED_NARRATED_UNITS: Partial<Record<MeasurementType, string>> = {
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
RESTING_HEART_RATE: "bpm",
HEART_RATE_VARIABILITY: "ms",
RESPIRATORY_RATE: "breaths/min",
OXYGEN_SATURATION: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: "",
BODY_FAT: "%",
};
/**
* How one narrated type is written for this reader: its unit symbol, the
* conversion of an absolute value (a mean, a band edge) and of a difference
* (a delta — factor only, never an affine offset). The computation itself
* stays canonical, so the personal-band floors and the percent change do not
* depend on the unit the reader chose.
*/
function narratedUnit(
type: MeasurementType,
units: UnitPreferences,
): {
unit: string;
abs: (canonical: number) => number;
delta: (canonical: number) => number;
} {
if (isUnitSensitiveType(type)) {
const transform = getReadingTransform(type, units);
return {
unit: transform.displayUnit,
// Rounded to the unit's own grain (one decimal for lb and mmol/L), the
// grain every screen shows the same reading at.
abs: (v) => round2(applyDisplayTransform(v, transform)),
delta: (v) => round2(applyDisplayTransformDelta(v, transform)),
};
}
if (type === "SLEEP_DURATION") {
return {
unit: "h",
abs: (v) => round2(v / 60),
delta: (v) => round2(v / 60),
};
}
return {
unit: FIXED_NARRATED_UNITS[type] ?? "",
abs: round2,
delta: round2,
};
}
// ── context shape ───────────────────────────────────────────────────────
/** One metric's current-period mean vs the prior period of equal length. */
@@ -114,6 +182,8 @@ export interface MetricDelta {
*/
export interface BandTransition {
type: MeasurementType;
/** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */
unit: string;
/** Current-period robust center (median of per-day means). */
center: number;
/** Prior-period band edges. */
@@ -243,6 +313,12 @@ export interface AssembleInput {
locale: Locale;
/** Compute time (ISO). */
computedAt: string;
/**
* The reader's unit preferences. Required for the same reason the locale
* is: the context carries numbers a page and a prompt print verbatim, and a
* caller that stays quiet must not get kilograms and mg/dL by default.
*/
units: UnitPreferences;
}
/**
@@ -262,12 +338,14 @@ export function assemblePeriodNarrativeContext(
discoverySeries,
locale,
computedAt,
units,
} = input;
// ── metric deltas (current period vs prior period of equal length) ──────
const metricDeltas: MetricDelta[] = [];
const metricsWithCoverage: string[] = [];
for (const { type, unit } of DELTA_METRICS) {
for (const type of DELTA_METRICS) {
const shown = narratedUnit(type, units);
const points = seriesByMetric.get(type) ?? [];
const { current, prior } = splitByPeriod(points, currentFrom, priorFrom);
const currentAvg = meanOrNull(current);
@@ -280,16 +358,21 @@ export function assemblePeriodNarrativeContext(
currentAvg !== null && priorAvg !== null
? round2(currentAvg - priorAvg)
: null;
// Percent on the canonical values: it is unit-free for a pure rescale,
// and the one affine unit (°F) would otherwise move it.
const deltaPercent =
delta !== null && priorAvg !== null && priorAvg !== 0
? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10
: null;
metricDeltas.push({
type,
unit,
current: currentAvg === null ? null : round2(currentAvg),
prior: priorAvg === null ? null : round2(priorAvg),
delta,
unit: shown.unit,
current: currentAvg === null ? null : shown.abs(currentAvg),
prior: priorAvg === null ? null : shown.abs(priorAvg),
delta:
currentAvg !== null && priorAvg !== null
? shown.delta(currentAvg - priorAvg)
: null,
deltaPercent,
currentDays: current.length,
priorDays: prior.length,
@@ -324,11 +407,13 @@ export function assemblePeriodNarrativeContext(
const center = median(current);
const above = center > band.high;
const below = center < band.low;
const shown = narratedUnit(type, units);
bandTransitions.push({
type,
center: round2(center),
bandLow: round2(band.low),
bandHigh: round2(band.high),
unit: shown.unit,
center: shown.abs(center),
bandLow: shown.abs(band.low),
bandHigh: shown.abs(band.high),
direction: above ? "above" : below ? "below" : "in",
movedOut: above || below,
baselineDays: prior.length,
@@ -479,9 +564,13 @@ export async function buildPeriodNarrativeContext(
const profile = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
select: { timezone: true, unitPreference: true, glucoseUnit: true },
});
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
const units = resolveUnitPreferences({
unitPreference: profile?.unitPreference,
glucoseUnit: profile?.glucoseUnit,
});
const currentFrom = tzDayKey(
new Date(now.getTime() - periodDays * MS_PER_DAY),
@@ -523,10 +612,7 @@ export async function buildPeriodNarrativeContext(
// comment for why it is one and not four.
includeMoodFactors: true,
extraMeasurementTypes: Array.from(
new Set<MeasurementType>([
...DELTA_METRICS.map((m) => m.type),
...VITALS_BASELINE_TYPES,
]),
new Set<MeasurementType>([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]),
),
});
@@ -553,5 +639,6 @@ export async function buildPeriodNarrativeContext(
discoverySeries,
locale: opts.locale,
computedAt: now.toISOString(),
units,
});
}
+7 -1
View File
@@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized<string> = {
export function getNoKeyMetricStatusText(
locale: InsightLocale,
signal?: MetricSignal | null,
/**
* Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or
* pounds). Defaults to one, which was the only precision before the
* signal could arrive in the reader's unit.
*/
digits = 1,
): string {
const grounded =
signal && Number.isFinite(signal.current)
@@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText(
// localised upstream, so it stands for every locale.
label: { en: signal.metric },
...(signal.unit ? { unit: ` ${signal.unit}` } : {}),
digits: 1,
digits,
pointer: GENERIC_METRIC_POINTER,
},
locale,
+6 -6
View File
@@ -51,7 +51,12 @@ export interface TrendChartConfig {
types: string[];
/** Recharts stroke colours, positionally matched to `types`. */
colors: string[];
/** Display unit (chart legend / tooltip). */
/**
* Display unit (chart legend / tooltip) for a metric no preference
* changes. A single-series slot whose type has a display transform
* (weight, distance, body temperature) carries none: the renderer takes
* the unit, scale and offset from the reader's preference instead.
*/
unit?: string;
/** Y-axis unit label when it differs from the value unit. */
yAxisUnit?: string;
@@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WEIGHT"],
colors: ["var(--chart-1)"],
unit: "kg",
titleKey: "charts.weight",
captionKey: "insights.trendsRow.caption.weight",
annotationKey: "weight",
@@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WALKING_RUNNING_DISTANCE"],
colors: ["var(--chart-4)"],
unit: "km",
yAxisUnit: "km",
titleKey: "charts.distance",
captionKey: "insights.trendsRow.caption.distance",
detailHref: "/insights/walking-distance",
@@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["BODY_TEMPERATURE"],
colors: ["var(--destructive)"],
unit: "°C",
yAxisUnit: "°C",
titleKey: "charts.bodyTemperature",
captionKey: "insights.trendsRow.caption.body_temp",
detailHref: "/insights/body-temperature",
+21 -7
View File
@@ -140,8 +140,18 @@ export function computeTrendDescriptor(
* dedicated copy rather than the numeric `{delta} {unit}` template.
*/
export interface TrendSlotDescriptorMeta {
/** Display unit interpolated into the numeric descriptor template. */
unit: string;
/**
* Display unit interpolated into the numeric descriptor template, for a
* metric no preference changes.
*/
unit?: string;
/**
* For a metric whose unit follows the reader's preference: the
* measurement type the caller resolves the unit of (and converts the
* series and the noise floor through) before describing it. Such a slot
* declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C).
*/
unitType?: string;
config: TrendDescriptorConfig;
}
@@ -154,7 +164,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 },
},
weight: {
unit: "kg",
unitType: "WEIGHT",
config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 },
},
pulse: {
@@ -186,15 +196,16 @@ export const TREND_SLOT_DESCRIPTOR_META: Record<
config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 },
},
distance: {
unit: "km",
config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 },
unitType: "WALKING_RUNNING_DISTANCE",
// 300 m — canonical metres, converted to the reader's km or mi.
config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 },
},
vo2_max: {
unit: "",
config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 },
},
body_temp: {
unit: "°C",
unitType: "BODY_TEMPERATURE",
config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 },
},
};
@@ -231,9 +242,12 @@ export interface TrendDescriptorCopy {
export function numericDescriptorCopy(
metric: string,
descriptor: TrendDescriptor,
/** Resolves a `unitType` slot's unit under the reader's preference. */
unitFor: (type: string) => string,
): TrendDescriptorCopy | null {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
if (!meta) return null;
const unit = meta.unitType ? unitFor(meta.unitType) : (meta.unit ?? "");
// Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still
// carries its small delta so the caption can read "stable (±0 …)" if a
@@ -255,7 +269,7 @@ export function numericDescriptorCopy(
// reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)"
// without one — no dangling space inside the parens for unit-less
// metrics (steps, flights).
unit: meta.unit ? ` ${meta.unit}` : "",
unit: unit ? ` ${unit}` : "",
},
};
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Re-warm the stored texts that quote numbers after a unit preference changes.
*
* A status note and a period narrative are written once and served all day
* (the narrative for up to twenty hours). Both now state their figures in the
* reader's units, so a note written before a switch from mg/dL to mmol/L — or
* from metric to imperial — keeps saying the old unit until something
* regenerates it. Nothing did: neither preference write touched the stored
* text.
*
* The refill goes through the same hash-gated path as the manual regenerate:
* every card the record has is enqueued, the worker forces a fresh snapshot,
* and a card whose snapshot did not change (a metric that does not depend on
* the preference) gets a timestamp refresh and no model call. Narratives are
* re-warmed only where one already exists. Best-effort and fire-and-forget:
* the preference write never waits on it and never fails because of it.
*/
import { prisma } from "@/lib/db";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
import { normalizeLocale } from "@/lib/insights/status-shared";
import { locales, type Locale } from "@/lib/i18n/config";
import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative";
import { annotate } from "@/lib/logging/context";
export async function refreshTextsAfterUnitChange(
userId: string,
): Promise<void> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
const statusScopes = await enqueueStatusRefillForUser(
userId,
normalizeLocale(user?.locale),
);
const narratives = await prisma.insightNarrative.findMany({
where: { userId },
select: { period: true, locale: true },
});
let narrativeCount = 0;
for (const row of narratives) {
if (row.period !== "week" && row.period !== "month") continue;
if (!(locales as readonly string[]).includes(row.locale)) continue;
void enqueueNarrativeWarm({
userId,
period: row.period as NarrativePeriod,
locale: row.locale as Locale,
});
narrativeCount++;
}
annotate({
action: { name: "insights.unit-change.refresh" },
meta: { status_scopes: statusScopes, narratives: narrativeCount },
});
} catch {
// Best-effort: the nightly warm and the next ingest still converge the
// texts; a failed enqueue must not surface on the preference write.
}
}
@@ -10,7 +10,14 @@ import {
transformRescales,
TRANSFORMED_TYPES,
DEFAULT_UNIT_PREFERENCE,
DEFAULT_UNIT_PREFERENCES,
getQuantityTransform,
getReadingTransform,
isUnitSensitiveType,
paceSecondsPerDistanceUnit,
resolveUnitPreferences,
} from "../display-transform";
import { convertGlucose } from "@/lib/glucose";
describe("display-transform", () => {
it("converts WALKING_SPEED m/s → km/h (factor 3.6)", () => {
@@ -308,3 +315,85 @@ describe("display-transform — converted values carry the declared precision",
expect(invertDisplayTransform(185.2, imperial)).not.toBe(84);
});
});
describe("display-transform — both unit preferences, one resolver", () => {
it("reads glucose in the preferred clinical unit, matching convertGlucose", () => {
const mmol = getReadingTransform("BLOOD_GLUCOSE", {
system: "metric",
glucoseUnit: "mmol/L",
});
expect(mmol.displayUnit).toBe("mmol/L");
for (const mgdl of [54, 70, 99, 126, 140, 250]) {
expect(applyDisplayTransform(mgdl, mmol)).toBe(
convertGlucose(mgdl, "mmol/L"),
);
}
const mgdl = getReadingTransform("BLOOD_GLUCOSE", DEFAULT_UNIT_PREFERENCES);
expect(mgdl.displayUnit).toBe("mg/dL");
expect(applyDisplayTransform(99, mgdl)).toBe(99);
});
it("leaves glucose alone under the imperial preference", () => {
const t = getReadingTransform("BLOOD_GLUCOSE", {
system: "imperial",
glucoseUnit: "mg/dL",
});
expect(t.displayUnit).toBe("mg/dL");
});
it("delegates every other type to the metric/imperial registry", () => {
expect(
getReadingTransform("WEIGHT", {
system: "imperial",
glucoseUnit: "mmol/L",
}).displayUnit,
).toBe("lb");
expect(
getReadingTransform("PULSE", {
system: "imperial",
glucoseUnit: "mmol/L",
}).displayUnit,
).toBe("bpm");
});
it("marks glucose and the transformed set as unit-sensitive", () => {
expect(isUnitSensitiveType("BLOOD_GLUCOSE")).toBe(true);
expect(isUnitSensitiveType("WEIGHT")).toBe(true);
expect(isUnitSensitiveType("HEART_RATE_VARIABILITY")).toBe(false);
});
it("resolves the stored columns with their defaults", () => {
expect(resolveUnitPreferences({})).toEqual(DEFAULT_UNIT_PREFERENCES);
expect(
resolveUnitPreferences({
unitPreference: "imperial",
glucoseUnit: "mmol/L",
}),
).toEqual({ system: "imperial", glucoseUnit: "mmol/L" });
expect(resolveUnitPreferences({ unitPreference: "bogus" }).system).toBe(
"metric",
);
});
it("converts workout distance, elevation and pace per preference", () => {
const km = getQuantityTransform("distance", "metric");
const mi = getQuantityTransform("distance", "imperial");
expect(applyDisplayTransform(5000, km)).toBe(5);
expect(km.displayUnit).toBe("km");
expect(applyDisplayTransform(5000, mi)).toBe(3.11);
expect(mi.displayUnit).toBe("mi");
expect(
applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")),
).toBe(328.1);
expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m");
// 30 min over 5 km: 360 s/km, 579.4 s/mi.
expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo(
360,
6,
);
expect(paceSecondsPerDistanceUnit(1800, 5000, "imperial")).toBeCloseTo(
579.36,
2,
);
});
});
+131
View File
@@ -61,12 +61,54 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
MGDL_PER_MMOL,
resolveGlucoseUnit,
type GlucoseUnit,
} from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
export type UnitPreference = "metric" | "imperial";
export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric";
/** The stored preference column resolved to a branch; anything unknown is metric. */
export function resolveUnitPreference(
raw: string | null | undefined,
): UnitPreference {
return raw === "imperial" ? "imperial" : DEFAULT_UNIT_PREFERENCE;
}
/**
* Both unit choices a person makes, together. They are separate columns
* because they are separate questions — metric versus imperial is about mass,
* length and temperature, mg/dL versus mmol/L is the clinical convention of
* the country the record lives in — but a surface that renders a reading
* needs both, and taking them as one value is what keeps a server-side text
* producer from honouring one and forgetting the other.
*/
export interface UnitPreferences {
system: UnitPreference;
glucoseUnit: GlucoseUnit;
}
/** Resolve both stored columns at once (each falls back to its default). */
export function resolveUnitPreferences(row: {
unitPreference?: string | null;
glucoseUnit?: string | null;
}): UnitPreferences {
return {
system: resolveUnitPreference(row.unitPreference),
glucoseUnit: resolveGlucoseUnit(row.glucoseUnit),
};
}
/** The defaults: metric, mg/dL. */
export const DEFAULT_UNIT_PREFERENCES: UnitPreferences = {
system: DEFAULT_UNIT_PREFERENCE,
glucoseUnit: "mg/dL",
};
export interface DisplayTransform {
/** Multiplier applied to the raw canonical value for display. */
factor: number;
@@ -156,6 +198,95 @@ const TRANSFORMS: Partial<
},
};
/**
* Blood glucose, stored in mg/dL, read in either clinical unit. Keyed by the
* glucose preference rather than metric/imperial. The mmol/L branch rounds to
* one decimal through `applyDisplayTransform`, which is exactly what
* `mgdlToMmol` does, so a server-side text and a screen built on
* `convertGlucose` print the same figure.
*/
const GLUCOSE_TRANSFORM: Record<GlucoseUnit, DisplayTransform> = {
"mg/dL": { factor: 1, displayUnit: "mg/dL", decimals: 0 },
"mmol/L": { factor: 1 / MGDL_PER_MMOL, displayUnit: "mmol/L", decimals: 1 },
};
/**
* The transform for a reading of `type` under BOTH unit preferences — the
* one resolver a server-side text producer (a status note, a narrative, a
* prompt) takes its numbers and unit symbols from.
*
* The client surfaces keep `getDisplayTransform` through `useUnitDisplay`
* and convert glucose through `convertGlucose` at the few places that render
* it; folding glucose into that path would convert it a second time on the
* screens that already do. Text built on the server has no such history, so
* it starts from the complete answer.
*/
export function getReadingTransform(
type: string,
preferences: UnitPreferences,
): DisplayTransform {
if (type === "BLOOD_GLUCOSE")
return GLUCOSE_TRANSFORM[preferences.glucoseUnit];
return getDisplayTransform(type, preferences.system);
}
/**
* Every measurement type whose displayed number or unit depends on a unit
* preference: the metric/imperial set plus glucose. Text that quoted one of
* these was written in a unit, and goes stale when the preference changes.
*/
export function isUnitSensitiveType(type: string): boolean {
return type === "BLOOD_GLUCOSE" || TRANSFORMED_TYPES.has(type);
}
/**
* Quantities that are not a stored measurement type but are read in a unit
* system all the same: a workout's distance and its elevation gain, both
* stored in metres. Same rules as the type registry — the metric distance
* branch reads kilometres, which is a rescale, so it rounds; the metric
* elevation branch is the identity.
*/
export type QuantityKind = "distance" | "elevation";
// 1 m = 3.28083989501 ft.
const M_TO_FT = 3.28083989501;
const QUANTITY_TRANSFORMS: Record<
QuantityKind,
Record<UnitPreference, DisplayTransform>
> = {
distance: {
metric: { factor: 0.001, displayUnit: "km", decimals: 2 },
imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 },
},
elevation: {
metric: { factor: 1, displayUnit: "m", decimals: 1 },
imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 },
},
};
/** The transform for a metre-valued quantity under a metric/imperial preference. */
export function getQuantityTransform(
kind: QuantityKind,
preference: UnitPreference,
): DisplayTransform {
return QUANTITY_TRANSFORMS[kind][preference];
}
/**
* Seconds per displayed distance unit (per km, or per mile) from a duration
* and a distance in metres. A pace is a rate in the inverse direction of a
* distance, so it divides by the factor instead of multiplying.
*/
export function paceSecondsPerDistanceUnit(
durationSec: number,
meters: number,
preference: UnitPreference,
): number {
const { factor } = QUANTITY_TRANSFORMS.distance[preference];
return durationSec / (meters * factor);
}
/**
* Every type with a registered transform. The structural guard test uses
* this set to assert no display surface hardcodes a unit string / scale
+14 -6
View File
@@ -239,11 +239,17 @@ const scorePillarIdEnum = z.enum(SCORE_PILLAR_IDS);
const pillarValue = z.object({
score: z.number(),
observed: z.object({
value: z.number(),
unit: z.string(),
value: z
.number()
.describe(
"The scored value in its canonical unit (fasting glucose in mg/dL).",
),
unit: z.string().describe("The canonical unit of `value`."),
label: z
.string()
.describe("The complete display value, paired or panel values included."),
.describe(
"The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit).",
),
asOf: z.iso.datetime({ offset: true }),
sources: z.array(z.string()),
}),
@@ -253,9 +259,11 @@ const pillarValue = z.object({
"population-percentile",
"guideline-band",
]),
low: z.number().nullable(),
high: z.number().nullable(),
label: z.string(),
low: z.number().nullable().describe("Lower band edge, canonical unit."),
high: z.number().nullable().describe("Upper band edge, canonical unit."),
label: z
.string()
.describe("The band as display text, in the reader's units."),
source: z.string(),
}),
personalReference: z
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { resolveTargetUnitAdapter } from "../target-unit-display";
import { toCanonicalMgdl } from "@/lib/glucose";
/**
* v1.32.27 — the target/threshold unit adapter.
@@ -18,7 +19,7 @@ const KG_PER_LB = 0.45359237;
describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("leaves a weight threshold bit-for-bit untouched", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric");
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric", "mg/dL");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("kg");
// Not "close to" — exactly the same double, in both directions.
@@ -37,6 +38,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
"BLOOD_PRESSURE_SYS",
"mmHg",
preference,
"mg/dL",
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmHg");
@@ -51,11 +53,14 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("is inert for the keys the edit sheet passes on its non-metric paths", () => {
// A derived card (BMI, mood, medication compliance) has no editable
// threshold and the sheet resolves the adapter with an empty key;
// glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick
// up a second conversion on top.
for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) {
const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial");
// threshold and the sheet resolves the adapter with an empty key.
for (const key of ["", "BMI"]) {
const units = resolveTargetUnitAdapter(
key,
"mmol/L",
"imperial",
"mg/dL",
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmol/L");
expect(units.toDisplay(5.5)).toBe(5.5);
@@ -66,14 +71,15 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("keeps the whole-hundred step for a step-count target", () => {
for (const preference of ["metric", "imperial"] as const) {
expect(
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step,
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference, "mg/dL")
.step,
).toBe(100);
}
});
});
describe("resolveTargetUnitAdapter — imperial weight class", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial");
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial", "mg/dL");
it("announces pounds and a one-decimal step", () => {
expect(units.rescales).toBe(true);
@@ -145,7 +151,12 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => {
it("carries the same treatment across the whole weight class", () => {
for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) {
const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial");
const adapter = resolveTargetUnitAdapter(
metric,
"kg",
"imperial",
"mg/dL",
);
expect(adapter.unit).toBe("lb");
const bounds = adapter.bounds(METRIC_BOUNDS[metric]);
expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
@@ -169,6 +180,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
"BODY_TEMPERATURE",
"celsius",
"imperial",
"mg/dL",
);
it("shifts absolute readings but never a difference", () => {
@@ -208,6 +220,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
"BODY_TEMPERATURE",
"celsius",
"metric",
"mg/dL",
);
expect(metricUnits.rescales).toBe(false);
expect(metricUnits.toDisplay(37)).toBe(37);
@@ -224,3 +237,73 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45);
});
});
describe("resolveTargetUnitAdapter — glucose thresholds follow the glucose unit", () => {
const contexts = [
"BLOOD_GLUCOSE_FASTING",
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
] as const;
it("reads mmol/L for an mmol/L reader, whatever the metric/imperial branch", () => {
for (const metric of contexts) {
for (const preference of ["metric", "imperial"] as const) {
const units = resolveTargetUnitAdapter(
metric,
"mg/dL",
preference,
"mmol/L",
);
expect(units.unit).toBe("mmol/L");
expect(units.rescales).toBe(true);
expect(units.step).toBe(0.1);
// The 70–99 mg/dL fasting default reads 3.9–5.5.
expect(units.toDisplay(70)).toBe(3.9);
expect(units.toDisplay(99)).toBe(5.5);
}
}
});
it("round-trips a typed mmol/L value to whole mg/dL and back", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"metric",
"mmol/L",
);
expect(units.toCanonical(5.5)).toBe(99);
expect(units.toDisplay(units.toCanonical(5.5))).toBe(5.5);
expect(units.toCanonical(3.9)).toBe(toCanonicalMgdl(3.9, "mmol/L"));
});
it("rounds the canonical 40–400 mg/dL guardrail inward", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"metric",
"mmol/L",
);
const bounds = units.bounds(METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING);
expect(bounds).toEqual({ min: 2.3, max: 22.1 });
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.min,
);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.max,
);
});
it("stays the exact identity for an mg/dL reader", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"imperial",
"mg/dL",
);
expect(units.unit).toBe("mg/dL");
expect(units.rescales).toBe(false);
expect(units.toDisplay(99)).toBe(99);
expect(units.toCanonical(99)).toBe(99);
});
});
+46 -1
View File
@@ -48,6 +48,12 @@ import {
transformRescales,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
MGDL_PER_MMOL,
convertGlucose,
toCanonicalMgdl,
type GlucoseUnit,
} from "@/lib/glucose";
/** Canonical decimals a converted threshold value persists with. */
const CANONICAL_DECIMALS = 2;
@@ -119,6 +125,41 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter {
};
}
/**
* The glucose thresholds (`BLOOD_GLUCOSE_FASTING` and its siblings) are
* stored in mg/dL and read in the reader's glucose unit. The settings editor
* used to take the identity path for them, so an mmol/L account was asked
* for mg/dL figures under a label that said nothing, against 40–400 bounds
* and a "default: 70–99" hint — while the insights sheet for the same
* threshold already spoke mmol/L.
*
* mmol/L reads at one decimal; the canonical write rounds to whole mg/dL
* (`toCanonicalMgdl`), the precision every glucose path stores. Guardrails
* round inward exactly like the imperial branch: 40 mg/dL is 2.22 mmol/L,
* shown as 2.3 so that the typed bound inverts to 41 mg/dL and passes.
*/
function glucoseAdapter(glucoseUnit: GlucoseUnit): TargetUnitAdapter {
if (glucoseUnit === "mg/dL") return identityAdapter("BLOOD_GLUCOSE", "mg/dL");
const toMmol = (mgdl: number) => mgdl / MGDL_PER_MMOL;
return {
unit: "mmol/L",
step: 0.1,
rescales: true,
toDisplay: (canonical) => convertGlucose(canonical, "mmol/L"),
toCanonical: (display) => toCanonicalMgdl(display, "mmol/L"),
toDisplayDelta: (canonicalDelta) => roundTo(toMmol(canonicalDelta), 1),
bounds: (canonical) => ({
min: ceilTo(toMmol(canonical.min), 1),
max: floorTo(toMmol(canonical.max), 1),
}),
};
}
/** True for a glucose threshold metric or target-card type. */
export function isGlucoseTargetMetric(metric: string): boolean {
return metric === "BLOOD_GLUCOSE" || metric.startsWith("BLOOD_GLUCOSE_");
}
/**
* Resolve the unit adapter for one threshold metric / target-card type.
*
@@ -128,13 +169,17 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter {
* on, so a metric participates exactly when it has a registered
* transform. `canonicalUnit` is the unit the surface announced before
* this release (`METRIC_BOUNDS[metric].unit` or the targets payload's
* `unit`) and is what an untransformed metric keeps.
* `unit`) and is what an untransformed metric keeps. `glucoseUnit` is
* the reader's glucose preference; it decides the glucose thresholds and
* nothing else.
*/
export function resolveTargetUnitAdapter(
metric: string,
canonicalUnit: string,
preference: UnitPreference,
glucoseUnit: GlucoseUnit,
): TargetUnitAdapter {
if (isGlucoseTargetMetric(metric)) return glucoseAdapter(glucoseUnit);
if (!hasDisplayTransform(metric))
return identityAdapter(metric, canonicalUnit);