Merge origin/main into fix/v1397-tz

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:39:26 +02:00
commit 27ac4c1d63
143 files changed
+6471 -858

No files matched your search

+23 -10
View File
@@ -41066,9 +41066,9 @@ components:
properties:
id:
type: string
description: Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse` /
`glucose` beyond 90 days); `hour:<ISO start>` for an hour bucket (`pulse` / `glucose` within 90 days
when the window holds more than 10 000 readings).
description: Measurement row id; `sleep:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse`,
`glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:<ISO start>` for an hour
bucket (the same kinds within 90 days when the window holds more than 10 000 readings).
at:
type: string
format: date-time
@@ -41141,12 +41141,13 @@ components:
- stats
additionalProperties: false
description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an
explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and
`glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window
holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff
or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range
as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats`
is always computed over the underlying readings, never over the buckets."
explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`,
`heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within
90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or
ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries
the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its
range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading.
`stats` is always computed over the underlying readings, never over the buckets."
ListMeasurementsResponse:
type: object
properties:
@@ -41339,6 +41340,12 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
windowFrom:
description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant
the read started from. The window is a year ending at `to` (or now); page further back by moving `to`."
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- total
- limit
@@ -55784,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
@@ -55817,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:
+25 -1
View File
@@ -61,6 +61,8 @@ interface DialogCase {
prepare?: (page: import("@playwright/test").Page) => Promise<void>;
/** Something inside the dialog that proves it rendered its form, not a shell. */
ready: string;
/** Render in this locale instead of the suite's pinned English. */
locale?: "de";
}
const DIALOGS: DialogCase[] = [
@@ -92,11 +94,23 @@ const DIALOGS: DialogCase[] = [
trigger: /^add$/i,
ready: '[data-slot="date-time-field"]',
},
// The labs sheet's footer holds three buttons (Cancel, Save & add another,
// Save). They fit a phone row in English; the German labels are ~100px
// wider and pushed Cancel off the sheet's left edge, at every width.
{
name: "labs add (German)",
path: "/labs",
trigger: /^hinzufügen$/i,
ready: '[data-slot="date-time-field"]',
locale: "de",
},
];
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
{ label: "phone 390", width: 390, height: 844 },
// The narrowest common Android width.
{ label: "phone 360", width: 360, height: 780 },
];
/**
@@ -200,7 +214,17 @@ test.describe("add/edit dialogs do not scroll sideways", () => {
for (const dialog of DIALOGS) {
test(`${dialog.name} fits its surface at ${vp.label}`, async ({
page,
}) => {
context,
}, testInfo) => {
if (dialog.locale) {
await context.addCookies([
{
name: "healthlog-locale",
value: dialog.locale,
url: testInfo.project.use.baseURL ?? "http://localhost:3000",
},
]);
}
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(dialog.path, { waitUntil: "domcontentloaded" });
+311
View File
@@ -0,0 +1,311 @@
import { readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* Every signed-in route, at an iPhone width, does not scroll sideways.
*
* `mobile-horizontal-overflow.spec.ts` sweeps five widths over four
* content-heavy routes. This file is the breadth half: one width, every route.
* It exists because the lab list panned sideways on a phone for weeks while
* the guard was green — `/labs` was simply not on the list. A hand-kept list
* goes stale the day a route is added, so the routes here are read from
* `src/app` itself, and a route this file cannot open fails the run instead of
* dropping out silently:
*
* - a static route is visited as is;
* - a dynamic route needs an entry in `DYNAMIC_ROUTES` that turns its
* segments into concrete paths (a settings slug, a seeded record's id);
* - a route that cannot be swept signed in (auth, invitation, public
* share-link pages) is listed in `NOT_SIGNED_IN` with its reason.
*
* The account is `E2E_MOBILE_ROUTES`, which `globalSetup` fills with one or
* two of everything (`mobile-routes-fixture.ts`): an empty page fits any
* viewport and would prove nothing. It renders in German, the locale with the
* longest common strings, and at 390 px — the iPhone width the lab list broke
* at.
*/
const APP_DIR = join(process.cwd(), "src", "app");
const WIDTH = 390;
/** Route patterns that are not signed-in surfaces, and why. */
const NOT_SIGNED_IN: Record<string, string> = {
"/auth/login": "the sign-in page; a session redirects away from it",
"/auth/register": "registration; a session redirects away from it",
"/invite/[token]":
"an invitation landing page for somebody without an account",
"/c/[token]": "the clinician share-link page, opened without a session",
"/confirm-access": "the re-proof page, reached only from a stale session",
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
"/onboarding/[step]":
"the first-run setup flow; covered by setup-flow-*.spec.ts",
};
type Resolver = (api: APIRequestContext) => Promise<string[]>;
async function readData<T>(api: APIRequestContext, path: string): Promise<T> {
const res = await api.get(path);
expect(res.ok(), `${path} answered ${res.status()}`).toBe(true);
return ((await res.json()) as { data: T }).data;
}
const firstId = (ids: Array<string | null | undefined>, what: string) => {
const id = ids.find((value): value is string => Boolean(value));
if (!id)
throw new Error(`the fixture holds no ${what} — globalSetup seeds one`);
return id;
};
/** Every dynamic route pattern under `src/app`, turned into real paths. */
const DYNAMIC_ROUTES: Record<string, Resolver> = {
"/settings/[section]": async () =>
SETTINGS_SECTION_SLUGS.map((slug) => `/settings/${slug}`),
"/admin/[section]": async () =>
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
// here fails on the status check below rather than passing quietly.
"/settings/layout/[module]": async () =>
[
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"vorsorge",
].map((group) => `/settings/layout/${group}`),
"/insights/scores/[metric]": async () =>
["sleep", "readiness", "recovery", "stress", "strain"].map(
(metric) => `/insights/scores/${metric}`,
),
"/insights/values/[type]": async () => [
"/insights/values/WEIGHT",
"/insights/values/BLOOD_PRESSURE_SYS",
],
"/labs/[biomarkerId]": async (api) => {
const data = await readData<{
results: Array<{ biomarkerId: string | null; analyte: string }>;
}>(api, "/api/labs?limit=50");
const id = firstId(
data.results
.filter((row) => row.analyte === MOBILE_ROUTES_ANALYTE)
.map((row) => row.biomarkerId),
"linked lab reading",
);
return [`/labs/${id}`];
},
"/labs/[biomarkerId]/values": async (api) =>
(await DYNAMIC_ROUTES["/labs/[biomarkerId]"](api)).map(
(path) => `${path}/values`,
),
"/medications/[id]": async (api) => {
const meds = await readData<Array<{ id: string }>>(api, "/api/medications");
return [
`/medications/${firstId(
meds.map((m) => m.id),
"medication",
)}`,
];
},
"/medications/[id]/history": async (api) =>
(await DYNAMIC_ROUTES["/medications/[id]"](api)).map(
(path) => `${path}/history`,
),
"/illness/[id]": async (api) => {
const episodes = await readData<Array<{ id: string }>>(
api,
"/api/illness/episodes",
);
return [
`/illness/${firstId(
episodes.map((e) => e.id),
"illness episode",
)}`,
];
},
"/custom-metrics/[id]": async (api) => {
const data = await readData<{ customMetrics: Array<{ id: string }> }>(
api,
"/api/custom-metrics",
);
const id = firstId(
data.customMetrics.map((m) => m.id),
"custom metric",
);
return [`/custom-metrics/${id}`];
},
"/custom-metrics/[id]/values": async (api) =>
(await DYNAMIC_ROUTES["/custom-metrics/[id]"](api)).map(
(path) => `${path}/values`,
),
"/insights/workouts/[id]": async (api) => {
const data = await readData<{ workouts: Array<{ id: string }> }>(
api,
"/api/workouts?limit=1",
);
return [
`/insights/workouts/${firstId(
data.workouts.map((w) => w.id),
"workout",
)}`,
];
},
// An ECG strip only arrives through a device sync; no route writes one. The
// detail page is swept in its not-found state, which is still the frame,
// header and back link a phone renders.
"/insights/ecg/[id]": async () => ["/insights/ecg/unknown-recording"],
};
/** Every `page.tsx` under `src/app`, as a route pattern (`/labs/[biomarkerId]`). */
function discoverRoutePatterns(dir = APP_DIR): string[] {
const patterns: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "api" || entry.startsWith("_") || entry === "__tests__")
continue;
patterns.push(...discoverRoutePatterns(full));
} else if (entry === "page.tsx") {
const rel = relative(APP_DIR, dir).split(sep).join("/");
// Route groups `(name)` do not appear in the URL.
const path = rel
.split("/")
.filter((segment) => segment && !/^\(.*\)$/.test(segment))
.join("/");
patterns.push(`/${path}`);
}
}
return patterns.sort();
}
const PATTERNS = discoverRoutePatterns();
const isDynamic = (pattern: string) => pattern.includes("[");
test.describe("every signed-in route fits a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
/**
* The bookkeeping the sweep depends on. A new dynamic route without a
* resolver, a resolver for a route that no longer exists, or an exclusion
* for a route that is gone each fail here by name.
*/
test("the swept routes are the routes the app has", () => {
expect(PATTERNS.length, "no page.tsx found under src/app").toBeGreaterThan(
50,
);
const unresolved = PATTERNS.filter(
(p) => isDynamic(p) && !NOT_SIGNED_IN[p] && !DYNAMIC_ROUTES[p],
);
expect(
unresolved,
"dynamic route(s) with no entry in DYNAMIC_ROUTES or NOT_SIGNED_IN",
).toEqual([]);
const stale = [
...Object.keys(DYNAMIC_ROUTES),
...Object.keys(NOT_SIGNED_IN),
].filter((p) => !PATTERNS.includes(p));
expect(stale, "entries for routes that no longer exist").toEqual([]);
});
for (const pattern of PATTERNS) {
if (NOT_SIGNED_IN[pattern]) continue;
test(`${pattern} does not scroll sideways at ${WIDTH}px`, async ({
page,
}) => {
test.slow();
const paths = isDynamic(pattern)
? await DYNAMIC_ROUTES[pattern](page.request)
: [pattern];
expect(paths.length, `${pattern} resolved to no path`).toBeGreaterThan(0);
await page.setViewportSize({ width: WIDTH, height: 844 });
for (const path of paths) {
const response = await page.goto(path, {
waitUntil: "domcontentloaded",
});
expect(
response?.status() ?? 0,
`${path} answered ${response?.status()}`,
).toBeLessThan(400);
// A redirect away (a module switched off, a missing record) would
// measure some other page and call it this one.
expect(new URL(page.url()).pathname, `${path} redirected`).toBe(
new URL(path, page.url()).pathname,
);
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${path} @${WIDTH}px`);
}
});
}
});
/**
* The lab surfaces, asserted at the elements that broke rather than only at
* the page width. The list's name column collapsed to nothing on a phone
* while the fixed columns beside it pushed the row off the card; the values
* table put its edit and delete buttons in a sideways-scrolling strip.
*/
test.describe("the lab surfaces stay readable on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
await page.setViewportSize({ width: WIDTH, height: 844 });
});
test("the lab list shows each marker's name", async ({ page }) => {
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const names = page.locator('[data-slot="lab-list-analyte"]');
await expect(names.first()).toBeVisible();
const widths = await names.evaluateAll((nodes) =>
nodes.map((node) => Math.round(node.getBoundingClientRect().width)),
);
expect(widths.length).toBeGreaterThan(1);
// A readable name, not the 0 px column the fixed columns left it.
for (const width of widths) expect(width).toBeGreaterThan(60);
});
test("the values list keeps its actions on screen", async ({ page }) => {
const [path] = await DYNAMIC_ROUTES["/labs/[biomarkerId]/values"](
page.request,
);
await page.goto(path, { waitUntil: "domcontentloaded" });
const list = page.locator('[data-slot="lab-history-mobile-list"]');
await expect(list).toBeVisible();
await expect(page.locator('[data-slot="lab-history-table"]')).toBeHidden();
const buttons = list.locator("button");
expect(await buttons.count()).toBeGreaterThan(1);
const outside = await buttons.evaluateAll(
(nodes) =>
nodes
.map((node) => node.getBoundingClientRect())
.filter((r) => r.left < 0 || r.right > window.innerWidth + 1).length,
);
expect(outside, "a reading's action sits off-screen").toBe(0);
});
});
+65
View File
@@ -33,6 +33,8 @@ import {
} from "@playwright/test";
import pg from "pg";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
// Resolve relative to the project root rather than __dirname/import.meta.url —
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
// loading either dirname helper at module scope crashes the test runner.
@@ -191,6 +193,27 @@ export const MODULES_STORAGE_STATE_PATH = resolve(
"e2e/setup/storageStateModules.json",
);
/**
* The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`).
*
* It holds one or two of everything a route can render
* (`mobile-routes-fixture.ts`), because the sweep is only worth something
* against populated pages, and writing that much into the shared account would
* change what every other spec reads. An administrator, so the admin console
* is swept too; `FEMALE`, so the cycle surfaces render without a switch.
*/
export const E2E_MOBILE_ROUTES = {
email: "e2e-mobile-routes@healthlog.test",
username: "e2e-mobile-routes",
password: "Wd7!Kp3mTz9qRv2N",
role: "ADMIN",
} as const;
export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateMobileRoutes.json",
);
/**
* The AI-optional journeys' own account (`ai-optional-*.spec.ts`).
*
@@ -823,6 +846,43 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The phone-width route sweep's account. Every opt-in module is on, so
// the sweep reaches the glucose, documents, wellbeing, nutrients and
// environment surfaces; its record is written after the login capture.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
height_cm, date_of_birth, gender,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
$8::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_MOBILE_ROUTES.username,
E2E_MOBILE_ROUTES.email,
await hashPassword(E2E_MOBILE_ROUTES.password),
E2E_MOBILE_ROUTES.role,
now,
new Date("1988-04-12T00:00:00.000Z"),
JSON.stringify({
glucose: true,
environment: true,
inboundDocuments: true,
mentalHealth: true,
nutrients: true,
}),
],
);
// The AI-optional journeys' account. Its provider, stored briefing,
// consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts`
// before each journey.
@@ -1441,6 +1501,11 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The module-surfaces journey's jar — see `E2E_MODULES`.
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
// The phone-width route sweep's jar, then its record — see
// `E2E_MOBILE_ROUTES`.
await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH);
await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH);
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
+204
View File
@@ -0,0 +1,204 @@
/**
* The record `mobile-route-overflow.spec.ts` measures.
*
* A phone-width overflow guard that visits an empty page proves nothing: an
* empty list fits any viewport, and the lab list that panned sideways on a
* phone only did so once it held a reading. So the account gets one or two of
* everything a signed-in route can render — readings with reference ranges, a
* medication, a mood entry, a visit, a vaccination, an illness episode, a
* custom metric, a workout, a document — and every dynamic route has a real id
* to open.
*
* Written through the app's own routes, once, from `globalSetup`, so the
* parallel workers of the spec never race each other to seed it. An account
* that already holds a lab reading is taken as seeded, which keeps a local
* re-run against a reused database from doubling every row.
*/
import { request as playwrightRequest } from "@playwright/test";
/** Long on purpose: the names are what a narrow column has to give way to. */
export const MOBILE_ROUTES_ANALYTE = "Glomeruläre Filtrationsrate (eGFR)";
export async function seedMobileRoutesRecord(
baseURL: string,
storageStatePath: string,
): Promise<void> {
const ctx = await playwrightRequest.newContext({
baseURL,
storageState: storageStatePath,
});
try {
// The document is the last thing written, so its presence means a
// complete record. A run that died half-way is finished by the next one:
// a row it already wrote answers 409 and is skipped.
const existing = await ctx.get("/api/documents/inbound");
const existingBody = (await existing.json()) as {
data: { documents?: unknown[] } | unknown[] | null;
};
const documents = Array.isArray(existingBody.data)
? existingBody.data
: (existingBody.data?.documents ?? []);
if (documents.length > 0) return;
const post = async (path: string, data: unknown): Promise<unknown> => {
const res = await ctx.post(path, { data });
if (res.status() === 409) return null;
if (!res.ok()) {
throw new Error(
`[mobile-routes-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
);
}
return ((await res.json()) as { data: unknown }).data;
};
const daysAgo = (days: number, hour = 8) => {
const at = new Date(Date.now() - days * 86_400_000);
at.setUTCHours(hour, 0, 0, 0);
return at.toISOString();
};
for (let day = 1; day <= 6; day += 1) {
await post("/api/measurements", {
type: "WEIGHT",
value: 80 + day / 10,
measuredAt: daysAgo(day, 7),
});
await post("/api/measurements", {
type: "BLOOD_PRESSURE_SYS",
value: 120 + day,
measuredAt: daysAgo(day, 7),
});
await post("/api/measurements", {
type: "BLOOD_PRESSURE_DIA",
value: 78 + (day % 3),
measuredAt: daysAgo(day, 7),
});
await post("/api/measurements", {
type: "PULSE",
value: 60 + day,
measuredAt: daysAgo(day, 7),
});
await post("/api/measurements", {
type: "BLOOD_GLUCOSE",
value: 92 + day,
glucoseContext: "FASTING",
measuredAt: daysAgo(day, 6),
});
await post("/api/mood-entries", {
mood: day % 2 ? "GUT" : "OKAY",
moodLoggedAt: daysAgo(day, 20),
});
}
// Two readings of one marker, so the list row has a trend line and a
// range bar, plus a qualitative one with neither.
for (const [days, value] of [
[40, 78],
[5, 84],
] as const) {
await post("/api/labs", {
analyte: MOBILE_ROUTES_ANALYTE,
panel: "Nierenwerte und Elektrolyte",
value,
unit: "mL/min/1.73m²",
referenceLow: 60,
referenceHigh: 120,
takenAt: daysAgo(days),
});
}
await post("/api/labs", {
analyte: "Hepatitis-B-Oberflächenantigen",
valueText: "negativ",
takenAt: daysAgo(5),
});
await post("/api/medications", {
name: "Ramipril",
dose: "5 mg",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
});
const practitioner = (await post("/api/practitioners", {
name: "Gemeinschaftspraxis für Allgemeinmedizin",
specialty: "Allgemeinmedizin und Innere Medizin",
})) as { id: string } | null;
await post("/api/encounters", {
occurredAt: daysAgo(20),
practitionerId: practitioner?.id ?? null,
reason: "Routinekontrolle Blutdruck und Blutbild",
});
await post("/api/vaccinations", {
occurredAt: daysAgo(300),
antigenSlug: "tetanus",
doseNumber: 1,
lotNumber: "AB1234-XY",
practitionerId: practitioner?.id ?? null,
});
await post("/api/illness/episodes", {
label: "Erkältung mit Husten",
type: "INFECTION",
onsetAt: daysAgo(12),
resolvedAt: daysAgo(6),
});
const metric = (await post("/api/custom-metrics", {
name: "Augeninnendruck rechts",
unit: "mmHg",
targetLow: 10,
targetHigh: 21,
decimals: 0,
})) as { id: string } | null;
const metricId =
metric?.id ??
(
(await (await ctx.get("/api/custom-metrics")).json()) as {
data: { customMetrics: Array<{ id: string }> };
}
).data.customMetrics[0]?.id;
await post(`/api/custom-metrics/${metricId}/entries`, {
value: 16,
measuredAt: daysAgo(3),
});
await post("/api/workouts/batch", {
workouts: [
{
sportType: "running",
startedAt: daysAgo(2, 6),
endedAt: new Date(
new Date(daysAgo(2, 6)).getTime() + 45 * 60_000,
).toISOString(),
source: "APPLE_HEALTH",
externalId: "e2e-mobile-routes-run",
activeEnergyKcal: 420,
distanceM: 6500,
},
],
});
const pdf =
"%PDF-1.4\n%e2e-mobile-routes\n" +
"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n" +
"xref\n0 4\ntrailer<</Size 4/Root 1 0 R>>\n%%EOF\n";
const doc = await ctx.post("/api/documents/inbound", {
multipart: {
file: {
name: "laborbefund-hausarzt-mit-langem-dateinamen.pdf",
mimeType: "application/pdf",
buffer: Buffer.from(pdf),
},
title: "Laborbefund Hausarzt mit einem sehr langen Titel",
kind: "LAB_RESULT",
},
});
if (!doc.ok()) {
throw new Error(
`[mobile-routes-fixture] /api/documents/inbound answered ${doc.status()}`,
);
}
} finally {
await ctx.dispose();
}
}
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,7 @@
"severity": "Stärke {severity}",
"noSymptoms": "Keine Symptome erfasst.",
"impact": "Befinden: {impact}",
"fever": "Temperatur: {value} °C",
"fever": "Temperatur: {value} {unit}",
"empty": "Noch keine Tage erfasst."
},
"loadError": "Diese Episode konnte nicht geladen werden.",
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,7 @@
"severity": "Severity {severity}",
"noSymptoms": "No symptoms logged.",
"impact": "How you functioned: {impact}",
"fever": "Temperature: {value} °C",
"fever": "Temperature: {value} {unit}",
"empty": "No days logged yet."
},
"loadError": "Could not load this episode.",
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,7 @@
"severity": "Intensidad {severity}",
"noSymptoms": "Sin síntomas registrados.",
"impact": "Cómo funcionaste: {impact}",
"fever": "Temperatura: {value} °C",
"fever": "Temperatura: {value} {unit}",
"empty": "Todavía no hay días registrados."
},
"loadError": "No se pudo cargar este episodio.",
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,7 @@
"severity": "Intensité {severity}",
"noSymptoms": "Aucun symptôme enregistré.",
"impact": "Votre forme : {impact}",
"fever": "Température : {value} °C",
"fever": "Température : {value} {unit}",
"empty": "Aucun jour enregistré pour l’instant."
},
"loadError": "Impossible de charger cet épisode.",
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,7 @@
"severity": "Intensità {severity}",
"noSymptoms": "Nessun sintomo registrato.",
"impact": "Come ti sei sentito: {impact}",
"fever": "Temperatura: {value} °C",
"fever": "Temperatura: {value} {unit}",
"empty": "Nessun giorno registrato finora."
},
"loadError": "Impossibile caricare questo episodio.",
+2 -1
View File
@@ -8167,6 +8167,7 @@
"medications": "복약 및 복약 순응도",
"bmi": "체질량지수(BMI)",
"statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})",
"statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})",
"adherence": "복약 순응도 {rate}",
"noAdherence": "—",
"glucose": {
@@ -9404,7 +9405,7 @@
"severity": "심각도 {severity}",
"noSymptoms": "기록한 증상이 없어요.",
"impact": "활동 정도: {impact}",
"fever": "체온: {value}°C",
"fever": "체온: {value}{unit}",
"empty": "아직 기록한 날이 없어요."
},
"loadError": "이 에피소드를 불러오지 못했어요.",
+2 -1
View File
@@ -8167,6 +8167,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": {
@@ -9404,7 +9405,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.",
@@ -1,6 +1,5 @@
/**
* Structural guard: background and insight readers must not materialise a
* dense measurement stream.
* Structural guard: no reader materialises a dense measurement stream.
*
* #1023: a single account syncing heart rate from a watch took the worker
* down within half an hour. The status-card read was bounded in time
@@ -10,17 +9,34 @@
* SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks
* the number of days, not the sampling rate.
*
* What this guard pins, in the directories that run on the worker or feed an
* insight: every `measurement.findMany` without a `take` reads a type that is
* named as a literal and is not one a device streams. A read whose type is a
* variable could be handed a dense type by the next caller, so it must be
* listed below with the reason it is safe. The list is frozen both ways: a new
* unlisted read fails, and an entry whose read is gone fails too.
* The same class kept turning up outside the directories this guard first
* walked: the targets page read a year of glucose and a month of heart rate,
* the cycle insights read a year of every outcome channel, the per-kind series
* read heart-rate variability and blood oxygen raw for ten years, all from
* `app/api` or a helper beside it. So the guard now covers the request
* handlers, import, export, the targets and the doctor report as well, and
* the raw SQL reads of `measurements` anywhere in the tree.
*
* It is a tripwire, not a proof: the matcher reads source text, and a read
* written some other way (a raw SELECT of rows, a helper in another
* directory) passes it. The floor below fails the guard if the matcher stops
* finding reads at all.
* Two checks.
*
* 1. `measurement.findMany` without a `take`, in the scoped directories,
* must be bounded by its `where`: a type named as a literal (or a list of
* literals) none of which a device streams, or a key list (`id`,
* `externalId` or `measuredAt` `in: [...]`, or an exact `measuredAt`).
* Anything else is listed below with the reason it stays bounded.
*
* 2. A raw `$queryRaw` / `$queryRawUnsafe` whose statement reads from
* `measurements` must fold: `GROUP BY`, `DISTINCT`, `LIMIT`, or a select
* list of aggregates only. A statement that returns rows is listed below
* with its reason. A statement held in a variable is invisible to the
* matcher; that is the check's known blind spot.
*
* Both lists are frozen both ways: a new unlisted read fails, and an entry
* whose read is gone fails too. The floors below fail the guard if a matcher
* stops finding reads at all, and the "plants" cases run each matcher over a
* planted offender, which is the mutation proof kept in the suite: the
* planted findMany of `BLOOD_GLUCOSE` over a year and the planted raw SELECT of
* every pulse row are both caught. It is a tripwire, not a proof.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -42,6 +58,12 @@ const SCOPED_DIRS = [
"lib/analytics/",
"lib/dashboard/",
"lib/daily/",
// Widened in v1.39.7: the request handlers and the helpers they lean on.
"app/api/",
"lib/import",
"lib/export",
"lib/targets/",
"lib/doctor-report",
];
/** Types a device writes at sampling rate rather than a few times a day. */
@@ -55,8 +77,8 @@ const DENSE_TYPES: ReadonlySet<string> = new Set([
]);
/**
* Untaken reads whose type is not a sparse literal, keyed
* `<file>::<type expression>`, with why each stays bounded.
* Untaken reads whose `where` neither names sparse literal types nor a key
* list, keyed `<file>::<type expression>`, with why each stays bounded.
*/
const ALLOWED: Record<string, string> = {
"lib/insights/comprehensive-generate.ts::(shorthand)":
@@ -69,8 +91,6 @@ const ALLOWED: Record<string, string> = {
"A constant for six-minute-walk distance, written at most daily.",
"lib/insights/derived/wellness-scores.ts::measurementType":
"Daily computed / provider scores (recovery, strain, readiness), one row per day.",
"lib/jobs/reaction-line.ts::{ in: [...types]":
"Exact-instant match (`measuredAt: row.occurredAt`), not a window.",
"lib/jobs/step-consolidation-repair.ts::STEP_TYPE":
"Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.",
'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never':
@@ -79,6 +99,34 @@ const ALLOWED: Record<string, string> = {
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
"lib/analytics/score/reader.ts::{ in: types":
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
"app/api/measurements/series/route.ts::(shorthand)":
"The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.",
"app/api/measurements/batch/route.ts::p.row.type as MeasurementType":
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
"app/api/measurements/route.ts::(shorthand)":
"Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.",
"lib/targets/build-response.ts::{ in: recentTypes":
"Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.",
"lib/doctor-report/collect.ts::{ notIn: rawExcluded":
"The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.",
};
/**
* Raw statements that read rows of `measurements` without folding them, keyed
* `<file>::<ordinal among this file's unfolded statements>`, with why each
* stays bounded.
*/
const ALLOWED_SQL: Record<string, string> = {
"lib/analytics/bp-in-target-fast-path.ts::1":
"Canonical-source blood pressure, one type over 365 days: a cuff, a few readings a day. The in-target pairing needs the individual readings.",
"lib/insights/comprehensive-aggregator.ts::1":
"Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.",
"lib/measurements/reconcile-external-measurement.ts::1":
"Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.",
"lib/rollups/measurement-coverage.ts::1":
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
};
interface UntakenRead {
@@ -87,63 +135,199 @@ interface UntakenRead {
typeExpr: string;
}
function scopedFiles(): string[] {
function lineOf(src: string, index: number): number {
return src.slice(0, index).split("\n").length;
}
/** Index just past the bracket that closes the one at `open`. */
function closeOf(src: string, open: number): number {
const pair: Record<string, string> = { "(": ")", "{": "}", "[": "]" };
const want = pair[src[open]];
let depth = 1;
let i = open + 1;
while (depth > 0 && i < src.length) {
if (src[i] === src[open]) depth += 1;
else if (src[i] === want) depth -= 1;
i += 1;
}
return i;
}
/** The `where: { … }` object of a call, or null when `where` is a variable. */
function whereObject(call: string): string | null {
const m = /\bwhere\s*:\s*\{/.exec(call);
if (!m) return null;
const open = m.index + m[0].length - 1;
return call.slice(open, closeOf(call, open));
}
/** The type expression a `where` object constrains on. */
function typeExprOf(call: string): string {
const where = whereObject(call);
if (where === null) {
return /\bwhere\s*[,}]/.test(call) ? "(where variable)" : "(none)";
}
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(where);
if (typed) return typed[1].trim();
return /\btype\s*[,}\n]/.test(where) ? "(shorthand)" : "(none)";
}
/** A `where` that pins rows by key rather than by a window. */
function keyBounded(call: string): boolean {
const where = whereObject(call) ?? "";
return (
/\b(?:id|externalId|measuredAt)\s*:\s*\{\s*in\s*:/.test(where) ||
/\bmeasuredAt\s*:\s*(?![\s{])/.test(where)
);
}
function sparseLiteral(typeExpr: string): boolean {
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
if (lit) return !DENSE_TYPES.has(lit[1]);
const list = /^\{\s*in\s*:\s*\[([^\]]*)/.exec(typeExpr);
if (!list) return false;
const items = list[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return (
items.length > 0 &&
items.every((item) => {
const m = /^"([A-Z_0-9]+)"$/.exec(item);
return m !== null && !DENSE_TYPES.has(m[1]);
})
);
}
/** Every untaken `measurement.findMany` in `src`, with its type expression. */
function untakenFindManyReads(
file: string,
src: string,
): { all: number; untaken: UntakenRead[] } {
let all = 0;
const untaken: UntakenRead[] = [];
// Whitespace-tolerant: a call split across lines still matches.
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
for (let m = re.exec(src); m; m = re.exec(src)) {
all += 1;
const call = src.slice(m.index, closeOf(src, m.index + m[0].length - 1));
if (/\btake\b/.test(call)) continue;
if (keyBounded(call)) continue;
// A type expression can itself contain a full bracket (`{ in: [...] }`);
// the regex above stops at the first `,` / `}` / newline, which is the
// stable key the allowlist uses.
untaken.push({
file,
line: lineOf(src, m.index),
typeExpr: typeExprOf(call),
});
}
return { all, untaken };
}
/** The statement text of the raw call starting at `at`, or null if opaque. */
function statementAt(src: string, at: number): string | null {
let i = at;
// Skip a type argument: `$queryRaw<Array<{ … }>>`.
if (src[i] === "<") {
let depth = 1;
i += 1;
while (depth > 0 && i < src.length) {
if (src[i] === "<") depth += 1;
else if (src[i] === ">") depth -= 1;
i += 1;
}
}
while (/\s/.test(src[i] ?? "")) i += 1;
if (src[i] === "`") {
// Tagged template: up to the matching backtick, skipping `${ … }`.
let j = i + 1;
while (j < src.length && src[j] !== "`") {
if (src[j] === "$" && src[j + 1] === "{") j = closeOf(src, j + 1);
else j += 1;
}
return src.slice(i, j + 1);
}
if (src[i] === "(") return src.slice(i, closeOf(src, i));
return null;
}
const READS_MEASUREMENTS =
/\bFROM\s+"?measurements"?(?![_\w])|canonicalMeasurementsFrom\s*\(/i;
const FOLDS =
/GROUP\s+BY|\bDISTINCT\b|\bLIMIT\b|^[\s`(]*(?:Prisma\.sql`)?\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX|STDDEV_POP)\s*\(/i;
interface RawRead {
file: string;
line: number;
folds: boolean;
}
/** Every raw statement in `src` that reads from `measurements`. */
function rawMeasurementReads(file: string, src: string): RawRead[] {
const out: RawRead[] = [];
const re = /\.\$queryRaw(?:Unsafe)?\b/g;
for (let m = re.exec(src); m; m = re.exec(src)) {
const statement = statementAt(src, m.index + m[0].length);
if (statement === null || !READS_MEASUREMENTS.test(statement)) continue;
out.push({
file,
line: lineOf(src, m.index),
folds: FOLDS.test(statement),
});
}
return out;
}
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d)))
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function untakenReads(): { all: number; untaken: UntakenRead[] } {
function scan() {
let all = 0;
const untaken: UntakenRead[] = [];
for (const file of scopedFiles()) {
const raw: RawRead[] = [];
for (const file of sourceFiles()) {
const src = readFileSync(join(SRC, file), "utf8");
// Whitespace-tolerant: a call split across lines still matches.
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
for (let m = re.exec(src); m; m = re.exec(src)) {
all += 1;
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < src.length) {
if (src[i] === "(") depth += 1;
else if (src[i] === ")") depth -= 1;
i += 1;
}
const call = src.slice(m.index, i);
if (/\btake\b/.test(call)) continue;
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call);
const typeExpr = typed
? typed[1].trim()
: /\btype\s*,/.test(call)
? "(shorthand)"
: "(none)";
untaken.push({
file,
line: src.slice(0, m.index).split("\n").length,
typeExpr,
});
}
raw.push(...rawMeasurementReads(file, src));
if (!SCOPED_DIRS.some((d) => file.startsWith(d))) continue;
const reads = untakenFindManyReads(file, src);
all += reads.all;
untaken.push(...reads.untaken);
}
return { all, untaken };
return { all, untaken, raw };
}
function sparseLiteral(typeExpr: string): boolean {
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
return lit !== null && !DENSE_TYPES.has(lit[1]);
/** `<file>::<n>` for the n-th unfolded statement of each file. */
function unfoldedKeys(raw: readonly RawRead[]): Map<string, RawRead> {
const keys = new Map<string, RawRead>();
const perFile = new Map<string, number>();
for (const read of raw) {
if (read.folds) continue;
const n = (perFile.get(read.file) ?? 0) + 1;
perFile.set(read.file, n);
keys.set(`${read.file}::${n}`, read);
}
return keys;
}
describe("dense measurement reads stay bounded (#1023)", () => {
const { all, untaken } = untakenReads();
const { all, untaken, raw } = scan();
it("finds the reads it is meant to police", () => {
// Guard against a matcher that silently matches nothing.
expect(all).toBeGreaterThanOrEqual(40);
expect(untaken.length).toBeGreaterThanOrEqual(15);
// Pinned below the counts on 2026-10-01 (82 / 45 / 37 / 33).
expect(all).toBeGreaterThanOrEqual(75);
expect(untaken.length).toBeGreaterThanOrEqual(40);
expect(raw.length).toBeGreaterThanOrEqual(33);
expect(raw.filter((r) => r.folds).length).toBeGreaterThanOrEqual(30);
});
it("every untaken read names a sparse literal type or is listed with its reason", () => {
it("every untaken read names sparse literal types, a key list, or is listed with its reason", () => {
const offenders = untaken
.filter((r) => !sparseLiteral(r.typeExpr))
.filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED))
@@ -156,20 +340,78 @@ describe("dense measurement reads stay bounded (#1023)", () => {
expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]);
});
it("every raw read of measurements folds or is listed with its reason", () => {
const keys = unfoldedKeys(raw);
const offenders = [...keys]
.filter(([key]) => !(key in ALLOWED_SQL))
.map(([key, read]) => `${key} (line ${read.line})`);
expect(offenders).toEqual([]);
expect(Object.keys(ALLOWED_SQL).filter((k) => !keys.has(k))).toEqual([]);
});
it("the readers fixed for #1023 fold in SQL rather than reading rows", () => {
for (const file of [
"lib/insights/graded-series.ts",
"lib/rollups/tiered-context.ts",
"lib/jobs/coach-plan-review.ts",
"lib/measurements/daily-series-read.ts",
"lib/insights/derived/baseline.ts",
"lib/targets/glucose-read.ts",
]) {
const src = readFileSync(join(SRC, file), "utf8");
expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
}
const baseline = readFileSync(
join(SRC, "lib/insights/derived/baseline.ts"),
"utf8",
});
it("plants: an unbounded findMany of a dense type is caught", () => {
const planted = `
const rows = await prisma.measurement.findMany({
where: { userId, type: "BLOOD_GLUCOSE", measuredAt: { gte: oneYearAgo } },
select: { value: true, measuredAt: true },
});`;
const { all: n, untaken: found } = untakenFindManyReads(
"app/api/planted/route.ts",
planted,
);
expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
expect(n).toBe(1);
expect(found.map((r) => r.typeExpr)).toEqual(['"BLOOD_GLUCOSE"']);
expect(sparseLiteral(found[0].typeExpr)).toBe(false);
// The same read with a key list or a take is bounded.
expect(
untakenFindManyReads(
"x.ts",
planted.replace("measuredAt: { gte", "id: { in: ids }, x: { gte"),
).untaken,
).toEqual([]);
expect(
untakenFindManyReads(
"x.ts",
planted.replace("select:", "take: 10, select:"),
).untaken,
).toEqual([]);
// A list of sparse literals passes; one dense member does not.
expect(sparseLiteral('{ in: ["WEIGHT", "BODY_FAT"]')).toBe(true);
expect(sparseLiteral('{ in: ["WEIGHT", "PULSE"]')).toBe(false);
});
it("plants: a raw SELECT of rows from measurements is caught", () => {
const planted =
"const rows = await prisma.$queryRaw<Array<{ v: number }>>`\n" +
' SELECT m."value" FROM measurements m\n' +
' WHERE m."user_id" = ${userId} AND m."type" = \'PULSE\'\n' +
"`;";
const reads = rawMeasurementReads("lib/planted.ts", planted);
expect(reads).toHaveLength(1);
expect(reads[0].folds).toBe(false);
const folded = rawMeasurementReads(
"lib/planted.ts",
planted.replace("WHERE", "WHERE true GROUP BY 1 HAVING true AND"),
);
expect(folded[0].folds).toBe(true);
const aggregate = rawMeasurementReads(
"lib/planted.ts",
planted.replace('SELECT m."value"', 'SELECT COUNT(*), AVG(m."value")'),
);
expect(aggregate[0].folds).toBe(true);
});
});
@@ -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",
@@ -0,0 +1,371 @@
/**
* Structural guard: every pg-boss queue declares how long its job can run, and
* a long one is safe against pg-boss's expiry.
*
* pg-boss gives a job fifteen minutes unless the send says otherwise, then
* fails it, fires its abort signal and retries it while the handler keeps
* going. The full-history imports ran beside themselves that way (#1066), and
* so could every other job that legitimately needs longer: the nightly status
* passes, the pre-generation, the record scan, the key rotation, the backups.
*
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
* `long`. This guard holds the tree to it:
*
* - every queue bound with `createAndWork` is in the table, and every table
* entry is bound;
* - every `boss.send` and every schedule tuple names a queue in the table;
* - a `long` queue has an expiry between the default and pg-boss's 24-hour
* ceiling, every send and schedule for it carries `expireInSeconds` (or an
* identifier the table names as its carrier), it stops on the job's budget
* where the table says (`jobBudget` / `jobDeadline`, or a declared
* one-shot), and it is exclusive per identity where the table says
* (`lockedPass` at the binding, or the named marker);
* - a `budgeted` queue stops on the budget and relies on the default expiry;
* - a `short` queue's sends set no expiry of their own, which would mean the
* job is longer than the table claims.
*
* Floors fail the guard if a matcher stops matching, and the "plants" cases
* run the checks over a planted long queue that lacks each protection in turn
* (the mutation proof kept in the suite). Removing `lockedPass` from the
* pr-detection binding, or the expiry from its cron tuple, fails the real
* checks below.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import {
DEFAULT_JOB_EXPIRE_SECONDS,
QUEUE_RUNTIME,
type QueueRuntime,
type SourceSite,
} from "@/lib/jobs/queue-runtime";
const SRC = join(process.cwd(), "src");
const REGISTRARS = [
"lib/jobs/reminder/register-integration-sync.ts",
"lib/jobs/reminder/register-maintenance.ts",
"lib/jobs/reminder/register-reminders.ts",
"lib/jobs/reminder/register-rollup.ts",
"lib/jobs/reminder/register-status.ts",
];
/** pg-boss refuses an expiry of 24 hours or more. */
const MAX_EXPIRE_SECONDS = 24 * 60 * 60;
const files = walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
const source = new Map(
files.map((f) => [f, readFileSync(join(SRC, f), "utf8")]),
);
/** Strip comments so documentation that quotes code never matches. */
function code(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
/** Index just past the bracket that closes the one at `open`. */
function closeOf(text: string, open: number): number {
const pair: Record<string, string> = { "(": ")", "[": "]", "{": "}" };
const want = pair[text[open]];
let depth = 1;
let i = open + 1;
while (depth > 0 && i < text.length) {
if (text[i] === text[open]) depth += 1;
else if (text[i] === want) depth -= 1;
i += 1;
}
return i;
}
type Definitions = Map<string, Array<{ file: string; name: string }>>;
/** `const NAME = "queue-name"` definitions, per file. */
function definitionsOf(texts: ReadonlyMap<string, string>): Definitions {
const definitions: Definitions = new Map();
for (const [file, text] of texts) {
for (const m of text.matchAll(
/\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*"([a-z0-9-]+)"/g,
)) {
const list = definitions.get(m[1]) ?? [];
list.push({ file, name: m[2] });
definitions.set(m[1], list);
}
}
return definitions;
}
/** Resolve a queue identifier as seen from `file`, preferring its own. */
function resolve(
definitions: Definitions,
file: string,
ident: string,
): string | null {
const defs = definitions.get(ident) ?? [];
const own = defs.find((d) => d.file === file);
if (own) return own.name;
const names = new Set(defs.map((d) => d.name));
return names.size === 1 ? [...names][0] : null;
}
interface Site {
file: string;
queue: string;
text: string;
}
/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */
function bindings(
texts: ReadonlyMap<string, string>,
definitions: Definitions,
): Site[] {
const out: Site[] = [];
for (const file of REGISTRARS) {
const text = code(texts.get(file) ?? "");
for (const m of text.matchAll(
/createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g,
)) {
const open = text.indexOf("(", m.index);
const queue = resolve(definitions, file, m[1]);
if (queue) {
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
}
}
}
return out;
}
/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */
function sends(
texts: ReadonlyMap<string, string>,
definitions: Definitions,
): Site[] {
const out: Site[] = [];
for (const [file, raw] of texts) {
const text = code(raw);
for (const m of text.matchAll(
/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*([A-Z][A-Z0-9_]*)/g,
)) {
const open = text.indexOf("(", text.indexOf("send", m.index));
const queue = resolve(definitions, file, m[1]);
if (queue) {
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
}
}
}
return out;
}
/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */
function schedules(
texts: ReadonlyMap<string, string>,
definitions: Definitions,
): Site[] {
const out: Site[] = [];
for (const file of REGISTRARS) {
const text = code(texts.get(file) ?? "");
const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text);
if (!m) continue;
const open = m.index + m[0].length - 1;
const body = text.slice(open + 1, closeOf(text, open) - 1);
for (let i = 0; i < body.length; i += 1) {
if (body[i] !== "[") continue;
const end = closeOf(body, i);
const tuple = body.slice(i, end);
const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1];
const queue = ident ? resolve(definitions, file, ident) : null;
if (queue) out.push({ file, queue, text: tuple });
i = end - 1;
}
}
return out;
}
/** The text of `fn` (a function or a const) in `file`. */
function bodyOf(
texts: ReadonlyMap<string, string>,
site: { file: string; fn: string },
): string {
const text = code(texts.get(site.file) ?? "");
const m = new RegExp(
`(?:function\\s+${site.fn}\\b|const\\s+${site.fn}\\b)`,
).exec(text);
if (!m) return "";
const end = text.indexOf("\n}", m.index);
return text.slice(m.index, end < 0 ? undefined : end + 2);
}
function siteText(
texts: ReadonlyMap<string, string>,
site: SourceSite,
binding: Site | undefined,
): string {
return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site);
}
const STOPS = /\bjob(?:Budget|Deadline)\s*\(/;
/** Every finding for one queue's declaration against the tree. */
function check(
queue: string,
entry: QueueRuntime,
texts: ReadonlyMap<string, string>,
all: { bindings: Site[]; sends: Site[]; schedules: Site[] },
): string[] {
const problems: string[] = [];
const binding = all.bindings.find((b) => b.queue === queue);
const carriers = [...all.sends, ...all.schedules].filter(
(s) => s.queue === queue,
);
if (entry.runtime === "long") {
if (
!(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) ||
!(entry.expireInSeconds < MAX_EXPIRE_SECONDS)
) {
problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`);
}
for (const site of carriers) {
const carried =
/\bexpireInSeconds\b/.test(site.text) ||
entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text));
if (!carried) {
problems.push(
`${queue}: ${site.file} sends or schedules it without an expiry`,
);
}
}
if (typeof entry.stop === "object" && "oneShot" in entry.stop) {
if (entry.stop.oneShot.length < 20) {
problems.push(`${queue}: a one-shot needs its reason`);
}
} else if (!STOPS.test(siteText(texts, entry.stop, binding))) {
problems.push(`${queue}: does not stop on the job's budget`);
}
if (entry.exclusive === "lockedPass") {
if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) {
problems.push(`${queue}: binding holds no lockedPass`);
}
} else if (
!siteText(texts, entry.exclusive.at, binding).includes(
entry.exclusive.marker,
)
) {
problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`);
}
} else {
if (entry.runtime === "budgeted") {
if (!STOPS.test(siteText(texts, entry.budget, binding))) {
problems.push(`${queue}: does not stop on the job's budget`);
}
}
for (const site of carriers) {
if (/\bexpireInSeconds\b/.test(site.text)) {
problems.push(
`${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`,
);
}
}
}
return problems;
}
function scan(texts: ReadonlyMap<string, string>) {
const definitions = definitionsOf(texts);
return {
bindings: bindings(texts, definitions),
sends: sends(texts, definitions),
schedules: schedules(texts, definitions),
};
}
describe("every queue declares its runtime", () => {
const all = scan(source);
const bound = new Set(all.bindings.map((b) => b.queue));
it("finds the bindings, sends and schedules it is meant to police", () => {
// Pinned below the counts on 2026-10-01 (112 / 64 / 74).
expect(all.bindings.length).toBeGreaterThanOrEqual(105);
expect(all.sends.length).toBeGreaterThanOrEqual(50);
expect(all.schedules.length).toBeGreaterThanOrEqual(70);
expect(
Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length,
).toBeGreaterThanOrEqual(15);
});
it("every bound queue is in the table, and every entry is bound", () => {
expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]);
expect(
Object.keys(QUEUE_RUNTIME)
.filter((q) => !bound.has(q))
.sort(),
).toEqual([]);
});
it("every send and schedule names a queue in the table", () => {
const unknown = [...all.sends, ...all.schedules]
.filter((s) => !(s.queue in QUEUE_RUNTIME))
.map((s) => `${s.file} → ${s.queue}`);
expect(unknown).toEqual([]);
});
it("every queue meets its declaration", () => {
const problems = Object.entries(QUEUE_RUNTIME).flatMap(([queue, entry]) =>
check(queue, entry, source, all),
);
expect(problems).toEqual([]);
});
it("plants: a long queue missing any protection is caught", () => {
const registrar = REGISTRARS[1];
const planted = (binding: string, schedule: string) =>
new Map([
...source,
[
registrar,
`const PLANT_QUEUE = "planted-long";\n` +
`const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` +
`async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`,
],
]);
const entry: QueueRuntime = {
runtime: "long",
expireInSeconds: 3600,
expiryVia: [],
stop: "binding",
exclusive: "lockedPass",
why: "planted",
};
const findings = (binding: string, schedule: string) => {
const texts = planted(binding, schedule);
return check("planted-long", entry, texts, scan(texts));
};
const safe = [
"lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))",
"[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]",
] as const;
expect(findings(...safe)).toEqual([]);
expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([
`planted-long: ${registrar} sends or schedules it without an expiry`,
]);
expect(
findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]),
).toEqual(["planted-long: does not stop on the job's budget"]);
expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([
"planted-long: binding holds no lockedPass",
]);
expect(
check(
"planted-long",
{ ...entry, expireInSeconds: 900 },
...((): [ReadonlyMap<string, string>, ReturnType<typeof scan>] => {
const texts = planted(...safe);
return [texts, scan(texts)];
})(),
),
).toEqual(["planted-long: expiry 900s out of range"]);
});
});
@@ -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([]);
});
});
@@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_SINGLETON,
ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
} from "@/lib/jobs/encryption-key-rotate";
import { getActiveKeyId } from "@/lib/crypto";
import { NextRequest } from "next/server";
@@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON,
retryLimit: 2,
retryDelay: 60,
expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
},
);
+2 -1
View File
@@ -34,7 +34,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
decisionForEvidence,
PATTERN_FAMILIES,
@@ -619,6 +619,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: glucoseEnabled,
@@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -56,6 +60,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
@@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote glucose in this unit; re-warm
// them so today's stored text does not keep the old one.
if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.glucose-unit.patch" },
meta: { glucoseUnit: next },
@@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
@@ -43,6 +47,7 @@ import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
// The status notes and narratives quote mass, length and temperature in
// this branch; re-warm them so today's stored text follows the switch.
if (resolve(previous?.unitPreference) !== next) {
void refreshTextsAfterUnitChange(user.id);
}
annotate({
action: { name: "auth.me.unit-preference.patch" },
meta: { unitPreference: next },
@@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({
discoverPhaseCorrelations(args),
}));
vi.mock("@/lib/measurements/day-aggregates", () => ({
readSourceDayAggregates: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
+18 -22
View File
@@ -43,6 +43,7 @@ import {
type SymptomDay,
} from "@/lib/cycle/symptom-phase";
import { addDays } from "@/lib/cycle/day-math";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -131,27 +132,19 @@ export const GET = apiHandler(async () => {
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
// for a caller outside `measurements`.
// The outcome metrics the phase contrast compares, soft-delete-scoped.
// A year of steps, heart-rate variability and sensor glucose is far too
// many readings to hold as objects (#1023), and the crosstab uses only
// each day's sum and count after picking one source and device per day.
// So the fold runs in SQL, one row per type, local day, source and
// device, the read the coach's cycle snapshot already makes. Skipped
// whole for a caller outside `measurements`.
seesMeasurements
? prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: startOfLocalDayKey(from, tz),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
? readSourceDayAggregates({
userId: user.id,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: startOfLocalDayKey(from, tz),
timeZone: tz,
})
: [],
prisma.user.findUnique({
@@ -201,10 +194,13 @@ export const GET = apiHandler(async () => {
today,
);
// Each row stands for its day's readings: `value` is their sum and
// `count` their number, so `metricDayMap` gets the raw readings' day mean.
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
type: m.type,
value: m.value,
measuredAt: m.measuredAt,
value: m.sum,
count: m.n,
measuredAt: m.firstAt,
source: m.source,
deviceType: m.deviceType,
}));
@@ -19,8 +19,12 @@ import { unzipSync } from "fflate";
vi.mock("@/lib/db", () => ({
prisma: {
// `count` sizes the report's pulse window (#1023): a sparse account.
measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) },
// `groupBy` sizes the report's dense windows (#1023): a sparse account.
measurement: {
findMany: vi.fn(),
count: vi.fn(async () => 0),
groupBy: vi.fn(async () => []),
},
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
@@ -30,6 +30,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The glucose and heart-rate streams fold in SQL; this file pins the route's
// composition over Prisma mocks, so both readers are mocked at the module.
vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/targets/glucose-read")>()),
readGlucoseTargetSummaries: vi.fn(),
}));
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
readRestingPulseProxy: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
@@ -84,6 +94,11 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import {
foldGlucoseTargetRows,
readGlucoseTargetSummaries,
} from "@/lib/targets/glucose-read";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
const SESSION_USER = {
id: "user-targets-1",
@@ -153,6 +168,8 @@ beforeEach(() => {
// v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON`
// (Prisma's `distinct` dedups client-side after pulling every row).
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([]);
vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]);
vi.mocked(readRestingPulseProxy).mockResolvedValue([]);
});
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
@@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => {
// The glucose section used to scan the user's entire BLOOD_GLUCOSE
// history on every cold build (no `measuredAt` filter at all) just
// to read the latest value per context. The read now carries the
// same 365-day floor as the latest-ever-per-type query.
// to read the latest value per context. It now folds in SQL, newest
// per context over a year and per day over thirty days, and no glucose
// or heart-rate reading is read as a row.
await callGet(makeReq());
const calls = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.map((c) => c[0]);
const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE");
expect(glucoseCall).toBeDefined();
expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
expect(
calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"),
).toBeUndefined();
expect(
calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")),
).toEqual([]);
const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0];
const day = 86_400_000;
expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day);
expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day);
expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day);
expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day);
});
it("pins the complete ordered public response for representative populated data", async () => {
@@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
).mockImplementation(
async (args: { where?: { type?: string | { in: string[] } } }) => {
if (args.where?.type === "SLEEP_DURATION") return sleepRows;
if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows;
return genericRows;
},
);
vi.mocked(readGlucoseTargetSummaries).mockImplementation(
async ({ recentSince, timeZone }) =>
foldGlucoseTargetRows(
glucoseRows.map((row) => ({
...row,
glucoseContext: row.glucoseContext as "FASTING",
})),
recentSince,
timeZone,
),
);
(prisma.$queryRawUnsafe as ReturnType<typeof vi.fn>).mockResolvedValue([
{ type: "WEIGHT", value: 70 },
{ type: "BLOOD_PRESSURE_SYS", value: 125 },
+19 -4
View File
@@ -543,9 +543,18 @@ async function sleepListResponse(
loadUserSourcePriority(user.id),
]);
// Bound the read window: honour an explicit from/to, otherwise cap to
// the last year so the per-stage scan stays small.
const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000);
// Bound the read window to a year ending at `to` (or now). An explicit
// `from` narrows it but never widens it: an early `from` used to walk every
// per-stage row back to that date, which on a multi-year, multi-source
// account is the unbounded read this cap exists to stop. Older nights are
// reached by moving `to` back, and `meta.windowFrom` says where a cut
// window started so a client can tell a cut from an empty history.
const windowEnd = to ?? new Date();
const floor = new Date(
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
);
const since = from && from > floor ? from : floor;
const windowCut = from !== undefined && from < floor;
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -690,7 +699,13 @@ async function sleepListResponse(
});
return apiSuccess({
measurements,
meta: { total: totalNights, limit, offset, groupBy: "night" },
meta: {
total: totalNights,
limit,
offset,
groupBy: "night",
...(windowCut ? { windowFrom: since.toISOString() } : {}),
},
});
}
+23 -9
View File
@@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365;
* byte-identical. Sparse kinds (weight, BP, …) are untouched.
*/
const DENSE_SERIES_RAW_WINDOW_DAYS = 90;
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set(["glucose", "pulse"]);
/**
* The kinds a device can write at sampling rate. Heart-rate variability and
* blood oxygen joined pulse and glucose once watches and rings began writing
* them every few minutes overnight: their 3650-day "Alle" range read every
* raw row, which is the read this rule exists to stop.
*/
const DENSE_SERIES_KINDS: ReadonlySet<string> = new Set([
"glucose",
"pulse",
"heartRateVariability",
"oxygenSaturation",
]);
/**
* #1023 — inside the raw window, a pulse or glucose series with more rows
* than this is bucketed per local hour in SQL instead of sent raw.
* #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV,
* blood oxygen) with more rows than this is bucketed per local hour in SQL
* instead of sent raw.
*
* A watch that records heart rate once a minute puts 43 000 rows in the
* default 30-day window and 130 000 in 90 days: a 15 MB response the client
@@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
})) > DENSE_SERIES_RAW_ROW_CAP)
) {
// Day buckets past the raw window; hour buckets for a pulse or CGM
// Day buckets past the raw window; hour buckets for a dense-kind
// stream too dense to send raw inside it.
const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour";
// v1.28.25 — long-window read of a sample-dense kind (CGM glucose,
@@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
};
}
} else {
// pulse — `value` is the bucket's average; valueMin/valueMax carry the
// bucket's low/high band (folding each hourly import bucket's own
// `value` is the bucket's average. For pulse, valueMin/valueMax carry
// the bucket's low/high band (folding each hourly import bucket's own
// spread via the COALESCE above), same band semantics as the per-hour
// shape.
// shape. The other dense kinds keep the bare point shape their raw rows
// have, so a client reads a bucket the way it reads a reading.
points = bucketRows.map((r) => ({
id: dayId(r.bucket_start),
at: r.bucket_start.toISOString(),
value: round2(r.mean),
secondary: null,
valueMin: r.min_value,
valueMax: r.max_value,
...(kind === "pulse"
? { valueMin: r.min_value, valueMax: r.max_value }
: {}),
}));
if (
rawAgg !== undefined &&
@@ -42,6 +42,7 @@ import {
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
export const dynamic = "force-dynamic";
@@ -233,6 +234,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 });
}
+4 -1
View File
@@ -192,7 +192,10 @@ export default function LabsPage() {
title={t("labs.addResult")}
description={t("labs.addDescription")}
footer={
<div ref={setAddFooterEl} className="flex w-full justify-end gap-2" />
<div
ref={setAddFooterEl}
className="flex w-full flex-wrap justify-end gap-2"
/>
}
>
<LabForm
+2
View File
@@ -201,6 +201,7 @@ export default function NotificationsPage() {
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
stackActionsOnPhone
/>
<EmptyState
className="max-w-2xl"
@@ -227,6 +228,7 @@ export default function NotificationsPage() {
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
stackActionsOnPhone
/>
{/* Desktop: table layout */}
@@ -125,7 +125,7 @@ export function AppLogPreviewSection() {
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => refetch()}
disabled={isFetching}
aria-label={t("admin.section.app-logs.refresh")}
@@ -133,13 +133,12 @@ export function AppLogPreviewSection() {
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
/>
<p className="text-sm">
{t("admin.section.app-logs.processDetail")}
</p>
</Button>
}
/>
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
<Input
type="search"
@@ -11,6 +11,11 @@ import {
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
getQuantityTransform,
} from "@/lib/measurements/display-transform";
/**
* Workout elevation profile — a small cumulative-distance / altitude
@@ -30,7 +35,15 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
const distance = getQuantityTransform("distance", preference);
const elevation = getQuantityTransform("elevation", preference);
const data = points.map((p) => ({
dist: applyDisplayTransform(p.distanceM, distance),
alt: applyDisplayTransform(p.altitude, elevation),
}));
return (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({
opacity={0.4}
/>
<XAxis
dataKey="km"
dataKey="dist"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
@@ -80,9 +93,11 @@ export function WorkoutElevationChart({
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
labelFormatter={(d) =>
`${Number(d).toFixed(2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} m`,
`${Math.round(Number(value))} ${elevation.displayUnit}`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
@@ -225,6 +225,49 @@ describe("<ClinicianView>", () => {
expect(html).not.toContain(">WEIGHT<");
});
it("prints every measurement group with its unit", () => {
const html = render(
makeReport({
stats: {
WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 },
// Per-night asleep minutes; the report reads them in hours.
SLEEP_DURATION: {
avg: 450,
min: 420,
max: 480,
count: 7,
latest: 450,
},
},
}),
);
expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)");
expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)");
});
it("shows glucose in the record owner's unit, never raw mg/dL", () => {
const html = render(
makeReport({
glucoseUnit: "mmol/L",
stats: {
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
glucoseStats: {
FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 },
},
}),
);
// 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3.
expect(html).toContain(
"latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)",
);
expect(html).toContain(
"latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)",
);
expect(html).not.toContain("mg/dL");
expect(html).not.toContain("latest 99");
});
it("omits the wellness card when there are no scores", () => {
const html = render(makeReport({ wellnessScores: [] }));
expect(html).not.toContain("Wellness scores");
+39 -10
View File
@@ -15,6 +15,11 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
reportStatUnit,
reportStatValue,
} from "@/lib/doctor-report/stat-display";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import {
isReportLeafId,
@@ -212,6 +217,13 @@ export function MeasurementGroups({
if (groups.length === 0) return null;
// The same unit and the same number the PDF prints for the row: SI for
// mass, length and temperature, the owner's unit for glucose, hours for
// sleep. A clinician must never read a figure without knowing its unit.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (type: string, value: number) =>
fmtNum(reportStatValue(type, value, glucoseUnit));
return (
<>
{groups.map((group) => (
@@ -224,11 +236,11 @@ export function MeasurementGroups({
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
latest: show(type, stat!.latest),
avg: show(type, stat!.avg),
min: show(type, stat!.min),
max: show(type, stat!.max),
})}
/>
))}
@@ -238,6 +250,20 @@ export function MeasurementGroups({
);
}
/**
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
* count) keeps the unit-less sentence rather than printing a dangling space.
*/
function statSummary(
t: Translate,
unit: string,
values: { latest: number; avg: number; min: number; max: number },
): string {
return unit
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
: t("clinicianView.statSummary", values);
}
/**
* Per-context glucose stats. The heading used to read "Lab values", which was
* the wrong name for it even before the record's actual lab results reached
@@ -258,6 +284,9 @@ export function GlucoseSection({
const entries = Object.entries(report.glucoseStats).filter(
([, s]) => s.count > 0,
);
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
return (
<LeafSection
t={t}
@@ -270,11 +299,11 @@ export function GlucoseSection({
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
value={statSummary(t, glucoseUnit, {
latest: show(s.latest),
avg: show(s.avg),
min: show(s.min),
max: show(s.max),
})}
/>
))}
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
// QueryClient, so mock the hook to a real metric-preference implementation —
// metric is the identity on values and yields the canonical display symbols,
// so the assertions below (kg, °C, unchanged numbers) hold.
const reader = vi.hoisted(() => ({
glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L",
}));
vi.mock("@/hooks/use-unit-display", async () => {
const dt = await import("@/lib/measurements/display-transform");
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
return {
useUnitDisplay: () => ({
preference: "metric" as const,
glucoseUnit: reader.glucoseUnit,
transformFor: forType,
toDisplay: (t: string, v: number) =>
dt.applyDisplayTransform(v, forType(t)),
@@ -107,4 +112,26 @@ describe("<CyclePhaseCrosstab>", () => {
const html = render(<CyclePhaseCrosstab rows={[unknown]} />);
expect(html).not.toContain("undefined");
});
it("states a glucose row in mmol/L for an mmol/L reader", () => {
reader.glucoseUnit = "mmol/L";
try {
const glucose: CyclePhaseCrosstabRow = {
...row("bloodGlucose", "glucose"),
lutealAvg: 108,
follicularAvg: 90,
delta: 18,
};
const html = render(<CyclePhaseCrosstab rows={[glucose]} />);
// 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L.
expect(html).toContain("+1.0 mmol/L");
expect(html).toContain("6.0");
expect(html).not.toContain("mg/dL");
expect(render(<CyclePhaseHeadline headline={glucose} />)).toContain(
"mmol/L",
);
} finally {
reader.glucoseUnit = "mg/dL";
}
});
});
+52 -21
View File
@@ -1,7 +1,12 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { cn } from "@/lib/utils";
/**
@@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
glucose: "BLOOD_GLUCOSE",
};
/**
* Unit symbol and conversions for one row, from the reader's preferences.
* Glucose follows the glucose unit (it used to print the mg/dL label from
* the bundle beside raw mg/dL values for every reader); weight and
* temperature follow metric/imperial; every other display keeps its bundle
* label and its raw value.
*/
function rowUnits(
display: CyclePhaseCrosstabDisplay,
unitDisplay: UnitDisplay,
t: (key: string) => string,
): {
unit: string;
abs: (v: number) => number;
delta: (v: number) => number;
} {
const type = DISPLAY_TO_TYPE[display];
if (!type) {
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const unitKey = UNIT_KEY[display];
return {
unit: unitKey ? t(unitKey) : "",
abs: (v) => v,
delta: (v) => v,
};
}
const transform = getReadingTransform(type, {
system: unitDisplay.preference,
glucoseUnit: unitDisplay.glucoseUnit,
});
return {
unit: transform.displayUnit,
abs: (v) => applyDisplayTransform(v, transform),
delta: (v) => applyDisplayTransformDelta(v, transform),
};
}
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -120,11 +164,7 @@ export function CyclePhaseHeadline({
const metricLabel = t(
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t` (whose
// resolver does `key.split(".")`). An empty unit is the honest degrade.
const mt = DISPLAY_TO_TYPE[headline.display];
const unitKey = UNIT_KEY[headline.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
const up = headline.delta >= 0;
const dir = t(
up
@@ -134,12 +174,7 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(
Math.abs(
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
),
headline.display,
),
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
unit,
dir,
};
@@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
const mt = DISPLAY_TO_TYPE[row.display];
const unitKey = UNIT_KEY[row.display];
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const absConv = (v: number) =>
mt ? unitDisplay.toDisplay(mt, v) : v;
const deltaConv = (v: number) =>
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
@@ -0,0 +1,70 @@
/**
* A logged fever reads in the reader's temperature unit. The timeline and the
* day sheet printed a hardcoded °C, so an imperial account read its own
* fever in Celsius.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const account = vi.hoisted(() => ({
user: { unitPreference: "imperial", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManageDomain: () => true }),
}));
vi.mock("../use-illness", () => ({
useIllnessDayLogList: () => ({
isLoading: false,
isError: false,
data: {
dayLogs: [
{
id: "d1",
episodeId: "e1",
date: "2026-09-30",
functionalImpact: null,
feverC: 38.5,
symptoms: [],
note: null,
updatedAt: "2026-09-30T08:00:00.000Z",
},
],
},
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { IllnessDayTimeline } from "../illness-day-timeline";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<IllnessDayTimeline episodeId="e1" onLogDay={() => {}} />
</I18nProvider>,
);
}
describe("<IllnessDayTimeline> fever unit", () => {
it("shows the fever in °F for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
const html = render();
// 38.5 °C = 101.3 °F.
expect(html).toContain("Temperature: 101.3 °F");
expect(html).not.toContain("°C");
});
it("keeps °C for a metric reader", () => {
account.user = { unitPreference: "metric", glucoseUnit: null };
expect(render()).toContain("Temperature: 38.5 °C");
});
});
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
@@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null {
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { toDisplay, unitFor } = useUnitDisplay();
return (
<div className="border-border/60 space-y-2 border-l-2 pl-3">
@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
{log.feverC !== null ? (
<p className="text-foreground text-sm">
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
{t("illness.timeline.fever", {
value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1),
unit: unitFor("BODY_TEMPERATURE"),
})}
</p>
) : null}
+16 -4
View File
@@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
@@ -140,6 +141,10 @@ export function LogDaySheet({
onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
// The fever is stored in °C and typed in the reader's temperature unit:
// seeded through the transform, inverted on save.
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
const feverUnit = unitFor("BODY_TEMPERATURE");
// The day being logged — seeded from `date` (today), but the picker lets the
// user backdate within [onset, today] for the retrospective journal.
@@ -178,7 +183,11 @@ export function LogDaySheet({
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
);
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(dto?.feverC != null ? String(dto.feverC) : "");
setFever(
dto?.feverC != null
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
: "",
);
setNote(dto?.note ?? "");
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
@@ -213,7 +222,10 @@ export function LogDaySheet({
const input: IllnessDayLogInput = {
date: selectedDate,
functionalImpact,
feverC: feverValue,
feverC:
feverValue == null
? null
: Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity: severity ?? undefined,
@@ -333,7 +345,7 @@ export function LogDaySheet({
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} °C
{feverValue.toFixed(1)} {feverUnit}
</span>
) : null
}
@@ -345,7 +357,7 @@ export function LogDaySheet({
<Input
id="illness-fever"
inputMode="decimal"
placeholder="37.0"
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = ({ title }: { title?: string }) => (
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
const Stub = ({
title,
unit,
valueScale,
valueOffset,
}: {
title?: string;
unit?: string;
valueScale?: number;
valueOffset?: number;
}) => (
<div
data-slot="trends-row-chart-stub"
data-unit={unit}
data-scale={valueScale}
data-offset={valueOffset}
>
{title ?? "chart"}
</div>
);
Stub.displayName = "TrendsRowChartStub";
return Stub;
},
}));
const account = vi.hoisted(() => ({
user: null as Record<string, unknown> | null,
}));
vi.mock("@/hooks/use-auth", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/use-auth")>()),
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
import { TrendsRow } from "../trends-row";
import type { DailyBriefing } from "@/lib/ai/schema";
@@ -306,4 +332,27 @@ describe("<TrendsRow>", () => {
expect(html).toMatch(/data-metric="steps"/);
expect(html).toContain('href="/insights/steps"');
});
it("plots a transformed slot in the reader's unit, scale and offset included", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<TrendsRow briefing={briefing(["weight", "distance", "body_temp"])} />,
);
expect(html).toContain('data-unit="lb"');
expect(html).toContain('data-unit="mi"');
expect(html).toContain('data-unit="°F"');
expect(html).toContain('data-offset="32"');
expect(html).not.toContain('data-unit="kg"');
expect(html).not.toContain('data-unit="km"');
} finally {
account.user = null;
}
});
it("plots distance in kilometres, scaled from metres, for a metric reader", () => {
const html = render(<TrendsRow briefing={briefing(["distance"])} />);
expect(html).toContain('data-unit="km"');
expect(html).toContain('data-scale="0.001"');
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("148 bpm");
});
it("states distance, pace, elevation and the average line in imperial units", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<WorkoutDetailStats
workout={{
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
}}
/>,
);
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
expect(html).toContain("3.23 mi");
expect(html).toContain("9:17 /mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
expect(html).not.toContain(" km");
expect(html).not.toContain("/km");
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(header).toContain("3.23 mi");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expect(html).toContain("5.20 km");
expect(html).toContain("5:46 /km");
expect(html).toContain("12.5 m");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
@@ -1,10 +1,23 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WorkoutList } from "../workout-list";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
expect(html).toContain("320 kcal");
});
it("states the distance in miles for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(<WorkoutList workouts={[ROW]} />);
// 5200 m = 3.23 mi.
expect(html).toContain("3.23 mi");
expect(html).not.toContain(" km");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("renders aggregate average HR without claiming an HR series", () => {
const html = render(
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
@@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { RangeBar } from "@/components/targets/range-bar";
@@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { getTargetSourceLink } from "@/lib/targets/source-link";
import { apiGet } from "@/lib/api/api-fetch";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
/**
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
@@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
// mg/dL values to the user's display unit. The route's per-context
// labels are i18n keys, so resolve them.
if (isGlucose) {
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayUnit);
// The payload stays canonical mg/dL here; each panel converts through
// the shared target adapter, which owns the glucose unit as it owns
// pounds, so the panel and its edit sheet cannot convert twice.
const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
data.targets.find((entry) => entry.type === type),
)
.filter(
(entry): entry is TargetItem => entry != null && entry.range != null,
)
.map((entry) => ({
...entry,
label: t(entry.label),
unit: displayUnit,
current: convert(entry.current),
average30: convert(entry.average30),
range: entry.range
? {
min: convertGlucose(entry.range.min, displayUnit),
max: convertGlucose(entry.range.max, displayUnit),
}
: null,
}));
.map((entry) => ({ ...entry, label: t(entry.label) }));
if (panels.length === 0) return null;
@@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
key={panel.type}
target={panel}
heading={panel.label}
glucoseUnit={glucoseUnit}
/>
))}
</div>
@@ -212,6 +202,12 @@ interface TargetReferencePanelProps {
bpDiastolic?: TargetsResponse["bpDiastolic"];
/** Optional sub-heading shown above the panel (used for glucose contexts). */
heading?: string;
/**
* The record's glucose unit as the targets payload resolved it. The
* glucose panels pass it so the band reads in the unit the server
* resolved for this record; every other panel leaves it to the hook.
*/
glucoseUnit?: GlucoseUnit;
}
/**
@@ -232,21 +228,26 @@ function TargetReferencePanel({
target,
bpDiastolic,
heading,
glucoseUnit: payloadGlucoseUnit,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
const adjust = useTargetAdjust();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
// v1.32.27 — the panel renders in the user's preferred unit. The
// `/api/insights/targets` payload is canonical (kg for weight); the
// adapter converts it once, here, and everything downstream — the
// range bar, the status pill, the 30-day average, and the edit
// sheet's seed — consumes the converted pair. Glucose contexts are
// pre-converted by the caller above and carry no display transform,
// so they take the adapter's identity path and are never scaled
// twice. For a metric account the adapter is the exact identity, so
// this panel is byte-identical to the previous release.
const units = resolveTargetUnitAdapter(target.type, target.unit, preference);
// sheet's seed — consumes the converted pair. The glucose contexts
// arrive canonical too and convert here to the reader's glucose unit.
// On the default preferences the adapter is the exact identity.
const units = resolveTargetUnitAdapter(
target.type,
target.unit,
preference,
glucoseUnit,
);
const unit = units.unit;
const range = target.range
? {
+19 -5
View File
@@ -20,6 +20,7 @@ import {
import { cn } from "@/lib/utils";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
import { apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
@@ -264,6 +265,7 @@ export function TrendDescriptorCaption({
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
const isMood = kind === "mood";
const primaryType = types[0] ?? "";
@@ -323,18 +325,30 @@ export function TrendDescriptorCaption({
}
// The numeric series is already server-bounded to the trailing
// 30-day window by the fetch, so every returned row is in-window.
// Described in the reader's unit, the same one the chart above plots.
return (numericQuery.data ?? []).map((row) => ({
timestamp: new Date(row.measuredAt).getTime(),
value: row.value,
value: toDisplay(primaryType, row.value),
}));
}, [isMood, moodQuery.data, numericQuery.data]);
}, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]);
const descriptor = useMemo(() => {
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
// A slot that converts its series converts its noise floor with it: the
// floor is declared canonically and is a difference (factor only).
const config = isMood
? MOOD_DESCRIPTOR_CONFIG
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
: meta?.unitType
? {
...meta.config,
absoluteFloor: toDisplayDelta(
meta.unitType,
meta.config.absoluteFloor,
),
}
: meta?.config;
return computeTrendDescriptor(points, config);
}, [points, isMood, metric]);
}, [points, isMood, metric, toDisplayDelta]);
// Tier 4 — genuinely too few points (also covers the in-flight window
// before the small series lands). Surface the real empty hint so the
@@ -361,7 +375,7 @@ export function TrendDescriptorCaption({
// copy defensively (should not happen for the legacy triple).
const copy = isMood
? moodDescriptorCopy(descriptor)
: (numericDescriptorCopy(metric, descriptor) ??
: (numericDescriptorCopy(metric, descriptor, unitFor) ??
moodDescriptorCopy(descriptor));
return (
+15 -2
View File
@@ -22,6 +22,7 @@ import {
type TrendAnnotationConfidenceBand,
type TrendAnnotationStatus,
} from "./trend-annotation";
import { useUnitDisplay } from "@/hooks/use-unit-display";
/**
* Trends row — a small chart per metric above a one-sentence
@@ -116,17 +117,29 @@ function MetricChart({
title: string;
}) {
const { user } = useAuth();
const { transformFor, isTransformed } = useUnitDisplay();
const userTimezone = user?.timezone;
if (config.kind === "mood") {
return <MoodChart title={title} mini />;
}
// A single transformed series (weight, distance, temperature) plots in
// the reader's unit: the chart folds scale and offset in at its read
// boundary, exactly as the metric's own insights page does. Distance
// used to plot raw metres under a "km" label for every reader.
const primary = config.types[0] ?? "";
const transform =
config.types.length === 1 && isTransformed(primary)
? transformFor(primary)
: null;
return (
<HealthChartDynamicMini
types={config.types}
title={title}
colors={config.colors}
unit={config.unit}
yAxisUnit={config.yAxisUnit}
unit={transform ? transform.displayUnit : config.unit}
yAxisUnit={transform ? undefined : config.yAxisUnit}
valueScale={transform?.factor}
valueOffset={transform?.offset}
mini
userTimezone={userTimezone}
/>
@@ -1,4 +1,10 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import {
applyDisplayTransform,
getQuantityTransform,
paceSecondsPerDistanceUnit,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
hourCycleOptions,
type TimeFormatPreference,
@@ -27,12 +33,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
return formatNumber(minutes, locale);
}
export function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
/**
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
* "3.60 mi". The workout list, the detail header, the stat tile and the
* sport-average line all print through this, so none of them can keep
* kilometres for an imperial reader.
*/
export function formatDistance(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("distance", preference);
const value = applyDisplayTransform(meters, transform);
const formatted = getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
minimumFractionDigits: value < 10 ? 2 : 1,
}).format(value);
return `${formatted} ${transform.displayUnit}`;
}
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
export function formatElevation(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("elevation", preference);
return `${formatNumber(
applyDisplayTransform(meters, transform),
locale,
transform.decimals,
)} ${transform.displayUnit}`;
}
export function formatNumber(
@@ -46,21 +78,36 @@ export function formatNumber(
}).format(value);
}
export function formatPaceMinPerKm(
/**
* Average pace per kilometre or per mile, by the reader's preference. Only
* meaningful for run / walk / hike / ride; the caller gates on distance +
* sport.
*/
export function formatPace(
durationSec: number,
meters: number,
preference: UnitPreference,
): string {
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
// the caller gates on distance + sport.
const secPerKm = (durationSec / meters) * 1000;
return formatPaceSeconds(secPerKm);
return formatPaceSeconds(
paceSecondsPerDistanceUnit(durationSec, meters, preference),
getQuantityTransform("distance", preference).displayUnit,
);
}
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
export function formatPaceSeconds(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} /km`;
/**
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
* explicitly: its rows are kilometre segments cut server-side, so their
* pace is per kilometre whatever the preference.
*/
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
let m = Math.floor(secPerUnit / 60);
let s = Math.round(secPerUnit % 60);
// 359.6 s is 6:00, not 5:60.
if (s === 60) {
m += 1;
s = 0;
}
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
}
export function formatDateRange(
@@ -3,8 +3,9 @@
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
import { formatDuration, formatDistance, formatDateRange } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
</div>
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
{formatPaceSeconds(s.paceSecPerKm, "km")}
</td>
</tr>
))}
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import type { UnitPreference } from "@/lib/measurements/display-transform";
import {
formatDuration,
formatDistanceKm,
formatDistance,
formatElevation,
formatNumber,
formatPaceMinPerKm,
formatPace,
formatDurationMinutes,
} from "./format";
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
function sportAverageLine(
workout: WorkoutDetailPayload,
locale: string,
preference: UnitPreference,
): string | null {
const ctx = workout.sportContext;
if (!ctx || ctx.count < 2) return null;
const parts: string[] = [];
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
}
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
@@ -83,6 +87,7 @@ function sportAverageLine(
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const { preference } = useUnitDisplay();
const tiles: StatTileProps[] = [];
tiles.push({
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
value: formatDistance(workout.distanceM, locale, preference),
});
}
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
value: formatElevation(workout.elevationM, locale, preference),
});
}
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
value: formatPace(workout.durationSec, workout.distanceM, preference),
});
}
const averageLine = sportAverageLine(workout, locale);
const averageLine = sportAverageLine(workout, locale, preference);
return (
<Card data-slot="workout-detail-stats">
+4 -9
View File
@@ -13,6 +13,8 @@ import {
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
/**
* v1.4.32 — workout list row primitive.
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
return `${m}m`;
}
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
@@ -86,6 +80,7 @@ export function WorkoutList({
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
return (
<ul
@@ -150,7 +145,7 @@ export function WorkoutList({
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
{workout.activeEnergyKcal != null ? (
+4 -1
View File
@@ -163,7 +163,10 @@ export function LabBiomarkerChart({
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
// A reference window that starts at 0 (LDL "0–130") must not drag the
// axis below zero: a concentration cannot be negative, and a "-13" tick
// reads as a value. The pad only stays below zero when the data already is.
return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad];
}, [points, lowerBound, upperBound, sourceBand]);
const animate = !prefersReducedMotion();
+4
View File
@@ -368,6 +368,10 @@ export function LabForm({
variant="outline"
data-action="save-and-add-another"
disabled={submitting}
// Three buttons do not fit one phone-width row: the secondary save
// takes a row of its own above Cancel + Save instead of pushing
// Cancel off the left edge of the sheet.
className="max-md:order-first max-md:w-full"
>
{pendingAction === "saveAndAddAnother" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+139 -94
View File
@@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}, [readings, sortBy, sortDir]);
// One action pair per reading, shared by the phone list and the table.
const renderActions = (r: LabResultDto) => (
<div className="flex items-center justify-end gap-1">
{canManageLabs && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
);
return (
<>
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
{canManageLabs && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</div>
</TableCell>
{/* Phones get one stacked row per reading. The table needs ~510 px for
its five columns, so on a phone it panned sideways inside its
container and parked the edit / delete buttons off-screen, while the
range bar's end labels were clipped by the same container. */}
<ul
data-slot="lab-history-mobile-list"
className="divide-border divide-y lg:hidden"
>
{ordered.map((r) => (
<li key={r.id} className="flex items-start gap-3 py-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="font-semibold tabular-nums">
{formatLabReading(r)}
</div>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
{r.hasNote ? (
<span className="text-xs">{t("labs.hasNote")}</span>
) : null}
</div>
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
{renderActions(r)}
</li>
))}
</ul>
<div data-slot="lab-history-table" className="max-lg:hidden">
<Table>
<TableHeader>
<TableRow>
<SortableHead
column="value"
label={t("labs.form.value")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="takenAt"
label={t("labs.form.takenAt")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="range"
label={t("labs.referenceLabel")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<SortableHead
column="note"
label={t("labs.hasNote")}
currentSort={sortBy}
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-20 pr-4 text-right">
<span className="sr-only">{t("labs.editReading")}</span>
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
<div className="space-y-2">
<span>{formatLabReading(r)}</span>
<LabReferenceRangeBar
value={r.value}
referenceLow={r.referenceLow}
referenceHigh={r.referenceHigh}
unit={r.unit}
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<ReferenceRangeBadge status={r.rangeStatus} />
<SourceRangeNote
reading={r}
className="text-muted-foreground text-xs"
/>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{r.hasNote ? t("labs.hasNote") : ""}
</TableCell>
<TableCell className="pr-4 text-right">
{renderActions(r)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ResponsiveSheet
open={editingId !== null}
+57 -15
View File
@@ -80,7 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
return (
<div className="w-48">
<div className={cn("w-full lg:w-48", MOBILE_CELL.rangeBar)}>
<LabReferenceRangeBar
value={reading.value}
referenceLow={reading.referenceLow}
@@ -91,6 +91,35 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) {
);
}
/**
* The compact list is a five-column table from `lg` up: name and reading, range
* badge, range bar, trend, chevron. Below that the same five cells fold into
* two lines per row, because the fixed columns alone (badge + 12rem bar + 72px
* trend + gaps) are wider than a phone, and the only flexible column — the
* name — was the one that gave way: it collapsed to nothing and the row
* panned sideways off the card.
*
* Phone layout, per row:
*
* name / reading · date badge ›
* range bar trend
*
* The placement classes are all `max-lg:` so the desktop table keeps exactly
* the subgrid it had.
*/
const ROW_CLASS =
"grid min-w-0 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-x-3 gap-y-2 px-4 py-2.5 lg:col-span-full lg:grid-cols-subgrid lg:gap-y-0";
const MOBILE_CELL = {
reading: "max-lg:col-start-1 max-lg:row-start-1",
badge: "max-lg:col-start-2 max-lg:row-start-1 max-lg:justify-self-end",
rangeBar: "max-lg:col-start-1 max-lg:row-start-2 max-lg:empty:hidden",
trend:
"max-lg:col-start-2 max-lg:row-start-2 max-lg:justify-self-end max-lg:empty:hidden",
end: "max-lg:col-start-3 max-lg:row-start-1",
notLinked: "max-lg:col-span-full max-lg:row-start-3",
} as const;
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canWriteDomain } = useRecordCapabilities();
@@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
</p>
) : null}
<Card>
<CardContent className="divide-border grid grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] gap-x-3 divide-y p-0">
<CardContent className="divide-border grid grid-cols-1 divide-y p-0 lg:grid-cols-[minmax(0,1fr)_max-content_12rem_72px_auto] lg:gap-x-3">
{groups.map((group) => {
const inner = (
<div className="min-w-0 flex-1">
<div className={cn("min-w-0 flex-1", MOBILE_CELL.reading)}>
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="truncate font-medium">
<span
data-slot="lab-list-analyte"
className="truncate font-medium"
>
{group.analyte}
</span>
</div>
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="hover:bg-muted/40 col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5 transition-colors"
className={cn(
ROW_CLASS,
"hover:bg-muted/40 transition-colors",
)}
>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
<ChevronRight
className={cn(
"text-muted-foreground h-4 w-4 shrink-0",
MOBILE_CELL.end,
)}
/>
</Link>
) : (
// Un-linked group (not backfilled to a catalog biomarker):
// render inert but say so, so it doesn't read as a broken
// link next to its clickable neighbours.
<div
key={group.key}
className="col-span-full grid min-w-0 grid-cols-subgrid items-center px-4 py-2.5"
>
<div key={group.key} className={ROW_CLASS}>
{inner}
<ReferenceRangeBadge
status={group.latest.rangeStatus}
compact
className="w-full"
className={cn("lg:w-full", MOBILE_CELL.badge)}
/>
<LabRangeBarSlot reading={group.latest} />
<div className="w-[72px]">
<div className={cn("w-[72px]", MOBILE_CELL.trend)}>
<LabTrendSparkline
values={group.readings.map((reading) => reading.value)}
referenceLow={group.latest.referenceLow}
referenceHigh={group.latest.referenceHigh}
/>
</div>
<span className="text-muted-foreground shrink-0 text-xs italic">
<span
className={cn(
"text-muted-foreground shrink-0 text-xs italic",
MOBILE_CELL.notLinked,
)}
>
{t("labs.notLinkedYet")}
</span>
</div>
+10 -2
View File
@@ -129,9 +129,17 @@ export function OcrRowEditor({
{fieldError("analyte", "labs.ocr.errorAnalyte")}
<div className="flex flex-wrap items-center gap-1.5">
{row.biomarkerMatch === "existing" ? (
<Badge variant="outline" className="text-muted-foreground">
// The chip repeats the analyte, so a long panel name ran past
// the row on a phone; it truncates inside the row instead.
<Badge
variant="outline"
className="text-muted-foreground max-w-full"
title={t("labs.ocr.linksExisting", { name: row.analyte })}
>
<Link2 aria-hidden />
{t("labs.ocr.linksExisting", { name: row.analyte })}
<span className="truncate">
{t("labs.ocr.linksExisting", { name: row.analyte })}
</span>
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
@@ -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"');
});
});
+6 -1
View File
@@ -89,7 +89,12 @@ export function SettingsCardHeader({
{titleAccessory}
</div>
{status ? (
<div className="flex shrink-0 items-center gap-2">{status}</div>
// `max-w-full` caps the slot at the row once it wraps under the
// title, so a status line longer than a phone row wraps inside
// the card instead of widening the page.
<div className="flex max-w-full shrink-0 items-center gap-2">
{status}
</div>
) : null}
</div>
{/* One sentence, one text run. This slot deliberately carries no
@@ -294,7 +294,7 @@ function AddProviderControl({
const [picked, setPicked] = useState<string>(addable[0] ?? "");
return (
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={t("settings.ai.providerChain.addProvider")}
value={picked}
@@ -869,7 +869,7 @@ function SortableWidgetRow({
style={style}
data-slot="widget-row"
data-dragging={isDragging ? "true" : undefined}
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
className={`border-border bg-background/30 flex min-h-12 flex-wrap items-center gap-2 rounded-md border px-3 py-2 sm:flex-nowrap ${
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
}`}
>
@@ -898,7 +898,12 @@ function SortableWidgetRow({
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
{labels.widgetLabel}
</span>
<div className="flex w-12 justify-center">
{/* On a phone the two switches and two arrows (~250 px) left the name
five characters ("Gewic…"). Below `sm` the controls wrap onto a
second line, right-aligned under the column heads, and the name
keeps the first line to itself. */}
<span className="basis-full sm:hidden" aria-hidden="true" />
<div className="flex w-12 justify-center max-sm:ml-auto">
<Switch
checked={tileChecked}
onCheckedChange={(v) => onToggleTile(widget.id, v)}
+1 -2
View File
@@ -234,8 +234,6 @@ export function LowStockCard({
disabled={!isAuthenticated || saving}
aria-label={t("notifications.lowStock.toggleAria")}
/>
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.lowStock.statusOn")
@@ -244,6 +242,7 @@ export function LowStockCard({
</label>
}
/>
<p className="text-sm">{t("notifications.lowStock.detail")}</p>
{enabled && (
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-days" className="text-sm font-medium">
@@ -193,8 +193,6 @@ export function MoodReminderCard({
disabled={!isAuthenticated || saving}
aria-label={t("notifications.moodReminder.toggleAria")}
/>
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.moodReminder.statusOn")
@@ -203,6 +201,7 @@ export function MoodReminderCard({
</label>
}
/>
<p className="text-sm">{t("notifications.moodReminder.detail")}</p>
{enabled && (
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
@@ -112,8 +112,11 @@ export function ReportScopePicker({
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. */}
<div className="flex items-start justify-between gap-3">
come back to the standard set after experimenting. On a phone the
action's own label is wider than half the row, so beside it the
sentence shrank to a one-word column and the button ran past the
card edge: there the action drops under the sentence instead. */}
<div className="flex items-start justify-between gap-3 max-sm:flex-col">
<p
id={hintId}
className="text-muted-foreground min-w-0 flex-1 text-xs"
@@ -237,7 +237,7 @@ function MetricRow({
}: MetricRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit } = useUnitDisplay();
const canonicalBounds = METRIC_BOUNDS[metric];
// v1.32.27 — the row reads and writes in the user's preferred unit
@@ -246,12 +246,14 @@ function MetricRow({
// canonical, so it converts on the way into the fields and inverts on
// the way back out in `onSave`. Guardrails round inward so a value
// typed at the displayed limit still passes the server's canonical
// check. Metrics without a transform — and every metric for a metric
// user — take the adapter's identity path untouched.
// check. The glucose thresholds read in the reader's glucose unit the
// same way. Metrics without a transform — and every metric on the default
// preferences — take the adapter's identity path untouched.
const units = resolveTargetUnitAdapter(
metric,
canonicalBounds.unit,
preference,
glucoseUnit,
);
const bounds = { ...units.bounds(canonicalBounds), unit: units.unit };
+26 -39
View File
@@ -17,8 +17,11 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import {
isGlucoseTargetMetric,
resolveTargetUnitAdapter,
} from "@/lib/targets/target-unit-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
@@ -129,49 +132,33 @@ function TargetEditSheetBody({
const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null;
const isDerivedMetric = !isBp && metric == null;
// Glucose is the one metric whose display unit can differ from the
// canonical storage unit: HealthLog stores mg/dL, but a user on the
// mmol/L preference sees — and types — mmol/L. The parent already
// hands us `unit="mmol/L"` and a `initialRange` pre-converted to that
// unit. We must therefore (a) seed the mg/dL persisted override into
// the display unit, (b) validate the typed value against bounds
// expressed in the display unit, and (c) convert the typed value back
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
const isGlucoseMmol =
metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L";
// v1.32.27 — the same coupled treatment now covers the metric/imperial
// preference. Weight-class thresholds are stored in kg; an imperial
// user seeds, validates, and types in lb, and the adapter inverts the
// typed number back to kg before the PUT. Glucose keeps its own
// mg/dL ↔ mmol/L dialect (it carries no display transform, so the two
// never compose); every other metric takes the adapter's identity
// path and is numerically untouched.
const { preference } = useUnitDisplay();
// One adapter owns every direction of the unit conversion: an imperial
// user seeds, validates and types weight-class thresholds in pounds, an
// mmol/L user types glucose thresholds in mmol/L, and the typed number is
// inverted back to the canonical kg / mg/dL before the PUT. The parent
// hands `unit` and `initialRange` already in the display unit; the
// persisted override and `METRIC_BOUNDS` are canonical and go through the
// adapter. Every other metric takes the identity path.
// A glucose sheet speaks the unit its panel registered, so the sheet and
// the band it edits can never disagree.
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? "");
const units = resolveTargetUnitAdapter(
isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""),
adapterKey,
unit,
preference,
isGlucoseTargetMetric(adapterKey)
? resolveGlucoseUnit(unit)
: accountGlucoseUnit,
);
const toDisplay = (canonical: number) =>
isGlucoseMmol
? convertGlucose(canonical, "mmol/L")
: units.toDisplay(canonical);
const toCanonical = (displayValue: number) =>
isGlucoseMmol
? toCanonicalMgdl(displayValue, "mmol/L")
: units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the
// display unit. Glucose projects its mg/dL window through the same
// conversion the fields use; every other metric rounds the window
// INWARD so a value typed at the display bound never inverts to a
// canonical value the server rejects.
const toDisplay = (canonical: number) => units.toDisplay(canonical);
const toCanonical = (displayValue: number) => units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the display
// unit and rounded INWARD so a value typed at the display bound never
// inverts to a canonical value the server rejects.
const toDisplayBounds = (canonical: { min: number; max: number }) =>
isGlucoseMmol
? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) }
: units.bounds(canonical);
units.bounds(canonical);
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
+53 -15
View File
@@ -26,6 +26,15 @@ import { cn } from "@/lib/utils";
* shells hand-rolled the whole block to get the second copy, which is how
* they drifted apart from every module page in the first place.
*/
/**
* A one-word German title ("Krankheitstagebuch") is wider than the column a
* phone leaves beside the actions, and without a break opportunity it ran
* underneath them. `hyphens-auto` breaks it by the page's `lang`; the
* `wrap-break-word` floor covers a word the hyphenation dictionary does not.
*/
const HEADING_CLASS =
"min-w-0 text-2xl font-bold tracking-tight hyphens-auto wrap-break-word";
export function PageHeader({
title,
titleId,
@@ -34,6 +43,7 @@ export function PageHeader({
backLink,
topSlot,
actions,
stackActionsOnPhone = false,
className,
}: {
title: ReactNode;
@@ -44,37 +54,65 @@ export function PageHeader({
/** Optional block above the title (e.g. a hub back-link the shell owns). */
topSlot?: ReactNode;
actions?: ReactNode;
/**
* Below `sm`, move the actions onto their own row under the description.
* For text-labelled actions (two outline links, say): beside the title they
* left the H1 a column narrower than its own word. Icon actions fit beside
* the title and leave this off.
*/
stackActionsOnPhone?: boolean;
className?: string;
}) {
const heading =
headingAs === "div" ? (
<div
id={titleId}
role="heading"
aria-level={1}
className="text-2xl font-bold tracking-tight"
>
<div id={titleId} role="heading" aria-level={1} className={HEADING_CLASS}>
{title}
</div>
) : (
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
<h1 id={titleId} className={HEADING_CLASS}>
{title}
</h1>
);
// Title, description and actions share one grid. On a phone the
// description spans the full row under the title + actions: a header with
// three or four icon actions otherwise left the sentence a ~130 px column
// that wrapped five or six times. From `sm` up the actions span both rows,
// so the description sits beside them exactly as before.
return (
<div className={cn("space-y-1.5", className)}>
{backLink ? <BackLink {...backLink} /> : null}
{topSlot}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
{heading}
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
<div
data-slot="page-header-row"
className={cn(
"grid items-start gap-x-3 gap-y-1.5",
actions ? "grid-cols-[minmax(0,1fr)_auto]" : "grid-cols-1",
actions && stackActionsOnPhone && "max-sm:grid-cols-1",
)}
>
{heading}
{actions ? (
<div className="flex shrink-0 items-center gap-2">{actions}</div>
<div
className={cn(
"col-start-2 row-start-1 flex shrink-0 items-center gap-2",
description && "sm:row-span-2",
stackActionsOnPhone &&
"max-sm:col-span-full max-sm:col-start-1 max-sm:row-start-3 max-sm:flex-wrap",
)}
>
{actions}
</div>
) : null}
{description ? (
<p
className={cn(
"text-muted-foreground text-sm",
actions && "col-span-full sm:col-span-1",
)}
>
{description}
</p>
) : null}
</div>
</div>
+15 -5
View File
@@ -23,13 +23,21 @@ import {
getDisplayTransform,
invertDisplayTransform,
hasDisplayTransform,
resolveUnitPreference,
type DisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
export interface UnitDisplay {
/** Resolved preference ("metric" by default / on a stale payload). */
preference: UnitPreference;
/**
* Resolved glucose unit ("mg/dL" by default). Carried beside the
* metric/imperial branch so a surface that edits or renders glucose reads
* both preferences from the one hook.
*/
glucoseUnit: GlucoseUnit;
/** The full transform for a type under the current preference. */
transformFor: (type: string) => DisplayTransform;
/** Convert a raw canonical ABSOLUTE value to the display value. */
@@ -48,7 +56,7 @@ export interface UnitDisplay {
export function useUnitDisplay(): UnitDisplay {
const { user } = useAuth();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
/**
@@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay {
*/
export function useUnitDisplayOnceMounted(): UnitDisplay {
const user = useAccountOnceMounted();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
function useDisplayForPreference(
rawPreference: string | null | undefined,
rawGlucoseUnit: string | null | undefined,
): UnitDisplay {
const preference: UnitPreference =
rawPreference === "imperial" ? "imperial" : "metric";
const preference = resolveUnitPreference(rawPreference);
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
return useMemo<UnitDisplay>(() => {
const transformFor = (type: string) =>
getDisplayTransform(type, preference);
return {
preference,
glucoseUnit,
transformFor,
toDisplay: (type, rawValue) =>
applyDisplayTransform(rawValue, transformFor(type)),
@@ -94,5 +104,5 @@ function useDisplayForPreference(
decimalsFor: (type) => transformFor(type).decimals,
isTransformed: (type) => hasDisplayTransform(type),
};
}, [preference]);
}, [preference, glucoseUnit]);
}
@@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => {
expect(text).not.toContain("450 h");
});
it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => {
// The measurement table used to take BLOOD_GLUCOSE's canonical unit and
// number, so an mmol/L account read "99.0 mg/dL" two lines above its own
// context rows in mmol/L.
const data = makeData({
glucoseUnit: "mmol/L",
stats: {
...makeData().stats,
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
},
});
const bytes = renderDoctorReportPdfBytes(data, {
timeFormat: "AUTO",
dateFormat: "AUTO",
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("5.5 mmol/L");
expect(text).toContain("6.0 mmol/L");
expect(text).not.toContain("mg/dL");
});
it("omits the sleep vitals row when there are no sleep stats", async () => {
const bytes = renderDoctorReportPdfBytes(makeData(), {
timeFormat: "AUTO",
@@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest";
import { buildInterpretationBlock } from "../interpretation-block";
describe("buildInterpretationBlock", () => {
it("classifies on the canonical value but prints in the reader's unit", () => {
const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10;
const block = buildInterpretationBlock({
metricKey: "BODY_TEMPERATURE",
value: 36.8,
sex: null,
locale: "en",
display: { unit: "°F", convert: toF },
});
expect(block).toBeDefined();
expect(block).toContain("Current value 98.2 °F");
expect(block).not.toContain("°C");
// Same band as the canonical block names.
const canonical = buildInterpretationBlock({
metricKey: "BODY_TEMPERATURE",
value: 36.8,
sex: null,
locale: "en",
})!;
const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1];
expect(band(block!)).toBe(band(canonical));
});
it("renders the band position for visceral fat 2.7 (the headline case)", () => {
const en = buildInterpretationBlock({
metricKey: "VISCERAL_FAT",
+21 -11
View File
@@ -130,9 +130,17 @@ function proximityPhrase(
*/
export function buildInterpretationBlock(args: {
metricKey: string;
/** The value in the guideline's own (canonical) unit — what is classified. */
value: number;
sex: "MALE" | "FEMALE" | null | undefined;
locale: Locale;
/**
* Print the block in the reader's unit. Classification always runs on the
* canonical value against the canonical bands; only the printed numbers and
* the unit symbol are converted, so a prompt whose snapshot reads °F does
* not carry a band table in °C beside it.
*/
display?: { unit: string; convert: (canonical: number) => number };
}): string | undefined {
const resolved = resolveInterpretation(args.metricKey, args.sex ?? null);
if (!resolved) return undefined;
@@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: {
const loc: "en" | "de" = instructionLocale(args.locale);
const pos = classifyBandPosition(args.value, resolved.bands);
const rows = resolved.bands
const show = (n: number) => (args.display ? args.display.convert(n) : n);
const unit = args.display ? args.display.unit : resolved.unit;
const shownBands = resolved.bands.map((band) => ({
...band,
upTo: band.upTo === null ? null : show(band.upTo),
}));
const rows = shownBands
.map((band, i) =>
bandRow(
band,
i > 0 ? resolved.bands[i - 1].upTo : null,
resolved.unit,
loc,
),
bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc),
)
.map((r) => ` - ${r}`)
.join("\n");
const proximity = proximityPhrase(
pos.proximity,
pos.nearestEdge,
resolved.unit,
pos.nearestEdge === null ? null : show(pos.nearestEdge),
unit,
loc,
);
const valence = valencePhrase(pos.band.valence, loc);
@@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: {
if (loc === "en") {
return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute):
- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}.
- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}.
- Guideline bands (${resolved.source}; ${direction}):
${rows}
- Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat}
- Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`;
}
return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen):
- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}.
- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}.
- Leitlinien-Bänder (${resolved.source}; ${direction}):
${rows}
- Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat}
+24 -5
View File
@@ -139,6 +139,26 @@ export function resolveRestingPulseSeries(input: {
pulseSamples: ReadonlyArray<PulseSample>;
/** Day key for the proxy buckets: the user's own day. */
dayKeyOf: (d: Date) => string;
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
return mergeRestingWithProxy({
restingSamples: input.restingSamples,
proxySamples: deriveRestingProxyFromPulse(
input.pulseSamples,
input.dayKeyOf,
),
dayKeyOf: input.dayKeyOf,
});
}
/**
* {@link resolveRestingPulseSeries} for a caller that already holds the
* proxy, one point per day, as `readRestingPulseProxy` folds it in Postgres.
* The per-day merge and the label are the same.
*/
export function mergeRestingWithProxy(input: {
restingSamples: ReadonlyArray<PulseSample>;
proxySamples: ReadonlyArray<PulseSample>;
dayKeyOf: (d: Date) => string;
}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } {
const dayKeyOf = input.dayKeyOf;
const byTime = (a: PulseSample, b: PulseSample) =>
@@ -148,14 +168,13 @@ export function resolveRestingPulseSeries(input: {
// Days already covered by a real resting row — the proxy must not
// second-guess them.
const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt)));
const proxy = deriveRestingProxyFromPulse(
input.pulseSamples,
dayKeyOf,
).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt)));
const proxy = input.proxySamples.filter(
(p) => !restingDays.has(dayKeyOf(p.measuredAt)),
);
if (resting.length === 0) {
return proxy.length > 0
? { series: proxy, which: "proxy" }
? { series: [...proxy], which: "proxy" }
: { series: [], which: "none" };
}
if (proxy.length === 0) return { series: resting, which: "resting" };
@@ -240,6 +240,7 @@ function score(args: {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: args.modules,
healthScoreConfigJson: args.healthScoreConfigJson,
@@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) {
timezone: "UTC",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: {
glucose: true,
@@ -14,6 +14,7 @@ const live = {
source: "live" as const,
readFailed: false,
timezone: "Europe/Berlin",
glucoseUnit: "mg/dL" as const,
};
function days(count: number, value: number) {
@@ -160,6 +161,41 @@ describe("reference-score pillars", () => {
expect(eligible.status).toBe("ok");
});
it("writes the fasting labels in the reader's glucose unit, value and band canonical", () => {
const fasting = days(8, 99).map((point) => ({
value: point.value,
at: new Date(`${point.day}T08:00:00Z`),
source: "APPLE_HEALTH",
}));
const mmol = computeGlycaemiaPillar({
...live,
glucoseUnit: "mmol/L",
asOf: NOW,
hba1c: [],
fastingGlucose: fasting,
});
const mgdl = computeGlycaemiaPillar({
...live,
asOf: NOW,
hba1c: [],
fastingGlucose: fasting,
});
expect(mmol.status).toBe("ok");
expect(mgdl.status).toBe("ok");
if (mmol.status !== "ok" || mgdl.status !== "ok") return;
// These labels reach the app as finished text.
expect(mmol.value.observed.label).toBe("Fasting median 5.5 mmol/L");
expect(mmol.value.reference.label).toBe("3.9–5.5 mmol/L fasting");
expect(mgdl.value.observed.label).toBe("Fasting median 99 mg/dL");
expect(mgdl.value.reference.label).toBe("70–99 mg/dL fasting");
// The score and the numbers the web converts from do not move.
expect(mmol.value.observed.value).toBe(99);
expect(mmol.value.observed.unit).toBe("mg/dL");
expect(mmol.value.reference.low).toBe(70);
expect(mmol.value.reference.high).toBe(99);
expect(mmol.value.score).toBe(mgdl.value.score);
});
it("does not use fasting fallback when the preferred lab read failed", () => {
const result = computeGlycaemiaPillar({
...live,
@@ -137,6 +137,7 @@ describe("score reader bounds and source parity", () => {
timezone: "Europe/Berlin",
sourcePriorityJson: null,
thresholdsJson: null,
glucoseUnit: "mg/dL" as const,
},
modules: {
glucose: true,
@@ -233,6 +233,7 @@ const live = {
source: "live" as const,
readFailed: false,
timezone: "Europe/Berlin",
glucoseUnit: "mg/dL" as const,
};
function days(count: number, value: number) {
+3
View File
@@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets";
import { resolveModuleMap } from "@/lib/modules/gate";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { computeAndRecordUserHealthScore } from "./record";
import type { CompositeValue } from "./types";
@@ -23,6 +24,7 @@ export async function computeHealthScoreDerived(
timezone: true,
thresholdsJson: true,
healthScoreConfigJson: true,
glucoseUnit: true,
},
});
const now = new Date();
@@ -59,6 +61,7 @@ export async function computeHealthScoreDerived(
timezone,
sourcePriorityJson,
thresholdsJson: user.thresholdsJson,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: modules.glucose !== false,
+6 -2
View File
@@ -1,4 +1,5 @@
import type { Derived } from "@/lib/insights/derived/types";
import { convertGlucose } from "@/lib/glucose";
import type { GlycaemiaPillarInput, PillarValue } from "./types";
import {
@@ -153,10 +154,13 @@ export function computeGlycaemiaPillar(
FASTING_GLUCOSE_REFERENCE.low,
FASTING_GLUCOSE_REFERENCE.high,
),
// `value`, `unit` and the band stay canonical: the score is computed on
// them and the web converts from them. The labels are finished text a
// client prints as-is, so they are written in the reader's unit.
observed: {
value: observed,
unit: "mg/dL",
label: `Fasting median ${Math.round(observed)} mg/dL`,
label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`,
asOf: fasting.at(-1)!.at.toISOString(),
sources: uniqueSources(fasting.map((row) => row.source)),
},
@@ -164,7 +168,7 @@ export function computeGlycaemiaPillar(
kind: "clinical-threshold",
low: FASTING_GLUCOSE_REFERENCE.low,
high: FASTING_GLUCOSE_REFERENCE.high,
label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`,
label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`,
source: "ADA 2025; Selvin 2010",
},
noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)),
+8
View File
@@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types";
import { computeWeightGoal } from "./weight-goal";
import { DAY_MS, uniqueSources } from "./shared";
import type { DerivedProvenanceSource } from "@/lib/insights/derived/types";
import type { GlucoseUnit } from "@/lib/glucose";
const HISTORY_OFFSET_DAYS = 14;
const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
@@ -52,6 +53,12 @@ export interface ScoreReaderProfile {
timezone: string;
sourcePriorityJson: unknown;
thresholdsJson: unknown;
/**
* The record's glucose unit, for the glycaemia pillar's display labels.
* Required: those labels are served as finished text (the app prints them
* verbatim), and a caller that forgets the unit must not get mg/dL.
*/
glucoseUnit: GlucoseUnit;
}
export interface UserHealthScoreInput {
@@ -513,6 +520,7 @@ function scoreInputsFor(args: {
},
GLYCAEMIA: {
source: "live",
glucoseUnit: input.profile.glucoseUnit,
readFailed:
(!input.modules.glucose || !args.glucose.ok) &&
(!input.modules.labs || !args.labs.ok),
+7
View File
@@ -8,6 +8,7 @@ import type {
// runtime cycle. The tier's definition belongs beside the rule that
// produces it; its NAME belongs here, where the wire shape is declared.
import type { ScoreBreadthTier } from "./breadth";
import type { GlucoseUnit } from "@/lib/glucose";
/**
* The scoring method's identity.
@@ -346,6 +347,12 @@ export interface GlucoseReading {
export interface GlycaemiaPillarInput extends DomainReadState {
asOf: Date;
/**
* The unit the fasting-glucose labels are written in. The scored value,
* its `unit` and the reference band stay canonical mg/dL; only the two
* human-readable labels follow the reader.
*/
glucoseUnit: GlucoseUnit;
hba1cReadFailed?: boolean;
fastingReadFailed?: boolean;
hba1c: HbA1cReading[];
+66 -30
View File
@@ -390,19 +390,29 @@ export interface RotationResult {
dropped: number;
}
/** Re-encrypt one column's stale rows to the active key. */
/** Thrown out of a column walk when the caller's time budget is spent. */
class RotationStopped extends Error {}
/**
* Re-encrypt one column's stale rows to the active key. `shouldStop` is the
* caller's time budget, checked between rows; a stopped column reports
* `stopped` and the rows it did not reach stay on their old key for the next
* run, which skips every row already rotated.
*/
export async function rotateColumn(
client: CorpusClient,
col: EncryptedColumn,
): Promise<RotationResult> {
shouldStop: () => boolean = () => false,
): Promise<RotationResult & { stopped: boolean }> {
const delegate = getDelegate(client, col.model);
const result: RotationResult = {
const result: RotationResult & { stopped: boolean } = {
model: col.model,
field: col.field,
scanned: 0,
rotated: 0,
errors: 0,
dropped: 0,
stopped: false,
};
/**
@@ -432,6 +442,10 @@ export async function rotateColumn(
// (`convertSingleValueBackup`). A row converted, replaced or deleted since
// it was listed is gone, not an error.
for await (const id of singleValueIds(delegate)) {
if (shouldStop()) {
result.stopped = true;
break;
}
result.scanned += 1;
try {
const outcome = await convertSingleValueBackup(
@@ -452,31 +466,37 @@ export async function rotateColumn(
// Idempotent — rows already on the active key are skipped, so an
// interrupted run resumes cleanly on the next invocation. `scanned` counts
// the rows that hold ciphertext, whatever the column.
await walkColumn(delegate, col, async ({ id, value, codec }) => {
result.scanned += 1;
if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return;
try {
// The plaintext is never inspected, only re-sealed, so every envelope
// a stored value can legitimately carry survives rotation untouched.
const next =
codec !== null
? reencryptBlob(value as Uint8Array, codec, col.aad)
: fromCiphertext(
encrypt(decrypt(toCiphertext(value, col.kind)!)),
col.kind,
);
await delegate.update({
where: { [pkField(col)]: id },
data: { [col.field]: next },
});
result.rotated += 1;
} catch (err) {
// Deleted between the read and the write (a backup replacing its
// pieces, an account going): nothing is left to rotate.
if (isRowGone(err)) return;
await onUnreadable(id);
}
});
try {
await walkColumn(delegate, col, async ({ id, value, codec }) => {
if (shouldStop()) throw new RotationStopped();
result.scanned += 1;
if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return;
try {
// The plaintext is never inspected, only re-sealed, so every envelope
// a stored value can legitimately carry survives rotation untouched.
const next =
codec !== null
? reencryptBlob(value as Uint8Array, codec, col.aad)
: fromCiphertext(
encrypt(decrypt(toCiphertext(value, col.kind)!)),
col.kind,
);
await delegate.update({
where: { [pkField(col)]: id },
data: { [col.field]: next },
});
result.rotated += 1;
} catch (err) {
// Deleted between the read and the write (a backup replacing its
// pieces, an account going): nothing is left to rotate.
if (isRowGone(err)) return;
await onUnreadable(id);
}
});
} catch (err) {
if (!(err instanceof RotationStopped)) throw err;
result.stopped = true;
}
return result;
}
@@ -488,16 +508,31 @@ export interface CorpusRotation {
totalErrors: number;
/** Unreadable rows deleted from `disposable` columns. */
totalDropped: number;
/**
* The time budget ran out before every column was walked. Running again
* finishes the job: rows already on the active key are skipped.
*/
stoppedEarly: boolean;
}
/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */
/**
* Re-encrypt the whole corpus to the active key. Idempotent + active-key-only.
* `shouldStop` is the caller's time budget (`jobBudget` in the worker).
*/
export async function rotateCorpus(
client: CorpusClient,
shouldStop: () => boolean = () => false,
): Promise<CorpusRotation> {
const activeKeyId = getActiveKeyId();
const results: RotationResult[] = [];
let stoppedEarly = false;
for (const col of ENCRYPTED_COLUMNS) {
results.push(await rotateColumn(client, col));
const { stopped, ...result } = await rotateColumn(client, col, shouldStop);
results.push(result);
if (stopped) {
stoppedEarly = true;
break;
}
}
let totalScanned = 0;
let totalRotated = 0;
@@ -516,5 +551,6 @@ export async function rotateCorpus(
totalRotated,
totalErrors,
totalDropped,
stoppedEarly,
};
}
+2 -1
View File
@@ -49,7 +49,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -790,6 +790,7 @@ async function buildExtras(
timezone: userTz,
sourcePriorityJson: scoreSourcePriority,
thresholdsJson: user.thresholdsJson,
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
},
modules: {
glucose: modules.glucose !== false,
+11 -25
View File
@@ -19,7 +19,11 @@ import {
REPORT_GROUPS,
} from "./report-selection/catalogue";
import { getUnitForType } from "./validations/measurement";
import {
DOCTOR_REPORT_TYPE_UNIT_KEYS,
reportStatUnit,
} from "./doctor-report/stat-display";
import { resolveGlucoseUnit } from "./glucose";
import {
makeFormatters,
type DateFormatPreference,
@@ -175,18 +179,9 @@ export interface DoctorReportRenderOptions {
includeCharts?: boolean;
}
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
// The unit map moved beside the clinician view's reader of it; re-exported so
// the enum-coverage test keeps one import.
export { DOCTOR_REPORT_TYPE_UNIT_KEYS };
/**
* The measurement table is driven by the selection, not by a whitelist.
@@ -238,18 +233,9 @@ export function buildDoctorReportPdfDocument(
const footerTz =
userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
const unitFor = (type: string): string => {
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
// Every other type falls back to the canonical unit the measurement
// validation layer already records. A type with no recorded unit prints
// none — an absent unit is absent, not a guessed symbol.
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
};
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
const unitFor = (type: string): string =>
reportStatUnit(type, glucoseUnit, t);
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
patchPdfTextSanitiser(doc);
@@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import { reportStatValue } from "../doctor-report/stat-display";
import type { MeasurementType } from "@/generated/prisma/client";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
import {
@@ -235,12 +236,9 @@ export function buildMeasurementsChartsSection(
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
// SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data
// layer reconstructs them for exactly this row); the vitals unit is
// hours (`h`), so convert to keep value and unit consistent — the same
// hours value the FHIR sleep Observation emits.
const conv =
type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v;
// Sleep minutes print as hours and glucose in the owner's unit; the
// clinician view reads the same helper, so the two cannot drift.
const conv = (v: number) => reportStatValue(type, v, glucoseUnit);
groupRows.push([
t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
`${num(conv(s.latest))} ${unit}`.trim(),
@@ -49,6 +49,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => {
return Promise.resolve(rows[name] ?? []);
},
count: () => Promise.resolve(0),
groupBy: () => Promise.resolve([]),
findUnique: () => {
calls.push(name);
return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null);
+46 -18
View File
@@ -78,8 +78,21 @@ import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/**
* #1023 — inside the raw window, pulse takes the per-day path anyway once the
* window holds more readings than this.
* The types a device writes at sampling rate whose report sections are
* statistics and a series and nothing else: the figures the day path keeps
* exactly (count, mean, minimum, maximum, latest) plus one point per local
* day. Glucose is dense too but has its own rule below, because its clinical
* panel needs the per-reading counts the bucket query carries.
*/
const DENSE_STAT_TYPES = [
"PULSE",
"HEART_RATE_VARIABILITY",
"OXYGEN_SATURATION",
] as const satisfies readonly MeasurementType[];
/**
* #1023 — inside the raw window, a dense statistics type takes the per-day
* path anyway once the window holds more of its readings than this.
*
* A watch that records heart rate once a minute puts 130 000 readings in a
* 90-day report. Read raw, every one of them became an object, went through
@@ -171,22 +184,38 @@ export async function collectDoctorReportData(
const lastDay = dayKeyAsUtcMidnight(userDayKey(end, reportTz));
const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS;
const densePulse =
!excluded.includes("PULSE") &&
(aggregateDenseTypes ||
(await prisma.measurement.count({
where: {
userId,
type: "PULSE",
measuredAt: { gte: start, lte: end },
deletedAt: null,
},
})) > DENSE_REPORT_RAW_ROW_CAP);
const includedDenseStatTypes = DENSE_STAT_TYPES.filter(
(type) => !excluded.includes(type),
);
// Inside the raw window, one grouped count decides each type on its own:
// a watch's minute-by-minute heart rate folds while a few blood-oxygen
// readings a night beside it stay raw.
const denseStatCounts =
aggregateDenseTypes || includedDenseStatTypes.length === 0
? null
: new Map(
(
await prisma.measurement.groupBy({
by: ["type"],
where: {
userId,
type: { in: [...includedDenseStatTypes] },
measuredAt: { gte: start, lte: end },
deletedAt: null,
},
_count: { _all: true },
})
).map((row) => [row.type, row._count._all]),
);
const denseTypes: MeasurementType[] = includedDenseStatTypes.filter(
(type) =>
denseStatCounts === null ||
(denseStatCounts.get(type) ?? 0) > DENSE_REPORT_RAW_ROW_CAP,
);
const denseGlucose =
aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE");
const rawExcluded = [...excluded];
if (densePulse) rawExcluded.push("PULSE");
if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE");
if (denseGlucose) denseTypes.push("BLOOD_GLUCOSE");
const rawExcluded = [...excluded, ...denseTypes];
const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf));
const wantsIntakeEvents =
@@ -319,8 +348,7 @@ export async function collectDoctorReportData(
start,
end,
reportTz,
densePulse,
denseGlucose,
denseTypes,
}),
]);
+15 -16
View File
@@ -1,16 +1,17 @@
/**
* The per-local-day pre-aggregation for the two sample-dense measurement
* types.
* The per-local-day pre-aggregation for the sample-dense measurement types.
*
* On a long window a CGM or a per-sample heart-rate account produces six
* figures of rows, so pulse and glucose collapse to one row per local
* day/source/device/context in SQL and the canonical-source picker then works
* over that bounded intermediate set. Every other type keeps the raw-row path.
* figures of rows, so the dense types (glucose, pulse, heart-rate variability,
* blood oxygen) collapse to one row per local day/source/device/context in
* SQL and the canonical-source picker then works over that bounded
* intermediate set. Every other type keeps the raw-row path.
*
* Extracted from the aggregator with the selection rework: the query is a job
* of its own, it is the one raw-SQL surface on this path, and keeping it in a
* thousand-line function is how a parameter stops being reviewed.
*/
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type {
GlucoseContext,
@@ -46,8 +47,8 @@ export interface DenseMeasurementBucket {
}
/**
* Load the dense buckets for whichever of the two types the caller wants. Both
* flags false is not a call: the caller skips it entirely.
* Load the dense buckets for the types the caller names. An empty list is not
* a call: nothing is read.
*
* Every value is bound as a tagged-template parameter — no splice, no
* interpolation of anything that came off the wire.
@@ -57,11 +58,13 @@ export async function loadDenseMeasurementBuckets(params: {
start: Date;
end: Date;
reportTz: string;
densePulse: boolean;
denseGlucose: boolean;
denseTypes: readonly MeasurementType[];
}): Promise<DenseMeasurementBucket[]> {
const { userId, start, end, reportTz, densePulse, denseGlucose } = params;
if (!densePulse && !denseGlucose) return [];
const { userId, start, end, reportTz, denseTypes } = params;
if (denseTypes.length === 0) return [];
const typeList = Prisma.join(
denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`),
);
return prisma.$queryRaw<DenseMeasurementBucket[]>`
WITH localized AS (
SELECT
@@ -81,11 +84,7 @@ export async function loadDenseMeasurementBuckets(params: {
AND m."measured_at" >= ${start}
AND m."measured_at" <= ${end}
AND m."deleted_at" IS NULL
AND (
(${densePulse} AND m."type" = 'PULSE'::"measurement_type")
OR
(${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type")
)
AND m."type" IN (${typeList})
),
scored AS (
SELECT
+79
View File
@@ -0,0 +1,79 @@
/**
* How a measurement statistic reads in the clinician-facing artefacts: the
* PDF report and the shared clinician view.
*
* Both describe the same record to the same reader, so they take the unit and
* the displayed number from here and nowhere else. Before this module the PDF
* carried its own unit map and the clinician view printed bare numbers, so a
* doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with
* no unit at all, and for an account on mmol/L the glucose rows of the PDF's
* measurement table still printed raw mg/dL beside a mmol/L glucose panel.
*
* The policy, which `display-transform.ts` states for the metric/imperial
* preference: a document handed to a clinician stays SI for mass, length and
* temperature regardless of the owner's imperial preference. Glucose is the
* exception, because mmol/L versus mg/dL is not an imperial question — it is
* the clinical convention of the country the record lives in — so glucose
* follows the owner's `glucoseUnit` here as it does everywhere a person reads
* it.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
/**
* Report-specific unit symbols, where the report's own reading differs from
* the validation layer's canonical unit. `null` marks a unit that has to be
* translated (steps). Everything absent falls back to `getUnitForType`.
*/
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
/**
* The unit symbol a report statistic of `type` is printed with.
*
* `translate` resolves the one translated unit (steps). A type with no
* recorded unit prints none — an absent unit is absent, not a guessed symbol.
*/
export function reportStatUnit(
type: string,
glucoseUnit: GlucoseUnit,
translate: (key: string) => string,
): string {
if (type === "BLOOD_GLUCOSE") return glucoseUnit;
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return translate("doctorReport.unitSteps");
}
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
const canonical = getUnitForType(type);
return canonical === "unknown" ? "" : canonical;
}
/**
* The number a report statistic of `type` is printed as, in the unit
* {@link reportStatUnit} names.
*
* SLEEP_DURATION statistics are per-night asleep totals in minutes (the data
* layer reconstructs them for exactly this row) while the unit is hours, so
* they are divided — the same hours value the FHIR sleep Observation emits.
* Glucose is stored in mg/dL and converted to the owner's unit.
*/
export function reportStatValue(
type: string,
value: number,
glucoseUnit: GlucoseUnit,
): number {
if (type === "SLEEP_DURATION") return value / 60;
if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit);
return value;
}
@@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => {
expect(result.text).toContain("Steady.");
});
});
describe("generateMetricStatus — the note is written in the reader's units", () => {
function seed(values: (day: number) => number, days = 120) {
const now = new Date();
const records: Array<{ value: number; measuredAt: Date }> = [];
for (let day = 0; day < days; day++) {
records.push({
value: values(day),
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.measurement.count).mockResolvedValue(days as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
}
function snapshotOf(userPrompt: string) {
return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]);
}
it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "metric",
glucoseUnit: "mmol/L",
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("unit: mmol/L");
// 70–140 mg/dL is 3.9–7.8 mmol/L.
expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L");
// The shared safety contract quotes its own fixed thresholds; the metric
// block itself must not carry the canonical band.
expect(captured.systemPrompt).not.toContain("70–140 mg/dL");
const snapshot = snapshotOf(captured.userPrompt!);
expect(snapshot.metric.unit).toBe("mmol/L");
expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 });
const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map(
(b: { mean: number }) => b.mean,
);
expect(recentMeans.length).toBeGreaterThan(0);
for (const mean of recentMeans) {
expect(mean).toBeGreaterThan(5);
expect(mean).toBeLessThan(6.5);
}
expect(captured.userPrompt).not.toContain("mg/dL");
});
it("states body temperature in °F for an imperial reader, guideline band included", async () => {
seed((day) => 36.6 + (day % 3) * 0.1);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "imperial",
glucoseUnit: null,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BODY_TEMPERATURE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("unit: °F");
expect(captured.systemPrompt).not.toContain("36.1–37.2 °C");
// The interpretation block classifies in °C but prints in °F.
expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT");
expect(captured.userPrompt).not.toContain("°C");
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F");
});
it("writes the no-provider floor in the reader's glucose unit", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: "metric",
glucoseUnit: "mmol/L",
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never);
const result = await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(result.text).toMatch(/\d\.\d mmol\/L/);
expect(result.text).not.toContain("mg/dL");
});
it("leaves a metric reader on the default units byte-identical", async () => {
seed((day) => 99 + (day % 4) * 3);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
unitPreference: null,
glucoseUnit: null,
} as never);
const captured = { systemPrompt: null, userPrompt: null } as {
systemPrompt: string | null;
userPrompt: string | null;
};
stubCompletion('{"summary":"Steady."}', captured);
await generateMetricStatus({
metric: "BLOOD_GLUCOSE",
userId: "user-1",
locale: "en",
});
expect(captured.systemPrompt).toContain("70–140 mg/dL");
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL");
});
});
@@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => {
});
describe("numericDescriptorCopy", () => {
it("states a preference-bound slot in the unit the caller resolves", () => {
const d = computeTrendDescriptor(series([181.6, 178.5]), {
absoluteFloor: 0.66,
relativeFloor: 0.01,
decimals: 1,
})!;
const copy = numericDescriptorCopy("weight", d, (type) =>
type === "WEIGHT" ? "lb" : "?",
);
expect(copy?.params.unit).toBe(" lb");
expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe(
"WALKING_RUNNING_DISTANCE",
);
expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe(
"BODY_TEMPERATURE",
);
});
it("resolves the rising template with a signed delta and spaced unit", () => {
const d = computeTrendDescriptor(series([120, 128]), {
absoluteFloor: 2,
relativeFloor: 0.02,
decimals: 0,
})!;
const copy = numericDescriptorCopy("bp", d);
const copy = numericDescriptorCopy("bp", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.delta).toBe("+8");
expect(copy?.params.unit).toBe(" mmHg");
@@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.05,
decimals: 0,
})!;
const copy = numericDescriptorCopy("steps", d);
const copy = numericDescriptorCopy("steps", d, () => "unused");
expect(copy?.key).toBe("insights.trendDescriptor.rising");
expect(copy?.params.unit).toBe("");
});
@@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0.01,
decimals: 1,
})!;
const copy = numericDescriptorCopy("weight", d);
const copy = numericDescriptorCopy("weight", d, () => "kg");
expect(copy?.key).toBe("insights.trendDescriptor.falling");
expect(copy?.params.delta).toBe("−1.4");
});
it("returns null for a slot with no numeric meta (mood)", () => {
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
expect(numericDescriptorCopy("mood", d)).toBeNull();
expect(numericDescriptorCopy("mood", d, () => "")).toBeNull();
});
it("covers every numeric slot in the descriptor meta", () => {
@@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => {
relativeFloor: 0,
decimals: 1,
})!;
expect(numericDescriptorCopy(metric, d)).not.toBeNull();
expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull();
}
});
});
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
insightNarrative: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-invalidation", () => ({
enqueueStatusRefillForUser: vi.fn(),
}));
vi.mock("@/lib/jobs/period-narrative-shared", () => ({
enqueueNarrativeWarm: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
import { refreshTextsAfterUnitChange } from "../unit-change-refresh";
beforeEach(() => {
vi.resetAllMocks();
});
describe("refreshTextsAfterUnitChange", () => {
it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
locale: "de",
} as never);
vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12);
vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([
{ period: "week", locale: "de" },
{ period: "month", locale: "en" },
{ period: "year", locale: "de" },
{ period: "week", locale: "xx" },
] as never);
await refreshTextsAfterUnitChange("user-1");
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de");
expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2);
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
userId: "user-1",
period: "week",
locale: "de",
});
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
userId: "user-1",
period: "month",
locale: "en",
});
});
it("never throws into the preference write", async () => {
vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down"));
await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe(
undefined,
);
});
});
+29
View File
@@ -598,6 +598,35 @@ export function scaleGradedSeries(
};
}
/**
* Re-express a canonical graded series in a display unit: absolute bucket
* values (`min / max / mean`) take the full affine conversion, the yearly
* slope — a rate, not a level — takes the factor alone, exactly as
* `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are
* untouched. Used where a series is narrated in the reader's unit; the
* series it is built from stays canonical.
*/
export function convertGradedSeries(
series: GradedSeries,
conversion: { factor: number; offset?: number },
digits = 2,
): GradedSeries {
const offset = conversion.offset ?? 0;
const abs = (v: number) => round(v * conversion.factor + offset, digits);
const bucket = <T extends { min: number; max: number; mean: number }>(
b: T,
): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) });
return {
recent: series.recent.map(bucket),
weekly: series.weekly.map(bucket),
monthly: series.monthly.map(bucket),
yearly: series.yearly.map((b) => ({
...bucket(b),
slope: b.slope === null ? null : round(b.slope * conversion.factor, 4),
})),
};
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
+129 -16
View File
@@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
buildGradedSeriesFromPoints,
buildGradedSeriesWithRollups,
convertGradedSeries,
degradeStatusSnapshotToBudget,
type GradedSeries,
} from "@/lib/insights/graded-series";
import {
type SupportedLocale,
@@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { binaryReferenceSex } from "@/lib/profile/sex";
import {
applyDisplayTransform,
getReadingTransform,
invertDisplayTransform,
isUnitSensitiveType,
resolveUnitPreferences,
transformRescales,
type DisplayTransform,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
export interface MetricStatusResult {
hasProvider: boolean;
@@ -222,9 +234,40 @@ export async function generateMetricStatus(args: {
const user = await prisma.user.findUnique({
where: { id: args.userId },
select: { dateOfBirth: true, gender: true },
select: {
dateOfBirth: true,
gender: true,
unitPreference: true,
glucoseUnit: true,
},
});
// The note is written in the reader's units. The series, the reference band
// and the signal are converted once, here, before any of them reaches the
// prompt or the deterministic floor; the registry entry, the rollups and
// the guideline classification stay canonical. An untransformed metric (and
// every metric on the default preferences that does not rescale) passes
// through unchanged, so its snapshot hash does not move.
const reading = readingDisplay(
meta,
resolveUnitPreferences({
unitPreference: user?.unitPreference,
glucoseUnit: user?.glucoseUnit,
}),
);
const shownMeta: MetricStatusMeta = {
...meta,
unit: reading.unit,
...(meta.normalRange
? {
normalRange: {
low: reading.toDisplay(meta.normalRange.low),
high: reading.toDisplay(meta.normalRange.high),
},
}
: {}),
};
const now = new Date();
// Bounded raw read for the derived recent stats; the monthly/yearly
@@ -295,7 +338,10 @@ export async function generateMetricStatus(args: {
})
.then((r) => r.reverse());
for (const m of rows) {
points.push({ measuredAt: m.measuredAt, value: m.value });
points.push({
measuredAt: m.measuredAt,
value: reading.toDisplay(m.value),
});
}
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
}
@@ -309,11 +355,13 @@ export async function generateMetricStatus(args: {
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
const graded = isSleep
? buildGradedSeriesFromPoints(points, now, userTz)
: await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
userTz,
: reading.convertGraded(
await buildGradedSeriesWithRollups(
args.userId,
meta.measurementType,
now,
userTz,
),
);
const summary = summarizeSeries(
series.daily.map((bucket) => ({ value: bucket.value })),
@@ -344,7 +392,14 @@ export async function generateMetricStatus(args: {
// metric + profile; otherwise keep the existing flat anchor (strictly
// additive, never a regression). Age + sex only — no ancestry/region.
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
const normalRange = sharpenedRange ?? meta.normalRange;
const canonicalRange = sharpenedRange ?? meta.normalRange;
const normalRange = canonicalRange
? {
...canonicalRange,
low: reading.toDisplay(canonicalRange.low),
high: reading.toDisplay(canonicalRange.high),
}
: canonicalRange;
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
// normal-swing verdict, computed server-side so the model states it rather
@@ -353,7 +408,7 @@ export async function generateMetricStatus(args: {
// when the recent window is empty, in which case it is omitted.
const signal = buildMetricSignal({
metric: meta.displayName,
unit: meta.unit,
unit: shownMeta.unit,
direction: meta.direction,
graded,
normalRange: normalRange ?? null,
@@ -369,7 +424,7 @@ export async function generateMetricStatus(args: {
metric: {
id: meta.id,
displayName: meta.displayName,
unit: meta.unit,
unit: shownMeta.unit,
direction: meta.direction,
...(normalRange ? { normalRange } : {}),
...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}),
@@ -477,14 +532,24 @@ export async function generateMetricStatus(args: {
// window exists); absent for uncovered metrics (fail-soft to personal-
// relative). Sex-split bands (waist) resolve only when the profile carries a
// sex.
// The guideline bands are canonical, so the band position is classified on
// the canonical value; the block then prints in the reader's unit.
const interpretationValue = signal?.current ?? latest?.value ?? null;
const interpretationBlock =
interpretationValue !== null
? buildInterpretationBlock({
metricKey: meta.id,
value: interpretationValue,
value: reading.toCanonical(interpretationValue),
sex,
locale,
...(reading.converts
? {
display: {
unit: reading.unit,
convert: reading.toDisplay,
},
}
: {}),
})
: undefined;
@@ -502,9 +567,9 @@ export async function generateMetricStatus(args: {
userId: args.userId,
cacheAction,
capability: "statusText",
systemPrompt: getMetricArchetypeSystemPrompt(meta, locale),
systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale),
userPrompt: getMetricArchetypeUserPrompt(
meta,
shownMeta,
snapshotJson,
todayKey,
locale,
@@ -526,7 +591,7 @@ export async function generateMetricStatus(args: {
if (outcome.kind === "none") {
return {
hasProvider: false,
text: getNoKeyMetricStatusText(locale, signal),
text: getNoKeyMetricStatusText(locale, signal, reading.decimals),
cached: true,
updatedAt: null,
};
@@ -537,7 +602,7 @@ export async function generateMetricStatus(args: {
reason: outcome.kind,
userId: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal),
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
@@ -553,7 +618,7 @@ export async function generateMetricStatus(args: {
reason: "screened",
userId: args.userId,
todayKey,
stubText: getNoKeyMetricStatusText(locale, signal),
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
});
}
const text = screened.text;
@@ -577,3 +642,51 @@ export function resolveMetricStatusLocale(
): SupportedLocale {
return normalizeLocale(locale);
}
/**
* How one metric's numbers are written for this reader: the unit symbol, the
* conversions in both directions, and the decimals a value is read at.
*
* Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the
* metric/imperial set) converts; every other metric keeps its registry unit
* and its stored numbers. A transform that does not rescale (`converts`
* false) is the identity, so the default preferences leave a snapshot
* byte-identical for the metrics whose registry unit already is the display
* unit.
*/
function readingDisplay(
meta: MetricStatusMeta,
preferences: UnitPreferences,
): {
unit: string;
decimals: number | undefined;
converts: boolean;
toDisplay: (canonical: number) => number;
toCanonical: (display: number) => number;
convertGraded: (series: GradedSeries) => GradedSeries;
} {
if (!isUnitSensitiveType(meta.measurementType)) {
return {
unit: meta.unit,
decimals: undefined,
converts: false,
toDisplay: (v) => v,
toCanonical: (v) => v,
convertGraded: (series) => series,
};
}
const transform: DisplayTransform = getReadingTransform(
meta.measurementType,
preferences,
);
const converts = transformRescales(transform);
return {
unit: transform.displayUnit,
decimals: transform.decimals,
converts,
toDisplay: (v) => applyDisplayTransform(v, transform),
toCanonical: (v) => invertDisplayTransform(v, transform),
convertGraded: (series) =>
converts ? convertGradedSeries(series, transform) : series,
};
}
@@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
expect(sleepDelta).toBeDefined();
// The pre-fix local `toDailyMeans` twin would have averaged the three
// segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no
// clinical meaning. The correct grain is the night's TOTAL (410).
expect(sleepDelta!.current).toBe(410);
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0);
// clinical meaning. The correct grain is the night's TOTAL (410 min),
// narrated in the hours its unit names: this used to read "410 h".
expect(sleepDelta!.unit).toBe("h");
expect(sleepDelta!.current).toBe(6.83);
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1);
});
it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => {
@@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => {
bandTransitions: [
{
type: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
center: 140,
bandLow: 110,
bandHigh: 130,
@@ -9,6 +9,9 @@ import type {
DailySeriesPoint,
NamedSeries,
} from "@/lib/insights/correlation-discovery";
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
import { buildDeterministicNarrative } from "../period-narrative-deterministic";
import { buildNarrativeUserPrompt } from "../period-narrative-generate";
/**
* Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point
@@ -42,6 +45,7 @@ function monthInput(
discoverySeries,
locale: "en",
computedAt: "2026-04-30T12:00:00.000Z",
units: DEFAULT_UNIT_PREFERENCES,
};
}
@@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => {
});
});
describe("assemblePeriodNarrativeContext — the reader's units", () => {
function glucoseAndWeight() {
const m = new Map<string, DailySeriesPoint[]>();
// Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg.
m.set(
"BLOOD_GLUCOSE",
seriesEndingAt(
[...Array(30).fill(90), ...Array(30).fill(108)],
"2026-04-30",
),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
return m;
}
it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => {
const ctx = assertReady(
assemblePeriodNarrativeContext({
...monthInput(glucoseAndWeight()),
units: { system: "imperial", glucoseUnit: "mmol/L" },
}),
);
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
expect(g.unit).toBe("mmol/L");
expect(g.current).toBe(6);
expect(g.prior).toBe(5);
expect(g.delta).toBe(1);
// Percent is unit-free.
expect(g.deltaPercent).toBe(20);
const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!;
expect(w.unit).toBe("lb");
expect(w.current).toBe(180.8);
expect(w.delta).toBe(4.4);
const prose = buildDeterministicNarrative(ctx, "en");
expect(prose).toContain("mmol/L");
expect(prose).toContain("lb");
expect(prose).not.toContain("mg/dL");
expect(prose).not.toContain("kg");
const prompt = buildNarrativeUserPrompt(ctx, "en");
expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L");
expect(prompt).not.toContain("mg/dL");
});
it("keeps mg/dL and kilograms on the default preferences", () => {
const ctx = assertReady(
assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())),
);
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
expect(g.unit).toBe("mg/dL");
expect(g.current).toBe(108);
expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg");
});
it("narrates sleep minutes as hours, the unit it always claimed", () => {
const m = new Map<string, DailySeriesPoint[]>();
m.set(
"SLEEP_DURATION",
seriesEndingAt(
[...Array(30).fill(420), ...Array(30).fill(450)],
"2026-04-30",
),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m)));
const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!;
expect(s.unit).toBe("h");
expect(s.current).toBe(7.5);
expect(s.delta).toBe(0.5);
});
it("states a band transition with its unit, converted", () => {
const m = new Map<string, DailySeriesPoint[]>();
const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3));
m.set(
"BLOOD_GLUCOSE",
seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"),
);
m.set(
"WEIGHT",
seriesEndingAt(
[...Array(30).fill(80), ...Array(30).fill(82)],
"2026-04-30",
),
);
const ctx = assertReady(
assemblePeriodNarrativeContext({
...monthInput(m),
units: { system: "metric", glucoseUnit: "mmol/L" },
}),
);
const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!;
expect(t.unit).toBe("mmol/L");
expect(t.center).toBe(7.8);
expect(t.movedOut).toBe(true);
expect(buildNarrativeUserPrompt(ctx, "en")).toContain(
"BLOOD_GLUCOSE: 7.8 mmol/L",
);
});
});
describe("assemblePeriodNarrativeContext — band transitions", () => {
it("flags a vital whose current center moved above its prior band", () => {
const m = new Map<string, DailySeriesPoint[]>();
@@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt(
: locale === "de"
? "im Bereich"
: "in range";
const unit = b.unit ? ` ${b.unit}` : "";
lines.push(
`- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`,
`- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`,
);
}
}
+112 -25
View File
@@ -56,6 +56,14 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { resolveModuleMap } from "@/lib/modules/gate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
isUnitSensitiveType,
resolveUnitPreferences,
type UnitPreferences,
} from "@/lib/measurements/display-transform";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -74,20 +82,80 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3;
/** A band must rest on at least this many prior-period days to be trusted. */
export const MIN_BASELINE_DAYS = 7;
/** The metrics the period-delta beat scans, with their display unit. */
const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [
{ type: "WEIGHT", unit: "kg" },
{ type: "BLOOD_PRESSURE_SYS", unit: "mmHg" },
{ type: "BLOOD_PRESSURE_DIA", unit: "mmHg" },
{ type: "PULSE", unit: "bpm" },
{ type: "RESTING_HEART_RATE", unit: "bpm" },
{ type: "HEART_RATE_VARIABILITY", unit: "ms" },
{ type: "SLEEP_DURATION", unit: "h" },
{ type: "ACTIVITY_STEPS", unit: "" },
{ type: "BODY_FAT", unit: "%" },
{ type: "BLOOD_GLUCOSE", unit: "mg/dL" },
/**
* The metrics the period-delta beat scans. Their unit is not listed here: a
* reading is narrated in the reader's unit (see `narratedUnit`), and the list
* used to pin kilograms and mg/dL for everyone.
*/
const DELTA_METRICS: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BODY_FAT",
"BLOOD_GLUCOSE",
];
/**
* Unit symbols of the narrated types whose unit no preference changes. The
* sleep channel is a per-night total in minutes and is narrated in hours.
*/
const FIXED_NARRATED_UNITS: Partial<Record<MeasurementType, string>> = {
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
RESTING_HEART_RATE: "bpm",
HEART_RATE_VARIABILITY: "ms",
RESPIRATORY_RATE: "breaths/min",
OXYGEN_SATURATION: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: "",
BODY_FAT: "%",
};
/**
* How one narrated type is written for this reader: its unit symbol, the
* conversion of an absolute value (a mean, a band edge) and of a difference
* (a delta — factor only, never an affine offset). The computation itself
* stays canonical, so the personal-band floors and the percent change do not
* depend on the unit the reader chose.
*/
function narratedUnit(
type: MeasurementType,
units: UnitPreferences,
): {
unit: string;
abs: (canonical: number) => number;
delta: (canonical: number) => number;
} {
if (isUnitSensitiveType(type)) {
const transform = getReadingTransform(type, units);
return {
unit: transform.displayUnit,
// Rounded to the unit's own grain (one decimal for lb and mmol/L), the
// grain every screen shows the same reading at.
abs: (v) => round2(applyDisplayTransform(v, transform)),
delta: (v) => round2(applyDisplayTransformDelta(v, transform)),
};
}
if (type === "SLEEP_DURATION") {
return {
unit: "h",
abs: (v) => round2(v / 60),
delta: (v) => round2(v / 60),
};
}
return {
unit: FIXED_NARRATED_UNITS[type] ?? "",
abs: round2,
delta: round2,
};
}
// ── context shape ───────────────────────────────────────────────────────
/** One metric's current-period mean vs the prior period of equal length. */
@@ -114,6 +182,8 @@ export interface MetricDelta {
*/
export interface BandTransition {
type: MeasurementType;
/** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */
unit: string;
/** Current-period robust center (median of per-day means). */
center: number;
/** Prior-period band edges. */
@@ -243,6 +313,12 @@ export interface AssembleInput {
locale: Locale;
/** Compute time (ISO). */
computedAt: string;
/**
* The reader's unit preferences. Required for the same reason the locale
* is: the context carries numbers a page and a prompt print verbatim, and a
* caller that stays quiet must not get kilograms and mg/dL by default.
*/
units: UnitPreferences;
}
/**
@@ -262,12 +338,14 @@ export function assemblePeriodNarrativeContext(
discoverySeries,
locale,
computedAt,
units,
} = input;
// ── metric deltas (current period vs prior period of equal length) ──────
const metricDeltas: MetricDelta[] = [];
const metricsWithCoverage: string[] = [];
for (const { type, unit } of DELTA_METRICS) {
for (const type of DELTA_METRICS) {
const shown = narratedUnit(type, units);
const points = seriesByMetric.get(type) ?? [];
const { current, prior } = splitByPeriod(points, currentFrom, priorFrom);
const currentAvg = meanOrNull(current);
@@ -280,16 +358,21 @@ export function assemblePeriodNarrativeContext(
currentAvg !== null && priorAvg !== null
? round2(currentAvg - priorAvg)
: null;
// Percent on the canonical values: it is unit-free for a pure rescale,
// and the one affine unit (°F) would otherwise move it.
const deltaPercent =
delta !== null && priorAvg !== null && priorAvg !== 0
? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10
: null;
metricDeltas.push({
type,
unit,
current: currentAvg === null ? null : round2(currentAvg),
prior: priorAvg === null ? null : round2(priorAvg),
delta,
unit: shown.unit,
current: currentAvg === null ? null : shown.abs(currentAvg),
prior: priorAvg === null ? null : shown.abs(priorAvg),
delta:
currentAvg !== null && priorAvg !== null
? shown.delta(currentAvg - priorAvg)
: null,
deltaPercent,
currentDays: current.length,
priorDays: prior.length,
@@ -324,11 +407,13 @@ export function assemblePeriodNarrativeContext(
const center = median(current);
const above = center > band.high;
const below = center < band.low;
const shown = narratedUnit(type, units);
bandTransitions.push({
type,
center: round2(center),
bandLow: round2(band.low),
bandHigh: round2(band.high),
unit: shown.unit,
center: shown.abs(center),
bandLow: shown.abs(band.low),
bandHigh: shown.abs(band.high),
direction: above ? "above" : below ? "below" : "in",
movedOut: above || below,
baselineDays: prior.length,
@@ -479,9 +564,13 @@ export async function buildPeriodNarrativeContext(
const profile = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
select: { timezone: true, unitPreference: true, glucoseUnit: true },
});
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
const units = resolveUnitPreferences({
unitPreference: profile?.unitPreference,
glucoseUnit: profile?.glucoseUnit,
});
const currentFrom = tzDayKey(
new Date(now.getTime() - periodDays * MS_PER_DAY),
@@ -523,10 +612,7 @@ export async function buildPeriodNarrativeContext(
// comment for why it is one and not four.
includeMoodFactors: true,
extraMeasurementTypes: Array.from(
new Set<MeasurementType>([
...DELTA_METRICS.map((m) => m.type),
...VITALS_BASELINE_TYPES,
]),
new Set<MeasurementType>([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]),
),
});
@@ -553,5 +639,6 @@ export async function buildPeriodNarrativeContext(
discoverySeries,
locale: opts.locale,
computedAt: now.toISOString(),
units,
});
}
+7 -1
View File
@@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized<string> = {
export function getNoKeyMetricStatusText(
locale: InsightLocale,
signal?: MetricSignal | null,
/**
* Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or
* pounds). Defaults to one, which was the only precision before the
* signal could arrive in the reader's unit.
*/
digits = 1,
): string {
const grounded =
signal && Number.isFinite(signal.current)
@@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText(
// localised upstream, so it stands for every locale.
label: { en: signal.metric },
...(signal.unit ? { unit: ` ${signal.unit}` } : {}),
digits: 1,
digits,
pointer: GENERIC_METRIC_POINTER,
},
locale,
+6 -6
View File
@@ -51,7 +51,12 @@ export interface TrendChartConfig {
types: string[];
/** Recharts stroke colours, positionally matched to `types`. */
colors: string[];
/** Display unit (chart legend / tooltip). */
/**
* Display unit (chart legend / tooltip) for a metric no preference
* changes. A single-series slot whose type has a display transform
* (weight, distance, body temperature) carries none: the renderer takes
* the unit, scale and offset from the reader's preference instead.
*/
unit?: string;
/** Y-axis unit label when it differs from the value unit. */
yAxisUnit?: string;
@@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WEIGHT"],
colors: ["var(--chart-1)"],
unit: "kg",
titleKey: "charts.weight",
captionKey: "insights.trendsRow.caption.weight",
annotationKey: "weight",
@@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["WALKING_RUNNING_DISTANCE"],
colors: ["var(--chart-4)"],
unit: "km",
yAxisUnit: "km",
titleKey: "charts.distance",
captionKey: "insights.trendsRow.caption.distance",
detailHref: "/insights/walking-distance",
@@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record<
kind: "health-chart",
types: ["BODY_TEMPERATURE"],
colors: ["var(--destructive)"],
unit: "°C",
yAxisUnit: "°C",
titleKey: "charts.bodyTemperature",
captionKey: "insights.trendsRow.caption.body_temp",
detailHref: "/insights/body-temperature",
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more