mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1397-derive
This commit is contained in:
commit
da8cc7a6ee
74 files changed
+2304
-292
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user