mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge origin/main into fix/v1397-tz
This commit is contained in:
commit
27ac4c1d63
143 files changed
+6471
-858
No files matched your search
+23
-10
@@ -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:
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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() } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
reportStatUnit,
|
||||
reportStatValue,
|
||||
} from "@/lib/doctor-report/stat-display";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import {
|
||||
isReportLeafId,
|
||||
@@ -212,6 +217,13 @@ export function MeasurementGroups({
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
// The same unit and the same number the PDF prints for the row: SI for
|
||||
// mass, length and temperature, the owner's unit for glucose, hours for
|
||||
// sleep. A clinician must never read a figure without knowing its unit.
|
||||
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
|
||||
const show = (type: string, value: number) =>
|
||||
fmtNum(reportStatValue(type, value, glucoseUnit));
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
@@ -224,11 +236,11 @@ export function MeasurementGroups({
|
||||
<StatRow
|
||||
key={type}
|
||||
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(stat!.latest),
|
||||
avg: fmtNum(stat!.avg),
|
||||
min: fmtNum(stat!.min),
|
||||
max: fmtNum(stat!.max),
|
||||
value={statSummary(t, reportStatUnit(type, glucoseUnit, t), {
|
||||
latest: show(type, stat!.latest),
|
||||
avg: show(type, stat!.avg),
|
||||
min: show(type, stat!.min),
|
||||
max: show(type, stat!.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
@@ -238,6 +250,20 @@ export function MeasurementGroups({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a
|
||||
* count) keeps the unit-less sentence rather than printing a dangling space.
|
||||
*/
|
||||
function statSummary(
|
||||
t: Translate,
|
||||
unit: string,
|
||||
values: { latest: number; avg: number; min: number; max: number },
|
||||
): string {
|
||||
return unit
|
||||
? t("clinicianView.statSummaryWithUnit", { ...values, unit })
|
||||
: t("clinicianView.statSummary", values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-context glucose stats. The heading used to read "Lab values", which was
|
||||
* the wrong name for it even before the record's actual lab results reached
|
||||
@@ -258,6 +284,9 @@ export function GlucoseSection({
|
||||
const entries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
// Stored in mg/dL; shown in the record owner's unit, as the PDF shows it.
|
||||
const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null);
|
||||
const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit));
|
||||
return (
|
||||
<LeafSection
|
||||
t={t}
|
||||
@@ -270,11 +299,11 @@ export function GlucoseSection({
|
||||
<StatRow
|
||||
key={ctx}
|
||||
label={t(`clinicianView.glucose.${ctx}`)}
|
||||
value={t("clinicianView.statSummary", {
|
||||
latest: fmtNum(s.latest),
|
||||
avg: fmtNum(s.avg),
|
||||
min: fmtNum(s.min),
|
||||
max: fmtNum(s.max),
|
||||
value={statSummary(t, glucoseUnit, {
|
||||
latest: show(s.latest),
|
||||
avg: show(s.avg),
|
||||
min: show(s.min),
|
||||
max: show(s.max),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
// QueryClient, so mock the hook to a real metric-preference implementation —
|
||||
// metric is the identity on values and yields the canonical display symbols,
|
||||
// so the assertions below (kg, °C, unchanged numbers) hold.
|
||||
const reader = vi.hoisted(() => ({
|
||||
glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L",
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-unit-display", async () => {
|
||||
const dt = await import("@/lib/measurements/display-transform");
|
||||
const forType = (t: string) => dt.getDisplayTransform(t, "metric");
|
||||
return {
|
||||
useUnitDisplay: () => ({
|
||||
preference: "metric" as const,
|
||||
glucoseUnit: reader.glucoseUnit,
|
||||
transformFor: forType,
|
||||
toDisplay: (t: string, v: number) =>
|
||||
dt.applyDisplayTransform(v, forType(t)),
|
||||
@@ -107,4 +112,26 @@ describe("<CyclePhaseCrosstab>", () => {
|
||||
const html = render(<CyclePhaseCrosstab rows={[unknown]} />);
|
||||
expect(html).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("states a glucose row in mmol/L for an mmol/L reader", () => {
|
||||
reader.glucoseUnit = "mmol/L";
|
||||
try {
|
||||
const glucose: CyclePhaseCrosstabRow = {
|
||||
...row("bloodGlucose", "glucose"),
|
||||
lutealAvg: 108,
|
||||
follicularAvg: 90,
|
||||
delta: 18,
|
||||
};
|
||||
const html = render(<CyclePhaseCrosstab rows={[glucose]} />);
|
||||
// 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L.
|
||||
expect(html).toContain("+1.0 mmol/L");
|
||||
expect(html).toContain("6.0");
|
||||
expect(html).not.toContain("mg/dL");
|
||||
expect(render(<CyclePhaseHeadline headline={glucose} />)).toContain(
|
||||
"mmol/L",
|
||||
);
|
||||
} finally {
|
||||
reader.glucoseUnit = "mg/dL";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
|
||||
const DISPLAY_TO_TYPE: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
|
||||
kg: "WEIGHT",
|
||||
celsius: "BODY_TEMPERATURE",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
};
|
||||
|
||||
/**
|
||||
* Unit symbol and conversions for one row, from the reader's preferences.
|
||||
* Glucose follows the glucose unit (it used to print the mg/dL label from
|
||||
* the bundle beside raw mg/dL values for every reader); weight and
|
||||
* temperature follow metric/imperial; every other display keeps its bundle
|
||||
* label and its raw value.
|
||||
*/
|
||||
function rowUnits(
|
||||
display: CyclePhaseCrosstabDisplay,
|
||||
unitDisplay: UnitDisplay,
|
||||
t: (key: string) => string,
|
||||
): {
|
||||
unit: string;
|
||||
abs: (v: number) => number;
|
||||
delta: (v: number) => number;
|
||||
} {
|
||||
const type = DISPLAY_TO_TYPE[display];
|
||||
if (!type) {
|
||||
// Defensive: an unmapped display must never pass undefined to `t`
|
||||
// (whose resolver does `key.split(".")`). Empty unit on a miss.
|
||||
const unitKey = UNIT_KEY[display];
|
||||
return {
|
||||
unit: unitKey ? t(unitKey) : "",
|
||||
abs: (v) => v,
|
||||
delta: (v) => v,
|
||||
};
|
||||
}
|
||||
const transform = getReadingTransform(type, {
|
||||
system: unitDisplay.preference,
|
||||
glucoseUnit: unitDisplay.glucoseUnit,
|
||||
});
|
||||
return {
|
||||
unit: transform.displayUnit,
|
||||
abs: (v) => applyDisplayTransform(v, transform),
|
||||
delta: (v) => applyDisplayTransformDelta(v, transform),
|
||||
};
|
||||
}
|
||||
|
||||
const HEADLINE_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.headline.restingHeartRate",
|
||||
heartRateVariability: "cycle.insights.headline.heartRateVariability",
|
||||
@@ -120,11 +164,7 @@ export function CyclePhaseHeadline({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
|
||||
);
|
||||
// Defensive: an unmapped display must never pass undefined to `t` (whose
|
||||
// resolver does `key.split(".")`). An empty unit is the honest degrade.
|
||||
const mt = DISPLAY_TO_TYPE[headline.display];
|
||||
const unitKey = UNIT_KEY[headline.display];
|
||||
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
|
||||
const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t);
|
||||
const up = headline.delta >= 0;
|
||||
const dir = t(
|
||||
up
|
||||
@@ -134,12 +174,7 @@ export function CyclePhaseHeadline({
|
||||
const params = {
|
||||
metric: metricLabel,
|
||||
// A phase delta → factor-only conversion (no affine offset).
|
||||
delta: fmt(
|
||||
Math.abs(
|
||||
mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
|
||||
),
|
||||
headline.display,
|
||||
),
|
||||
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
|
||||
unit,
|
||||
dir,
|
||||
};
|
||||
@@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
// Defensive: an unmapped display must never pass undefined to `t`
|
||||
// (whose resolver does `key.split(".")`). Empty unit on a miss.
|
||||
const mt = DISPLAY_TO_TYPE[row.display];
|
||||
const unitKey = UNIT_KEY[row.display];
|
||||
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
|
||||
const absConv = (v: number) =>
|
||||
mt ? unitDisplay.toDisplay(mt, v) : v;
|
||||
const deltaConv = (v: number) =>
|
||||
mt ? unitDisplay.toDisplayDelta(mt, v) : v;
|
||||
const {
|
||||
unit,
|
||||
abs: absConv,
|
||||
delta: deltaConv,
|
||||
} = rowUnits(row.display, unitDisplay, t);
|
||||
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
|
||||
// higher in the luteal phase. The number stays NEUTRAL — higher is
|
||||
// good for steps but bad for resting HR, and this board is
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* A logged fever reads in the reader's temperature unit. The timeline and the
|
||||
* day sheet printed a hardcoded °C, so an imperial account read its own
|
||||
* fever in Celsius.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "imperial", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManageDomain: () => true }),
|
||||
}));
|
||||
|
||||
vi.mock("../use-illness", () => ({
|
||||
useIllnessDayLogList: () => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
dayLogs: [
|
||||
{
|
||||
id: "d1",
|
||||
episodeId: "e1",
|
||||
date: "2026-09-30",
|
||||
functionalImpact: null,
|
||||
feverC: 38.5,
|
||||
symptoms: [],
|
||||
note: null,
|
||||
updatedAt: "2026-09-30T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { IllnessDayTimeline } from "../illness-day-timeline";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IllnessDayTimeline episodeId="e1" onLogDay={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<IllnessDayTimeline> fever unit", () => {
|
||||
it("shows the fever in °F for an imperial reader", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
const html = render();
|
||||
// 38.5 °C = 101.3 °F.
|
||||
expect(html).toContain("Temperature: 101.3 °F");
|
||||
expect(html).not.toContain("°C");
|
||||
});
|
||||
|
||||
it("keeps °C for a metric reader", () => {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
expect(render()).toContain("Temperature: 38.5 °C");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLogList } from "./use-illness";
|
||||
@@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null {
|
||||
function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { toDisplay, unitFor } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
<div className="border-border/60 space-y-2 border-l-2 pl-3">
|
||||
@@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
|
||||
|
||||
{log.feverC !== null ? (
|
||||
<p className="text-foreground text-sm">
|
||||
{t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })}
|
||||
{t("illness.timeline.fever", {
|
||||
value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1),
|
||||
unit: unitFor("BODY_TEMPERATURE"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
|
||||
@@ -140,6 +141,10 @@ export function LogDaySheet({
|
||||
onsetDate,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
// The fever is stored in °C and typed in the reader's temperature unit:
|
||||
// seeded through the transform, inverted on save.
|
||||
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
|
||||
const feverUnit = unitFor("BODY_TEMPERATURE");
|
||||
|
||||
// The day being logged — seeded from `date` (today), but the picker lets the
|
||||
// user backdate within [onset, today] for the retrospective journal.
|
||||
@@ -178,7 +183,11 @@ export function LogDaySheet({
|
||||
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
|
||||
);
|
||||
setFunctionalImpact(dto?.functionalImpact ?? null);
|
||||
setFever(dto?.feverC != null ? String(dto.feverC) : "");
|
||||
setFever(
|
||||
dto?.feverC != null
|
||||
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
|
||||
: "",
|
||||
);
|
||||
setNote(dto?.note ?? "");
|
||||
} else if (!open && lastFormKey !== null) {
|
||||
setLastFormKey(null);
|
||||
@@ -213,7 +222,10 @@ export function LogDaySheet({
|
||||
const input: IllnessDayLogInput = {
|
||||
date: selectedDate,
|
||||
functionalImpact,
|
||||
feverC: feverValue,
|
||||
feverC:
|
||||
feverValue == null
|
||||
? null
|
||||
: Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100,
|
||||
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
|
||||
key,
|
||||
severity: severity ?? undefined,
|
||||
@@ -333,7 +345,7 @@ export function LogDaySheet({
|
||||
summary={
|
||||
feverValue != null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{feverValue.toFixed(1)} °C
|
||||
{feverValue.toFixed(1)} {feverUnit}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
@@ -345,7 +357,7 @@ export function LogDaySheet({
|
||||
<Input
|
||||
id="illness-fever"
|
||||
inputMode="decimal"
|
||||
placeholder="37.0"
|
||||
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
|
||||
value={fever}
|
||||
onChange={(e) => setFever(e.target.value)}
|
||||
className="max-w-32"
|
||||
|
||||
@@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = ({ title }: { title?: string }) => (
|
||||
<div data-slot="trends-row-chart-stub">{title ?? "chart"}</div>
|
||||
const Stub = ({
|
||||
title,
|
||||
unit,
|
||||
valueScale,
|
||||
valueOffset,
|
||||
}: {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
valueScale?: number;
|
||||
valueOffset?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="trends-row-chart-stub"
|
||||
data-unit={unit}
|
||||
data-scale={valueScale}
|
||||
data-offset={valueOffset}
|
||||
>
|
||||
{title ?? "chart"}
|
||||
</div>
|
||||
);
|
||||
Stub.displayName = "TrendsRowChartStub";
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
const account = vi.hoisted(() => ({
|
||||
user: null as Record<string, unknown> | null,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-auth")>()),
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
import { TrendsRow } from "../trends-row";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
|
||||
@@ -306,4 +332,27 @@ describe("<TrendsRow>", () => {
|
||||
expect(html).toMatch(/data-metric="steps"/);
|
||||
expect(html).toContain('href="/insights/steps"');
|
||||
});
|
||||
|
||||
it("plots a transformed slot in the reader's unit, scale and offset included", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(
|
||||
<TrendsRow briefing={briefing(["weight", "distance", "body_temp"])} />,
|
||||
);
|
||||
expect(html).toContain('data-unit="lb"');
|
||||
expect(html).toContain('data-unit="mi"');
|
||||
expect(html).toContain('data-unit="°F"');
|
||||
expect(html).toContain('data-offset="32"');
|
||||
expect(html).not.toContain('data-unit="kg"');
|
||||
expect(html).not.toContain('data-unit="km"');
|
||||
} finally {
|
||||
account.user = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("plots distance in kilometres, scaled from metres, for a metric reader", () => {
|
||||
const html = render(<TrendsRow briefing={briefing(["distance"])} />);
|
||||
expect(html).toContain('data-unit="km"');
|
||||
expect(html).toContain('data-scale="0.001"');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
// The unit preference comes from the account payload; a mutable stub lets a
|
||||
// test switch the reader to imperial.
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "metric", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
/**
|
||||
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
|
||||
* directory. Each primitive runs through SSR with a canonical fixture;
|
||||
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
|
||||
expect(html).toContain("148 bpm");
|
||||
});
|
||||
|
||||
it("states distance, pace, elevation and the average line in imperial units", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(
|
||||
<WorkoutDetailStats
|
||||
workout={{
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 8,
|
||||
avgDurationSec: 2040,
|
||||
avgDistanceM: 5800,
|
||||
avgAvgHr: 148,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
|
||||
expect(html).toContain("3.23 mi");
|
||||
expect(html).toContain("9:17 /mi");
|
||||
expect(html).toContain("41.0 ft");
|
||||
// The sport-average line: 5800 m = 3.60 mi.
|
||||
expect(html).toContain("3.60 mi");
|
||||
expect(html).not.toContain(" km");
|
||||
expect(html).not.toContain("/km");
|
||||
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
|
||||
expect(header).toContain("3.23 mi");
|
||||
} finally {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
|
||||
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
|
||||
expect(html).toContain("5.20 km");
|
||||
expect(html).toContain("5:46 /km");
|
||||
expect(html).toContain("12.5 m");
|
||||
});
|
||||
|
||||
it("hides the average line for a lone session", () => {
|
||||
const lone: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WorkoutList } from "../workout-list";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
|
||||
// The unit preference comes from the account payload; a mutable stub lets a
|
||||
// test switch the reader to imperial.
|
||||
const account = vi.hoisted(() => ({
|
||||
user: { unitPreference: "metric", glucoseUnit: null } as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: account.user, isAuthenticated: true }),
|
||||
useAccountOnceMounted: () => account.user,
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutList>` unit tests.
|
||||
*
|
||||
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
|
||||
expect(html).toContain("320 kcal");
|
||||
});
|
||||
|
||||
it("states the distance in miles for an imperial reader", () => {
|
||||
account.user = { unitPreference: "imperial", glucoseUnit: null };
|
||||
try {
|
||||
const html = render(<WorkoutList workouts={[ROW]} />);
|
||||
// 5200 m = 3.23 mi.
|
||||
expect(html).toContain("3.23 mi");
|
||||
expect(html).not.toContain(" km");
|
||||
} finally {
|
||||
account.user = { unitPreference: "metric", glucoseUnit: null };
|
||||
}
|
||||
});
|
||||
|
||||
it("renders aggregate average HR without claiming an HR series", () => {
|
||||
const html = render(
|
||||
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { RangeBar } from "@/components/targets/range-bar";
|
||||
@@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { getTargetSourceLink } from "@/lib/targets/source-link";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
|
||||
@@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
// mg/dL values to the user's display unit. The route's per-context
|
||||
// labels are i18n keys, so resolve them.
|
||||
if (isGlucose) {
|
||||
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayUnit);
|
||||
// The payload stays canonical mg/dL here; each panel converts through
|
||||
// the shared target adapter, which owns the glucose unit as it owns
|
||||
// pounds, so the panel and its edit sheet cannot convert twice.
|
||||
const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
|
||||
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
|
||||
data.targets.find((entry) => entry.type === type),
|
||||
)
|
||||
.filter(
|
||||
(entry): entry is TargetItem => entry != null && entry.range != null,
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
label: t(entry.label),
|
||||
unit: displayUnit,
|
||||
current: convert(entry.current),
|
||||
average30: convert(entry.average30),
|
||||
range: entry.range
|
||||
? {
|
||||
min: convertGlucose(entry.range.min, displayUnit),
|
||||
max: convertGlucose(entry.range.max, displayUnit),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
.map((entry) => ({ ...entry, label: t(entry.label) }));
|
||||
|
||||
if (panels.length === 0) return null;
|
||||
|
||||
@@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
key={panel.type}
|
||||
target={panel}
|
||||
heading={panel.label}
|
||||
glucoseUnit={glucoseUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -212,6 +202,12 @@ interface TargetReferencePanelProps {
|
||||
bpDiastolic?: TargetsResponse["bpDiastolic"];
|
||||
/** Optional sub-heading shown above the panel (used for glucose contexts). */
|
||||
heading?: string;
|
||||
/**
|
||||
* The record's glucose unit as the targets payload resolved it. The
|
||||
* glucose panels pass it so the band reads in the unit the server
|
||||
* resolved for this record; every other panel leaves it to the hook.
|
||||
*/
|
||||
glucoseUnit?: GlucoseUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,21 +228,26 @@ function TargetReferencePanel({
|
||||
target,
|
||||
bpDiastolic,
|
||||
heading,
|
||||
glucoseUnit: payloadGlucoseUnit,
|
||||
}: TargetReferencePanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const adjust = useTargetAdjust();
|
||||
const { preference } = useUnitDisplay();
|
||||
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
|
||||
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
|
||||
|
||||
// v1.32.27 — the panel renders in the user's preferred unit. The
|
||||
// `/api/insights/targets` payload is canonical (kg for weight); the
|
||||
// adapter converts it once, here, and everything downstream — the
|
||||
// range bar, the status pill, the 30-day average, and the edit
|
||||
// sheet's seed — consumes the converted pair. Glucose contexts are
|
||||
// pre-converted by the caller above and carry no display transform,
|
||||
// so they take the adapter's identity path and are never scaled
|
||||
// twice. For a metric account the adapter is the exact identity, so
|
||||
// this panel is byte-identical to the previous release.
|
||||
const units = resolveTargetUnitAdapter(target.type, target.unit, preference);
|
||||
// sheet's seed — consumes the converted pair. The glucose contexts
|
||||
// arrive canonical too and convert here to the reader's glucose unit.
|
||||
// On the default preferences the adapter is the exact identity.
|
||||
const units = resolveTargetUnitAdapter(
|
||||
target.type,
|
||||
target.unit,
|
||||
preference,
|
||||
glucoseUnit,
|
||||
);
|
||||
const unit = units.unit;
|
||||
const range = target.range
|
||||
? {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
/**
|
||||
* v1.4.20 phase B3 — single-sentence AI annotation rendered directly
|
||||
@@ -264,6 +265,7 @@ export function TrendDescriptorCaption({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay();
|
||||
|
||||
const isMood = kind === "mood";
|
||||
const primaryType = types[0] ?? "";
|
||||
@@ -323,18 +325,30 @@ export function TrendDescriptorCaption({
|
||||
}
|
||||
// The numeric series is already server-bounded to the trailing
|
||||
// 30-day window by the fetch, so every returned row is in-window.
|
||||
// Described in the reader's unit, the same one the chart above plots.
|
||||
return (numericQuery.data ?? []).map((row) => ({
|
||||
timestamp: new Date(row.measuredAt).getTime(),
|
||||
value: row.value,
|
||||
value: toDisplay(primaryType, row.value),
|
||||
}));
|
||||
}, [isMood, moodQuery.data, numericQuery.data]);
|
||||
}, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]);
|
||||
|
||||
const descriptor = useMemo(() => {
|
||||
const meta = TREND_SLOT_DESCRIPTOR_META[metric];
|
||||
// A slot that converts its series converts its noise floor with it: the
|
||||
// floor is declared canonically and is a difference (factor only).
|
||||
const config = isMood
|
||||
? MOOD_DESCRIPTOR_CONFIG
|
||||
: TREND_SLOT_DESCRIPTOR_META[metric]?.config;
|
||||
: meta?.unitType
|
||||
? {
|
||||
...meta.config,
|
||||
absoluteFloor: toDisplayDelta(
|
||||
meta.unitType,
|
||||
meta.config.absoluteFloor,
|
||||
),
|
||||
}
|
||||
: meta?.config;
|
||||
return computeTrendDescriptor(points, config);
|
||||
}, [points, isMood, metric]);
|
||||
}, [points, isMood, metric, toDisplayDelta]);
|
||||
|
||||
// Tier 4 — genuinely too few points (also covers the in-flight window
|
||||
// before the small series lands). Surface the real empty hint so the
|
||||
@@ -361,7 +375,7 @@ export function TrendDescriptorCaption({
|
||||
// copy defensively (should not happen for the legacy triple).
|
||||
const copy = isMood
|
||||
? moodDescriptorCopy(descriptor)
|
||||
: (numericDescriptorCopy(metric, descriptor) ??
|
||||
: (numericDescriptorCopy(metric, descriptor, unitFor) ??
|
||||
moodDescriptorCopy(descriptor));
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type TrendAnnotationConfidenceBand,
|
||||
type TrendAnnotationStatus,
|
||||
} from "./trend-annotation";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
/**
|
||||
* Trends row — a small chart per metric above a one-sentence
|
||||
@@ -116,17 +117,29 @@ function MetricChart({
|
||||
title: string;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const { transformFor, isTransformed } = useUnitDisplay();
|
||||
const userTimezone = user?.timezone;
|
||||
if (config.kind === "mood") {
|
||||
return <MoodChart title={title} mini />;
|
||||
}
|
||||
// A single transformed series (weight, distance, temperature) plots in
|
||||
// the reader's unit: the chart folds scale and offset in at its read
|
||||
// boundary, exactly as the metric's own insights page does. Distance
|
||||
// used to plot raw metres under a "km" label for every reader.
|
||||
const primary = config.types[0] ?? "";
|
||||
const transform =
|
||||
config.types.length === 1 && isTransformed(primary)
|
||||
? transformFor(primary)
|
||||
: null;
|
||||
return (
|
||||
<HealthChartDynamicMini
|
||||
types={config.types}
|
||||
title={title}
|
||||
colors={config.colors}
|
||||
unit={config.unit}
|
||||
yAxisUnit={config.yAxisUnit}
|
||||
unit={transform ? transform.displayUnit : config.unit}
|
||||
yAxisUnit={transform ? undefined : config.yAxisUnit}
|
||||
valueScale={transform?.factor}
|
||||
valueOffset={transform?.offset}
|
||||
mini
|
||||
userTimezone={userTimezone}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getQuantityTransform,
|
||||
paceSecondsPerDistanceUnit,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
@@ -27,12 +33,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
|
||||
return formatNumber(minutes, locale);
|
||||
}
|
||||
|
||||
export function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
/**
|
||||
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
|
||||
* "3.60 mi". The workout list, the detail header, the stat tile and the
|
||||
* sport-average line all print through this, so none of them can keep
|
||||
* kilometres for an imperial reader.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
const transform = getQuantityTransform("distance", preference);
|
||||
const value = applyDisplayTransform(meters, transform);
|
||||
const formatted = getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
minimumFractionDigits: value < 10 ? 2 : 1,
|
||||
}).format(value);
|
||||
return `${formatted} ${transform.displayUnit}`;
|
||||
}
|
||||
|
||||
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
|
||||
export function formatElevation(
|
||||
meters: number,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
const transform = getQuantityTransform("elevation", preference);
|
||||
return `${formatNumber(
|
||||
applyDisplayTransform(meters, transform),
|
||||
locale,
|
||||
transform.decimals,
|
||||
)} ${transform.displayUnit}`;
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
@@ -46,21 +78,36 @@ export function formatNumber(
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatPaceMinPerKm(
|
||||
/**
|
||||
* Average pace per kilometre or per mile, by the reader's preference. Only
|
||||
* meaningful for run / walk / hike / ride; the caller gates on distance +
|
||||
* sport.
|
||||
*/
|
||||
export function formatPace(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
preference: UnitPreference,
|
||||
): string {
|
||||
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
|
||||
// the caller gates on distance + sport.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
return formatPaceSeconds(secPerKm);
|
||||
return formatPaceSeconds(
|
||||
paceSecondsPerDistanceUnit(durationSec, meters, preference),
|
||||
getQuantityTransform("distance", preference).displayUnit,
|
||||
);
|
||||
}
|
||||
|
||||
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
|
||||
export function formatPaceSeconds(secPerKm: number): string {
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||
/**
|
||||
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
|
||||
* explicitly: its rows are kilometre segments cut server-side, so their
|
||||
* pace is per kilometre whatever the preference.
|
||||
*/
|
||||
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
|
||||
let m = Math.floor(secPerUnit / 60);
|
||||
let s = Math.round(secPerUnit % 60);
|
||||
// 359.6 s is 6:00, not 5:60.
|
||||
if (s === 60) {
|
||||
m += 1;
|
||||
s = 0;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
|
||||
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
|
||||
import { formatDuration, formatDistance, formatDateRange } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const { preference } = useUnitDisplay();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
{formatDistance(workout.distanceM, locale, preference)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
|
||||
: "py-1.5 text-right tabular-nums"
|
||||
}
|
||||
>
|
||||
{formatPaceSeconds(s.paceSecPerKm)}
|
||||
{formatPaceSeconds(s.paceSecPerKm, "km")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import type { UnitPreference } from "@/lib/measurements/display-transform";
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatDistanceKm,
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatNumber,
|
||||
formatPaceMinPerKm,
|
||||
formatPace,
|
||||
formatDurationMinutes,
|
||||
} from "./format";
|
||||
|
||||
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
function sportAverageLine(
|
||||
workout: WorkoutDetailPayload,
|
||||
locale: string,
|
||||
preference: UnitPreference,
|
||||
): string | null {
|
||||
const ctx = workout.sportContext;
|
||||
if (!ctx || ctx.count < 2) return null;
|
||||
const parts: string[] = [];
|
||||
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
|
||||
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
|
||||
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
|
||||
}
|
||||
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
|
||||
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
|
||||
@@ -83,6 +87,7 @@ function sportAverageLine(
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { preference } = useUnitDisplay();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
value: formatDistance(workout.distanceM, locale, preference),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
value: formatElevation(workout.elevationM, locale, preference),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
|
||||
value: formatPace(workout.durationSec, workout.distanceM, preference),
|
||||
});
|
||||
}
|
||||
|
||||
const averageLine = sportAverageLine(workout, locale);
|
||||
const averageLine = sportAverageLine(workout, locale, preference);
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { formatDistance } from "@/components/insights/workout-detail/format";
|
||||
|
||||
/**
|
||||
* v1.4.32 — workout list row primitive.
|
||||
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatEnergy(kcal: number, locale: string): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 0,
|
||||
@@ -86,6 +80,7 @@ export function WorkoutList({
|
||||
}: WorkoutListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const { preference } = useUnitDisplay();
|
||||
|
||||
return (
|
||||
<ul
|
||||
@@ -150,7 +145,7 @@ export function WorkoutList({
|
||||
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
|
||||
{workout.distanceM != null ? (
|
||||
<span data-slot="workout-list-distance">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
{formatDistance(workout.distanceM, locale, preference)}
|
||||
</span>
|
||||
) : null}
|
||||
{workout.activeEnergyKcal != null ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,13 +23,21 @@ import {
|
||||
getDisplayTransform,
|
||||
invertDisplayTransform,
|
||||
hasDisplayTransform,
|
||||
resolveUnitPreference,
|
||||
type DisplayTransform,
|
||||
type UnitPreference,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export interface UnitDisplay {
|
||||
/** Resolved preference ("metric" by default / on a stale payload). */
|
||||
preference: UnitPreference;
|
||||
/**
|
||||
* Resolved glucose unit ("mg/dL" by default). Carried beside the
|
||||
* metric/imperial branch so a surface that edits or renders glucose reads
|
||||
* both preferences from the one hook.
|
||||
*/
|
||||
glucoseUnit: GlucoseUnit;
|
||||
/** The full transform for a type under the current preference. */
|
||||
transformFor: (type: string) => DisplayTransform;
|
||||
/** Convert a raw canonical ABSOLUTE value to the display value. */
|
||||
@@ -48,7 +56,7 @@ export interface UnitDisplay {
|
||||
|
||||
export function useUnitDisplay(): UnitDisplay {
|
||||
const { user } = useAuth();
|
||||
return useDisplayForPreference(user?.unitPreference);
|
||||
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay {
|
||||
*/
|
||||
export function useUnitDisplayOnceMounted(): UnitDisplay {
|
||||
const user = useAccountOnceMounted();
|
||||
return useDisplayForPreference(user?.unitPreference);
|
||||
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
|
||||
}
|
||||
|
||||
function useDisplayForPreference(
|
||||
rawPreference: string | null | undefined,
|
||||
rawGlucoseUnit: string | null | undefined,
|
||||
): UnitDisplay {
|
||||
const preference: UnitPreference =
|
||||
rawPreference === "imperial" ? "imperial" : "metric";
|
||||
const preference = resolveUnitPreference(rawPreference);
|
||||
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
|
||||
|
||||
return useMemo<UnitDisplay>(() => {
|
||||
const transformFor = (type: string) =>
|
||||
getDisplayTransform(type, preference);
|
||||
return {
|
||||
preference,
|
||||
glucoseUnit,
|
||||
transformFor,
|
||||
toDisplay: (type, rawValue) =>
|
||||
applyDisplayTransform(rawValue, transformFor(type)),
|
||||
@@ -94,5 +104,5 @@ function useDisplayForPreference(
|
||||
decimalsFor: (type) => transformFor(type).decimals,
|
||||
isTransformed: (type) => hasDisplayTransform(type),
|
||||
};
|
||||
}, [preference]);
|
||||
}, [preference, glucoseUnit]);
|
||||
}
|
||||
@@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => {
|
||||
expect(text).not.toContain("450 h");
|
||||
});
|
||||
|
||||
it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => {
|
||||
// The measurement table used to take BLOOD_GLUCOSE's canonical unit and
|
||||
// number, so an mmol/L account read "99.0 mg/dL" two lines above its own
|
||||
// context rows in mmol/L.
|
||||
const data = makeData({
|
||||
glucoseUnit: "mmol/L",
|
||||
stats: {
|
||||
...makeData().stats,
|
||||
BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 },
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("5.5 mmol/L");
|
||||
expect(text).toContain("6.0 mmol/L");
|
||||
expect(text).not.toContain("mg/dL");
|
||||
});
|
||||
|
||||
it("omits the sleep vitals row when there are no sleep stats", async () => {
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
timeFormat: "AUTO",
|
||||
|
||||
@@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildInterpretationBlock } from "../interpretation-block";
|
||||
|
||||
describe("buildInterpretationBlock", () => {
|
||||
it("classifies on the canonical value but prints in the reader's unit", () => {
|
||||
const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10;
|
||||
const block = buildInterpretationBlock({
|
||||
metricKey: "BODY_TEMPERATURE",
|
||||
value: 36.8,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
display: { unit: "°F", convert: toF },
|
||||
});
|
||||
expect(block).toBeDefined();
|
||||
expect(block).toContain("Current value 98.2 °F");
|
||||
expect(block).not.toContain("°C");
|
||||
// Same band as the canonical block names.
|
||||
const canonical = buildInterpretationBlock({
|
||||
metricKey: "BODY_TEMPERATURE",
|
||||
value: 36.8,
|
||||
sex: null,
|
||||
locale: "en",
|
||||
})!;
|
||||
const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1];
|
||||
expect(band(block!)).toBe(band(canonical));
|
||||
});
|
||||
|
||||
it("renders the band position for visceral fat 2.7 (the headline case)", () => {
|
||||
const en = buildInterpretationBlock({
|
||||
metricKey: "VISCERAL_FAT",
|
||||
|
||||
@@ -130,9 +130,17 @@ function proximityPhrase(
|
||||
*/
|
||||
export function buildInterpretationBlock(args: {
|
||||
metricKey: string;
|
||||
/** The value in the guideline's own (canonical) unit — what is classified. */
|
||||
value: number;
|
||||
sex: "MALE" | "FEMALE" | null | undefined;
|
||||
locale: Locale;
|
||||
/**
|
||||
* Print the block in the reader's unit. Classification always runs on the
|
||||
* canonical value against the canonical bands; only the printed numbers and
|
||||
* the unit symbol are converted, so a prompt whose snapshot reads °F does
|
||||
* not carry a band table in °C beside it.
|
||||
*/
|
||||
display?: { unit: string; convert: (canonical: number) => number };
|
||||
}): string | undefined {
|
||||
const resolved = resolveInterpretation(args.metricKey, args.sex ?? null);
|
||||
if (!resolved) return undefined;
|
||||
@@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: {
|
||||
const loc: "en" | "de" = instructionLocale(args.locale);
|
||||
const pos = classifyBandPosition(args.value, resolved.bands);
|
||||
|
||||
const rows = resolved.bands
|
||||
const show = (n: number) => (args.display ? args.display.convert(n) : n);
|
||||
const unit = args.display ? args.display.unit : resolved.unit;
|
||||
const shownBands = resolved.bands.map((band) => ({
|
||||
...band,
|
||||
upTo: band.upTo === null ? null : show(band.upTo),
|
||||
}));
|
||||
|
||||
const rows = shownBands
|
||||
.map((band, i) =>
|
||||
bandRow(
|
||||
band,
|
||||
i > 0 ? resolved.bands[i - 1].upTo : null,
|
||||
resolved.unit,
|
||||
loc,
|
||||
),
|
||||
bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc),
|
||||
)
|
||||
.map((r) => ` - ${r}`)
|
||||
.join("\n");
|
||||
|
||||
const proximity = proximityPhrase(
|
||||
pos.proximity,
|
||||
pos.nearestEdge,
|
||||
resolved.unit,
|
||||
pos.nearestEdge === null ? null : show(pos.nearestEdge),
|
||||
unit,
|
||||
loc,
|
||||
);
|
||||
const valence = valencePhrase(pos.band.valence, loc);
|
||||
@@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: {
|
||||
|
||||
if (loc === "en") {
|
||||
return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute):
|
||||
- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}.
|
||||
- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}.
|
||||
- Guideline bands (${resolved.source}; ${direction}):
|
||||
${rows}
|
||||
- Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat}
|
||||
- Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`;
|
||||
}
|
||||
return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen):
|
||||
- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}.
|
||||
- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}.
|
||||
- Leitlinien-Bänder (${resolved.source}; ${direction}):
|
||||
${rows}
|
||||
- Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
import { computeAndRecordUserHealthScore } from "./record";
|
||||
import type { CompositeValue } from "./types";
|
||||
@@ -23,6 +24,7 @@ export async function computeHealthScoreDerived(
|
||||
timezone: true,
|
||||
thresholdsJson: true,
|
||||
healthScoreConfigJson: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
@@ -59,6 +61,7 @@ export async function computeHealthScoreDerived(
|
||||
timezone,
|
||||
sourcePriorityJson,
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
glucoseUnit: resolveGlucoseUnit(user.glucoseUnit),
|
||||
},
|
||||
modules: {
|
||||
glucose: modules.glucose !== false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
import { convertGlucose } from "@/lib/glucose";
|
||||
|
||||
import type { GlycaemiaPillarInput, PillarValue } from "./types";
|
||||
import {
|
||||
@@ -153,10 +154,13 @@ export function computeGlycaemiaPillar(
|
||||
FASTING_GLUCOSE_REFERENCE.low,
|
||||
FASTING_GLUCOSE_REFERENCE.high,
|
||||
),
|
||||
// `value`, `unit` and the band stay canonical: the score is computed on
|
||||
// them and the web converts from them. The labels are finished text a
|
||||
// client prints as-is, so they are written in the reader's unit.
|
||||
observed: {
|
||||
value: observed,
|
||||
unit: "mg/dL",
|
||||
label: `Fasting median ${Math.round(observed)} mg/dL`,
|
||||
label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`,
|
||||
asOf: fasting.at(-1)!.at.toISOString(),
|
||||
sources: uniqueSources(fasting.map((row) => row.source)),
|
||||
},
|
||||
@@ -164,7 +168,7 @@ export function computeGlycaemiaPillar(
|
||||
kind: "clinical-threshold",
|
||||
low: FASTING_GLUCOSE_REFERENCE.low,
|
||||
high: FASTING_GLUCOSE_REFERENCE.high,
|
||||
label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`,
|
||||
label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`,
|
||||
source: "ADA 2025; Selvin 2010",
|
||||
},
|
||||
noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)),
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types";
|
||||
import { computeWeightGoal } from "./weight-goal";
|
||||
import { DAY_MS, uniqueSources } from "./shared";
|
||||
import type { DerivedProvenanceSource } from "@/lib/insights/derived/types";
|
||||
import type { GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
const HISTORY_OFFSET_DAYS = 14;
|
||||
const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS;
|
||||
@@ -52,6 +53,12 @@ export interface ScoreReaderProfile {
|
||||
timezone: string;
|
||||
sourcePriorityJson: unknown;
|
||||
thresholdsJson: unknown;
|
||||
/**
|
||||
* The record's glucose unit, for the glycaemia pillar's display labels.
|
||||
* Required: those labels are served as finished text (the app prints them
|
||||
* verbatim), and a caller that forgets the unit must not get mg/dL.
|
||||
*/
|
||||
glucoseUnit: GlucoseUnit;
|
||||
}
|
||||
|
||||
export interface UserHealthScoreInput {
|
||||
@@ -513,6 +520,7 @@ function scoreInputsFor(args: {
|
||||
},
|
||||
GLYCAEMIA: {
|
||||
source: "live",
|
||||
glucoseUnit: input.profile.glucoseUnit,
|
||||
readFailed:
|
||||
(!input.modules.glucose || !args.glucose.ok) &&
|
||||
(!input.modules.labs || !args.labs.ok),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
// runtime cycle. The tier's definition belongs beside the rule that
|
||||
// produces it; its NAME belongs here, where the wire shape is declared.
|
||||
import type { ScoreBreadthTier } from "./breadth";
|
||||
import type { GlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* The scoring method's identity.
|
||||
@@ -346,6 +347,12 @@ export interface GlucoseReading {
|
||||
|
||||
export interface GlycaemiaPillarInput extends DomainReadState {
|
||||
asOf: Date;
|
||||
/**
|
||||
* The unit the fasting-glucose labels are written in. The scored value,
|
||||
* its `unit` and the reference band stay canonical mg/dL; only the two
|
||||
* human-readable labels follow the reader.
|
||||
*/
|
||||
glucoseUnit: GlucoseUnit;
|
||||
hba1cReadFailed?: boolean;
|
||||
fastingReadFailed?: boolean;
|
||||
hba1c: HbA1cReading[];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* How a measurement statistic reads in the clinician-facing artefacts: the
|
||||
* PDF report and the shared clinician view.
|
||||
*
|
||||
* Both describe the same record to the same reader, so they take the unit and
|
||||
* the displayed number from here and nowhere else. Before this module the PDF
|
||||
* carried its own unit map and the clinician view printed bare numbers, so a
|
||||
* doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with
|
||||
* no unit at all, and for an account on mmol/L the glucose rows of the PDF's
|
||||
* measurement table still printed raw mg/dL beside a mmol/L glucose panel.
|
||||
*
|
||||
* The policy, which `display-transform.ts` states for the metric/imperial
|
||||
* preference: a document handed to a clinician stays SI for mass, length and
|
||||
* temperature regardless of the owner's imperial preference. Glucose is the
|
||||
* exception, because mmol/L versus mg/dL is not an imperial question — it is
|
||||
* the clinical convention of the country the record lives in — so glucose
|
||||
* follows the owner's `glucoseUnit` here as it does everywhere a person reads
|
||||
* it.
|
||||
*/
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
|
||||
/**
|
||||
* Report-specific unit symbols, where the report's own reading differs from
|
||||
* the validation layer's canonical unit. `null` marks a unit that has to be
|
||||
* translated (steps). Everything absent falls back to `getUnitForType`.
|
||||
*/
|
||||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
};
|
||||
|
||||
/**
|
||||
* The unit symbol a report statistic of `type` is printed with.
|
||||
*
|
||||
* `translate` resolves the one translated unit (steps). A type with no
|
||||
* recorded unit prints none — an absent unit is absent, not a guessed symbol.
|
||||
*/
|
||||
export function reportStatUnit(
|
||||
type: string,
|
||||
glucoseUnit: GlucoseUnit,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
if (type === "BLOOD_GLUCOSE") return glucoseUnit;
|
||||
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return translate("doctorReport.unitSteps");
|
||||
}
|
||||
if (staticUnit !== undefined && staticUnit !== null) return staticUnit;
|
||||
const canonical = getUnitForType(type);
|
||||
return canonical === "unknown" ? "" : canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number a report statistic of `type` is printed as, in the unit
|
||||
* {@link reportStatUnit} names.
|
||||
*
|
||||
* SLEEP_DURATION statistics are per-night asleep totals in minutes (the data
|
||||
* layer reconstructs them for exactly this row) while the unit is hours, so
|
||||
* they are divided — the same hours value the FHIR sleep Observation emits.
|
||||
* Glucose is stored in mg/dL and converted to the owner's unit.
|
||||
*/
|
||||
export function reportStatValue(
|
||||
type: string,
|
||||
value: number,
|
||||
glucoseUnit: GlucoseUnit,
|
||||
): number {
|
||||
if (type === "SLEEP_DURATION") return value / 60;
|
||||
if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit);
|
||||
return value;
|
||||
}
|
||||
@@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => {
|
||||
expect(result.text).toContain("Steady.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateMetricStatus — the note is written in the reader's units", () => {
|
||||
function seed(values: (day: number) => number, days = 120) {
|
||||
const now = new Date();
|
||||
const records: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let day = 0; day < days; day++) {
|
||||
records.push({
|
||||
value: values(day),
|
||||
measuredAt: new Date(now.getTime() - day * dayMs),
|
||||
});
|
||||
}
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(days as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
|
||||
}
|
||||
|
||||
function snapshotOf(userPrompt: string) {
|
||||
return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]);
|
||||
}
|
||||
|
||||
it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => {
|
||||
seed((day) => 99 + (day % 4) * 3);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
unitPreference: "metric",
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
const captured = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion('{"summary":"Steady."}', captured);
|
||||
|
||||
await generateMetricStatus({
|
||||
metric: "BLOOD_GLUCOSE",
|
||||
userId: "user-1",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(captured.systemPrompt).toContain("unit: mmol/L");
|
||||
// 70–140 mg/dL is 3.9–7.8 mmol/L.
|
||||
expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L");
|
||||
// The shared safety contract quotes its own fixed thresholds; the metric
|
||||
// block itself must not carry the canonical band.
|
||||
expect(captured.systemPrompt).not.toContain("70–140 mg/dL");
|
||||
const snapshot = snapshotOf(captured.userPrompt!);
|
||||
expect(snapshot.metric.unit).toBe("mmol/L");
|
||||
expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 });
|
||||
const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map(
|
||||
(b: { mean: number }) => b.mean,
|
||||
);
|
||||
expect(recentMeans.length).toBeGreaterThan(0);
|
||||
for (const mean of recentMeans) {
|
||||
expect(mean).toBeGreaterThan(5);
|
||||
expect(mean).toBeLessThan(6.5);
|
||||
}
|
||||
expect(captured.userPrompt).not.toContain("mg/dL");
|
||||
});
|
||||
|
||||
it("states body temperature in °F for an imperial reader, guideline band included", async () => {
|
||||
seed((day) => 36.6 + (day % 3) * 0.1);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
unitPreference: "imperial",
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
const captured = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion('{"summary":"Steady."}', captured);
|
||||
|
||||
await generateMetricStatus({
|
||||
metric: "BODY_TEMPERATURE",
|
||||
userId: "user-1",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(captured.systemPrompt).toContain("unit: °F");
|
||||
expect(captured.systemPrompt).not.toContain("36.1–37.2 °C");
|
||||
// The interpretation block classifies in °C but prints in °F.
|
||||
expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT");
|
||||
expect(captured.userPrompt).not.toContain("°C");
|
||||
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F");
|
||||
});
|
||||
|
||||
it("writes the no-provider floor in the reader's glucose unit", async () => {
|
||||
seed((day) => 99 + (day % 4) * 3);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
unitPreference: "metric",
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never);
|
||||
|
||||
const result = await generateMetricStatus({
|
||||
metric: "BLOOD_GLUCOSE",
|
||||
userId: "user-1",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(result.text).toMatch(/\d\.\d mmol\/L/);
|
||||
expect(result.text).not.toContain("mg/dL");
|
||||
});
|
||||
|
||||
it("leaves a metric reader on the default units byte-identical", async () => {
|
||||
seed((day) => 99 + (day % 4) * 3);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
unitPreference: null,
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
const captured = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion('{"summary":"Steady."}', captured);
|
||||
|
||||
await generateMetricStatus({
|
||||
metric: "BLOOD_GLUCOSE",
|
||||
userId: "user-1",
|
||||
locale: "en",
|
||||
});
|
||||
|
||||
expect(captured.systemPrompt).toContain("70–140 mg/dL");
|
||||
expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL");
|
||||
});
|
||||
});
|
||||
@@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => {
|
||||
});
|
||||
|
||||
describe("numericDescriptorCopy", () => {
|
||||
it("states a preference-bound slot in the unit the caller resolves", () => {
|
||||
const d = computeTrendDescriptor(series([181.6, 178.5]), {
|
||||
absoluteFloor: 0.66,
|
||||
relativeFloor: 0.01,
|
||||
decimals: 1,
|
||||
})!;
|
||||
const copy = numericDescriptorCopy("weight", d, (type) =>
|
||||
type === "WEIGHT" ? "lb" : "?",
|
||||
);
|
||||
expect(copy?.params.unit).toBe(" lb");
|
||||
expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe(
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
);
|
||||
expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe(
|
||||
"BODY_TEMPERATURE",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves the rising template with a signed delta and spaced unit", () => {
|
||||
const d = computeTrendDescriptor(series([120, 128]), {
|
||||
absoluteFloor: 2,
|
||||
relativeFloor: 0.02,
|
||||
decimals: 0,
|
||||
})!;
|
||||
const copy = numericDescriptorCopy("bp", d);
|
||||
const copy = numericDescriptorCopy("bp", d, () => "unused");
|
||||
expect(copy?.key).toBe("insights.trendDescriptor.rising");
|
||||
expect(copy?.params.delta).toBe("+8");
|
||||
expect(copy?.params.unit).toBe(" mmHg");
|
||||
@@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => {
|
||||
relativeFloor: 0.05,
|
||||
decimals: 0,
|
||||
})!;
|
||||
const copy = numericDescriptorCopy("steps", d);
|
||||
const copy = numericDescriptorCopy("steps", d, () => "unused");
|
||||
expect(copy?.key).toBe("insights.trendDescriptor.rising");
|
||||
expect(copy?.params.unit).toBe("");
|
||||
});
|
||||
@@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => {
|
||||
relativeFloor: 0.01,
|
||||
decimals: 1,
|
||||
})!;
|
||||
const copy = numericDescriptorCopy("weight", d);
|
||||
const copy = numericDescriptorCopy("weight", d, () => "kg");
|
||||
expect(copy?.key).toBe("insights.trendDescriptor.falling");
|
||||
expect(copy?.params.delta).toBe("−1.4");
|
||||
});
|
||||
|
||||
it("returns null for a slot with no numeric meta (mood)", () => {
|
||||
const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!;
|
||||
expect(numericDescriptorCopy("mood", d)).toBeNull();
|
||||
expect(numericDescriptorCopy("mood", d, () => "")).toBeNull();
|
||||
});
|
||||
|
||||
it("covers every numeric slot in the descriptor meta", () => {
|
||||
@@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => {
|
||||
relativeFloor: 0,
|
||||
decimals: 1,
|
||||
})!;
|
||||
expect(numericDescriptorCopy(metric, d)).not.toBeNull();
|
||||
expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
insightNarrative: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/insights/status-invalidation", () => ({
|
||||
enqueueStatusRefillForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/period-narrative-shared", () => ({
|
||||
enqueueNarrativeWarm: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation";
|
||||
import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared";
|
||||
import { refreshTextsAfterUnitChange } from "../unit-change-refresh";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("refreshTextsAfterUnitChange", () => {
|
||||
it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
locale: "de",
|
||||
} as never);
|
||||
vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12);
|
||||
vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([
|
||||
{ period: "week", locale: "de" },
|
||||
{ period: "month", locale: "en" },
|
||||
{ period: "year", locale: "de" },
|
||||
{ period: "week", locale: "xx" },
|
||||
] as never);
|
||||
|
||||
await refreshTextsAfterUnitChange("user-1");
|
||||
|
||||
expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de");
|
||||
expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2);
|
||||
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
period: "week",
|
||||
locale: "de",
|
||||
});
|
||||
expect(enqueueNarrativeWarm).toHaveBeenCalledWith({
|
||||
userId: "user-1",
|
||||
period: "month",
|
||||
locale: "en",
|
||||
});
|
||||
});
|
||||
|
||||
it("never throws into the preference write", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down"));
|
||||
await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -598,6 +598,35 @@ export function scaleGradedSeries(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-express a canonical graded series in a display unit: absolute bucket
|
||||
* values (`min / max / mean`) take the full affine conversion, the yearly
|
||||
* slope — a rate, not a level — takes the factor alone, exactly as
|
||||
* `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are
|
||||
* untouched. Used where a series is narrated in the reader's unit; the
|
||||
* series it is built from stays canonical.
|
||||
*/
|
||||
export function convertGradedSeries(
|
||||
series: GradedSeries,
|
||||
conversion: { factor: number; offset?: number },
|
||||
digits = 2,
|
||||
): GradedSeries {
|
||||
const offset = conversion.offset ?? 0;
|
||||
const abs = (v: number) => round(v * conversion.factor + offset, digits);
|
||||
const bucket = <T extends { min: number; max: number; mean: number }>(
|
||||
b: T,
|
||||
): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) });
|
||||
return {
|
||||
recent: series.recent.map(bucket),
|
||||
weekly: series.weekly.map(bucket),
|
||||
monthly: series.monthly.map(bucket),
|
||||
yearly: series.yearly.map((b) => ({
|
||||
...bucket(b),
|
||||
slope: b.slope === null ? null : round(b.slope * conversion.factor, 4),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembled-snapshot soft char cap, mirroring the Coach's
|
||||
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
|
||||
|
||||
@@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
convertGradedSeries,
|
||||
degradeStatusSnapshotToBudget,
|
||||
type GradedSeries,
|
||||
} from "@/lib/insights/graded-series";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
@@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { binaryReferenceSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
getReadingTransform,
|
||||
invertDisplayTransform,
|
||||
isUnitSensitiveType,
|
||||
resolveUnitPreferences,
|
||||
transformRescales,
|
||||
type DisplayTransform,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
export interface MetricStatusResult {
|
||||
hasProvider: boolean;
|
||||
@@ -222,9 +234,40 @@ export async function generateMetricStatus(args: {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: args.userId },
|
||||
select: { dateOfBirth: true, gender: true },
|
||||
select: {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
unitPreference: true,
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The note is written in the reader's units. The series, the reference band
|
||||
// and the signal are converted once, here, before any of them reaches the
|
||||
// prompt or the deterministic floor; the registry entry, the rollups and
|
||||
// the guideline classification stay canonical. An untransformed metric (and
|
||||
// every metric on the default preferences that does not rescale) passes
|
||||
// through unchanged, so its snapshot hash does not move.
|
||||
const reading = readingDisplay(
|
||||
meta,
|
||||
resolveUnitPreferences({
|
||||
unitPreference: user?.unitPreference,
|
||||
glucoseUnit: user?.glucoseUnit,
|
||||
}),
|
||||
);
|
||||
const shownMeta: MetricStatusMeta = {
|
||||
...meta,
|
||||
unit: reading.unit,
|
||||
...(meta.normalRange
|
||||
? {
|
||||
normalRange: {
|
||||
low: reading.toDisplay(meta.normalRange.low),
|
||||
high: reading.toDisplay(meta.normalRange.high),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Bounded raw read for the derived recent stats; the monthly/yearly
|
||||
@@ -295,7 +338,10 @@ export async function generateMetricStatus(args: {
|
||||
})
|
||||
.then((r) => r.reverse());
|
||||
for (const m of rows) {
|
||||
points.push({ measuredAt: m.measuredAt, value: m.value });
|
||||
points.push({
|
||||
measuredAt: m.measuredAt,
|
||||
value: reading.toDisplay(m.value),
|
||||
});
|
||||
}
|
||||
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
|
||||
}
|
||||
@@ -309,11 +355,13 @@ export async function generateMetricStatus(args: {
|
||||
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
|
||||
const graded = isSleep
|
||||
? buildGradedSeriesFromPoints(points, now, userTz)
|
||||
: await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
meta.measurementType,
|
||||
now,
|
||||
userTz,
|
||||
: reading.convertGraded(
|
||||
await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
meta.measurementType,
|
||||
now,
|
||||
userTz,
|
||||
),
|
||||
);
|
||||
const summary = summarizeSeries(
|
||||
series.daily.map((bucket) => ({ value: bucket.value })),
|
||||
@@ -344,7 +392,14 @@ export async function generateMetricStatus(args: {
|
||||
// metric + profile; otherwise keep the existing flat anchor (strictly
|
||||
// additive, never a regression). Age + sex only — no ancestry/region.
|
||||
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
|
||||
const normalRange = sharpenedRange ?? meta.normalRange;
|
||||
const canonicalRange = sharpenedRange ?? meta.normalRange;
|
||||
const normalRange = canonicalRange
|
||||
? {
|
||||
...canonicalRange,
|
||||
low: reading.toDisplay(canonicalRange.low),
|
||||
high: reading.toDisplay(canonicalRange.high),
|
||||
}
|
||||
: canonicalRange;
|
||||
|
||||
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
|
||||
// normal-swing verdict, computed server-side so the model states it rather
|
||||
@@ -353,7 +408,7 @@ export async function generateMetricStatus(args: {
|
||||
// when the recent window is empty, in which case it is omitted.
|
||||
const signal = buildMetricSignal({
|
||||
metric: meta.displayName,
|
||||
unit: meta.unit,
|
||||
unit: shownMeta.unit,
|
||||
direction: meta.direction,
|
||||
graded,
|
||||
normalRange: normalRange ?? null,
|
||||
@@ -369,7 +424,7 @@ export async function generateMetricStatus(args: {
|
||||
metric: {
|
||||
id: meta.id,
|
||||
displayName: meta.displayName,
|
||||
unit: meta.unit,
|
||||
unit: shownMeta.unit,
|
||||
direction: meta.direction,
|
||||
...(normalRange ? { normalRange } : {}),
|
||||
...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}),
|
||||
@@ -477,14 +532,24 @@ export async function generateMetricStatus(args: {
|
||||
// window exists); absent for uncovered metrics (fail-soft to personal-
|
||||
// relative). Sex-split bands (waist) resolve only when the profile carries a
|
||||
// sex.
|
||||
// The guideline bands are canonical, so the band position is classified on
|
||||
// the canonical value; the block then prints in the reader's unit.
|
||||
const interpretationValue = signal?.current ?? latest?.value ?? null;
|
||||
const interpretationBlock =
|
||||
interpretationValue !== null
|
||||
? buildInterpretationBlock({
|
||||
metricKey: meta.id,
|
||||
value: interpretationValue,
|
||||
value: reading.toCanonical(interpretationValue),
|
||||
sex,
|
||||
locale,
|
||||
...(reading.converts
|
||||
? {
|
||||
display: {
|
||||
unit: reading.unit,
|
||||
convert: reading.toDisplay,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -502,9 +567,9 @@ export async function generateMetricStatus(args: {
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
capability: "statusText",
|
||||
systemPrompt: getMetricArchetypeSystemPrompt(meta, locale),
|
||||
systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale),
|
||||
userPrompt: getMetricArchetypeUserPrompt(
|
||||
meta,
|
||||
shownMeta,
|
||||
snapshotJson,
|
||||
todayKey,
|
||||
locale,
|
||||
@@ -526,7 +591,7 @@ export async function generateMetricStatus(args: {
|
||||
if (outcome.kind === "none") {
|
||||
return {
|
||||
hasProvider: false,
|
||||
text: getNoKeyMetricStatusText(locale, signal),
|
||||
text: getNoKeyMetricStatusText(locale, signal, reading.decimals),
|
||||
cached: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
@@ -537,7 +602,7 @@ export async function generateMetricStatus(args: {
|
||||
reason: outcome.kind,
|
||||
userId: args.userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMetricStatusText(locale, signal),
|
||||
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -553,7 +618,7 @@ export async function generateMetricStatus(args: {
|
||||
reason: "screened",
|
||||
userId: args.userId,
|
||||
todayKey,
|
||||
stubText: getNoKeyMetricStatusText(locale, signal),
|
||||
stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals),
|
||||
});
|
||||
}
|
||||
const text = screened.text;
|
||||
@@ -577,3 +642,51 @@ export function resolveMetricStatusLocale(
|
||||
): SupportedLocale {
|
||||
return normalizeLocale(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* How one metric's numbers are written for this reader: the unit symbol, the
|
||||
* conversions in both directions, and the decimals a value is read at.
|
||||
*
|
||||
* Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the
|
||||
* metric/imperial set) converts; every other metric keeps its registry unit
|
||||
* and its stored numbers. A transform that does not rescale (`converts`
|
||||
* false) is the identity, so the default preferences leave a snapshot
|
||||
* byte-identical for the metrics whose registry unit already is the display
|
||||
* unit.
|
||||
*/
|
||||
function readingDisplay(
|
||||
meta: MetricStatusMeta,
|
||||
preferences: UnitPreferences,
|
||||
): {
|
||||
unit: string;
|
||||
decimals: number | undefined;
|
||||
converts: boolean;
|
||||
toDisplay: (canonical: number) => number;
|
||||
toCanonical: (display: number) => number;
|
||||
convertGraded: (series: GradedSeries) => GradedSeries;
|
||||
} {
|
||||
if (!isUnitSensitiveType(meta.measurementType)) {
|
||||
return {
|
||||
unit: meta.unit,
|
||||
decimals: undefined,
|
||||
converts: false,
|
||||
toDisplay: (v) => v,
|
||||
toCanonical: (v) => v,
|
||||
convertGraded: (series) => series,
|
||||
};
|
||||
}
|
||||
const transform: DisplayTransform = getReadingTransform(
|
||||
meta.measurementType,
|
||||
preferences,
|
||||
);
|
||||
const converts = transformRescales(transform);
|
||||
return {
|
||||
unit: transform.displayUnit,
|
||||
decimals: transform.decimals,
|
||||
converts,
|
||||
toDisplay: (v) => applyDisplayTransform(v, transform),
|
||||
toCanonical: (v) => invertDisplayTransform(v, transform),
|
||||
convertGraded: (series) =>
|
||||
converts ? convertGradedSeries(series, transform) : series,
|
||||
};
|
||||
}
|
||||
@@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
|
||||
expect(sleepDelta).toBeDefined();
|
||||
// The pre-fix local `toDailyMeans` twin would have averaged the three
|
||||
// segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no
|
||||
// clinical meaning. The correct grain is the night's TOTAL (410).
|
||||
expect(sleepDelta!.current).toBe(410);
|
||||
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0);
|
||||
// clinical meaning. The correct grain is the night's TOTAL (410 min),
|
||||
// narrated in the hours its unit names: this used to read "410 h".
|
||||
expect(sleepDelta!.unit).toBe("h");
|
||||
expect(sleepDelta!.current).toBe(6.83);
|
||||
expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1);
|
||||
});
|
||||
|
||||
it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => {
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => {
|
||||
bandTransitions: [
|
||||
{
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
unit: "mmHg",
|
||||
center: 140,
|
||||
bandLow: 110,
|
||||
bandHigh: 130,
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
DailySeriesPoint,
|
||||
NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform";
|
||||
import { buildDeterministicNarrative } from "../period-narrative-deterministic";
|
||||
import { buildNarrativeUserPrompt } from "../period-narrative-generate";
|
||||
|
||||
/**
|
||||
* Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point
|
||||
@@ -42,6 +45,7 @@ function monthInput(
|
||||
discoverySeries,
|
||||
locale: "en",
|
||||
computedAt: "2026-04-30T12:00:00.000Z",
|
||||
units: DEFAULT_UNIT_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePeriodNarrativeContext — the reader's units", () => {
|
||||
function glucoseAndWeight() {
|
||||
const m = new Map<string, DailySeriesPoint[]>();
|
||||
// Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg.
|
||||
m.set(
|
||||
"BLOOD_GLUCOSE",
|
||||
seriesEndingAt(
|
||||
[...Array(30).fill(90), ...Array(30).fill(108)],
|
||||
"2026-04-30",
|
||||
),
|
||||
);
|
||||
m.set(
|
||||
"WEIGHT",
|
||||
seriesEndingAt(
|
||||
[...Array(30).fill(80), ...Array(30).fill(82)],
|
||||
"2026-04-30",
|
||||
),
|
||||
);
|
||||
return m;
|
||||
}
|
||||
|
||||
it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => {
|
||||
const ctx = assertReady(
|
||||
assemblePeriodNarrativeContext({
|
||||
...monthInput(glucoseAndWeight()),
|
||||
units: { system: "imperial", glucoseUnit: "mmol/L" },
|
||||
}),
|
||||
);
|
||||
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
|
||||
expect(g.unit).toBe("mmol/L");
|
||||
expect(g.current).toBe(6);
|
||||
expect(g.prior).toBe(5);
|
||||
expect(g.delta).toBe(1);
|
||||
// Percent is unit-free.
|
||||
expect(g.deltaPercent).toBe(20);
|
||||
const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!;
|
||||
expect(w.unit).toBe("lb");
|
||||
expect(w.current).toBe(180.8);
|
||||
expect(w.delta).toBe(4.4);
|
||||
|
||||
const prose = buildDeterministicNarrative(ctx, "en");
|
||||
expect(prose).toContain("mmol/L");
|
||||
expect(prose).toContain("lb");
|
||||
expect(prose).not.toContain("mg/dL");
|
||||
expect(prose).not.toContain("kg");
|
||||
const prompt = buildNarrativeUserPrompt(ctx, "en");
|
||||
expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L");
|
||||
expect(prompt).not.toContain("mg/dL");
|
||||
});
|
||||
|
||||
it("keeps mg/dL and kilograms on the default preferences", () => {
|
||||
const ctx = assertReady(
|
||||
assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())),
|
||||
);
|
||||
const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!;
|
||||
expect(g.unit).toBe("mg/dL");
|
||||
expect(g.current).toBe(108);
|
||||
expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg");
|
||||
});
|
||||
|
||||
it("narrates sleep minutes as hours, the unit it always claimed", () => {
|
||||
const m = new Map<string, DailySeriesPoint[]>();
|
||||
m.set(
|
||||
"SLEEP_DURATION",
|
||||
seriesEndingAt(
|
||||
[...Array(30).fill(420), ...Array(30).fill(450)],
|
||||
"2026-04-30",
|
||||
),
|
||||
);
|
||||
m.set(
|
||||
"WEIGHT",
|
||||
seriesEndingAt(
|
||||
[...Array(30).fill(80), ...Array(30).fill(82)],
|
||||
"2026-04-30",
|
||||
),
|
||||
);
|
||||
const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m)));
|
||||
const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!;
|
||||
expect(s.unit).toBe("h");
|
||||
expect(s.current).toBe(7.5);
|
||||
expect(s.delta).toBe(0.5);
|
||||
});
|
||||
|
||||
it("states a band transition with its unit, converted", () => {
|
||||
const m = new Map<string, DailySeriesPoint[]>();
|
||||
const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3));
|
||||
m.set(
|
||||
"BLOOD_GLUCOSE",
|
||||
seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"),
|
||||
);
|
||||
m.set(
|
||||
"WEIGHT",
|
||||
seriesEndingAt(
|
||||
[...Array(30).fill(80), ...Array(30).fill(82)],
|
||||
"2026-04-30",
|
||||
),
|
||||
);
|
||||
const ctx = assertReady(
|
||||
assemblePeriodNarrativeContext({
|
||||
...monthInput(m),
|
||||
units: { system: "metric", glucoseUnit: "mmol/L" },
|
||||
}),
|
||||
);
|
||||
const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!;
|
||||
expect(t.unit).toBe("mmol/L");
|
||||
expect(t.center).toBe(7.8);
|
||||
expect(t.movedOut).toBe(true);
|
||||
expect(buildNarrativeUserPrompt(ctx, "en")).toContain(
|
||||
"BLOOD_GLUCOSE: 7.8 mmol/L",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePeriodNarrativeContext — band transitions", () => {
|
||||
it("flags a vital whose current center moved above its prior band", () => {
|
||||
const m = new Map<string, DailySeriesPoint[]>();
|
||||
|
||||
@@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt(
|
||||
: locale === "de"
|
||||
? "im Bereich"
|
||||
: "in range";
|
||||
const unit = b.unit ? ` ${b.unit}` : "";
|
||||
lines.push(
|
||||
`- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`,
|
||||
`- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
|
||||
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
applyDisplayTransformDelta,
|
||||
getReadingTransform,
|
||||
isUnitSensitiveType,
|
||||
resolveUnitPreferences,
|
||||
type UnitPreferences,
|
||||
} from "@/lib/measurements/display-transform";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -74,20 +82,80 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3;
|
||||
/** A band must rest on at least this many prior-period days to be trusted. */
|
||||
export const MIN_BASELINE_DAYS = 7;
|
||||
|
||||
/** The metrics the period-delta beat scans, with their display unit. */
|
||||
const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [
|
||||
{ type: "WEIGHT", unit: "kg" },
|
||||
{ type: "BLOOD_PRESSURE_SYS", unit: "mmHg" },
|
||||
{ type: "BLOOD_PRESSURE_DIA", unit: "mmHg" },
|
||||
{ type: "PULSE", unit: "bpm" },
|
||||
{ type: "RESTING_HEART_RATE", unit: "bpm" },
|
||||
{ type: "HEART_RATE_VARIABILITY", unit: "ms" },
|
||||
{ type: "SLEEP_DURATION", unit: "h" },
|
||||
{ type: "ACTIVITY_STEPS", unit: "" },
|
||||
{ type: "BODY_FAT", unit: "%" },
|
||||
{ type: "BLOOD_GLUCOSE", unit: "mg/dL" },
|
||||
/**
|
||||
* The metrics the period-delta beat scans. Their unit is not listed here: a
|
||||
* reading is narrated in the reader's unit (see `narratedUnit`), and the list
|
||||
* used to pin kilograms and mg/dL for everyone.
|
||||
*/
|
||||
const DELTA_METRICS: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BODY_FAT",
|
||||
"BLOOD_GLUCOSE",
|
||||
];
|
||||
|
||||
/**
|
||||
* Unit symbols of the narrated types whose unit no preference changes. The
|
||||
* sleep channel is a per-night total in minutes and is narrated in hours.
|
||||
*/
|
||||
const FIXED_NARRATED_UNITS: Partial<Record<MeasurementType, string>> = {
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
HEART_RATE_VARIABILITY: "ms",
|
||||
RESPIRATORY_RATE: "breaths/min",
|
||||
OXYGEN_SATURATION: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: "",
|
||||
BODY_FAT: "%",
|
||||
};
|
||||
|
||||
/**
|
||||
* How one narrated type is written for this reader: its unit symbol, the
|
||||
* conversion of an absolute value (a mean, a band edge) and of a difference
|
||||
* (a delta — factor only, never an affine offset). The computation itself
|
||||
* stays canonical, so the personal-band floors and the percent change do not
|
||||
* depend on the unit the reader chose.
|
||||
*/
|
||||
function narratedUnit(
|
||||
type: MeasurementType,
|
||||
units: UnitPreferences,
|
||||
): {
|
||||
unit: string;
|
||||
abs: (canonical: number) => number;
|
||||
delta: (canonical: number) => number;
|
||||
} {
|
||||
if (isUnitSensitiveType(type)) {
|
||||
const transform = getReadingTransform(type, units);
|
||||
return {
|
||||
unit: transform.displayUnit,
|
||||
// Rounded to the unit's own grain (one decimal for lb and mmol/L), the
|
||||
// grain every screen shows the same reading at.
|
||||
abs: (v) => round2(applyDisplayTransform(v, transform)),
|
||||
delta: (v) => round2(applyDisplayTransformDelta(v, transform)),
|
||||
};
|
||||
}
|
||||
if (type === "SLEEP_DURATION") {
|
||||
return {
|
||||
unit: "h",
|
||||
abs: (v) => round2(v / 60),
|
||||
delta: (v) => round2(v / 60),
|
||||
};
|
||||
}
|
||||
return {
|
||||
unit: FIXED_NARRATED_UNITS[type] ?? "",
|
||||
abs: round2,
|
||||
delta: round2,
|
||||
};
|
||||
}
|
||||
|
||||
// ── context shape ───────────────────────────────────────────────────────
|
||||
|
||||
/** One metric's current-period mean vs the prior period of equal length. */
|
||||
@@ -114,6 +182,8 @@ export interface MetricDelta {
|
||||
*/
|
||||
export interface BandTransition {
|
||||
type: MeasurementType;
|
||||
/** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */
|
||||
unit: string;
|
||||
/** Current-period robust center (median of per-day means). */
|
||||
center: number;
|
||||
/** Prior-period band edges. */
|
||||
@@ -243,6 +313,12 @@ export interface AssembleInput {
|
||||
locale: Locale;
|
||||
/** Compute time (ISO). */
|
||||
computedAt: string;
|
||||
/**
|
||||
* The reader's unit preferences. Required for the same reason the locale
|
||||
* is: the context carries numbers a page and a prompt print verbatim, and a
|
||||
* caller that stays quiet must not get kilograms and mg/dL by default.
|
||||
*/
|
||||
units: UnitPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,12 +338,14 @@ export function assemblePeriodNarrativeContext(
|
||||
discoverySeries,
|
||||
locale,
|
||||
computedAt,
|
||||
units,
|
||||
} = input;
|
||||
|
||||
// ── metric deltas (current period vs prior period of equal length) ──────
|
||||
const metricDeltas: MetricDelta[] = [];
|
||||
const metricsWithCoverage: string[] = [];
|
||||
for (const { type, unit } of DELTA_METRICS) {
|
||||
for (const type of DELTA_METRICS) {
|
||||
const shown = narratedUnit(type, units);
|
||||
const points = seriesByMetric.get(type) ?? [];
|
||||
const { current, prior } = splitByPeriod(points, currentFrom, priorFrom);
|
||||
const currentAvg = meanOrNull(current);
|
||||
@@ -280,16 +358,21 @@ export function assemblePeriodNarrativeContext(
|
||||
currentAvg !== null && priorAvg !== null
|
||||
? round2(currentAvg - priorAvg)
|
||||
: null;
|
||||
// Percent on the canonical values: it is unit-free for a pure rescale,
|
||||
// and the one affine unit (°F) would otherwise move it.
|
||||
const deltaPercent =
|
||||
delta !== null && priorAvg !== null && priorAvg !== 0
|
||||
? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10
|
||||
: null;
|
||||
metricDeltas.push({
|
||||
type,
|
||||
unit,
|
||||
current: currentAvg === null ? null : round2(currentAvg),
|
||||
prior: priorAvg === null ? null : round2(priorAvg),
|
||||
delta,
|
||||
unit: shown.unit,
|
||||
current: currentAvg === null ? null : shown.abs(currentAvg),
|
||||
prior: priorAvg === null ? null : shown.abs(priorAvg),
|
||||
delta:
|
||||
currentAvg !== null && priorAvg !== null
|
||||
? shown.delta(currentAvg - priorAvg)
|
||||
: null,
|
||||
deltaPercent,
|
||||
currentDays: current.length,
|
||||
priorDays: prior.length,
|
||||
@@ -324,11 +407,13 @@ export function assemblePeriodNarrativeContext(
|
||||
const center = median(current);
|
||||
const above = center > band.high;
|
||||
const below = center < band.low;
|
||||
const shown = narratedUnit(type, units);
|
||||
bandTransitions.push({
|
||||
type,
|
||||
center: round2(center),
|
||||
bandLow: round2(band.low),
|
||||
bandHigh: round2(band.high),
|
||||
unit: shown.unit,
|
||||
center: shown.abs(center),
|
||||
bandLow: shown.abs(band.low),
|
||||
bandHigh: shown.abs(band.high),
|
||||
direction: above ? "above" : below ? "below" : "in",
|
||||
movedOut: above || below,
|
||||
baselineDays: prior.length,
|
||||
@@ -479,9 +564,13 @@ export async function buildPeriodNarrativeContext(
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
select: { timezone: true, unitPreference: true, glucoseUnit: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
|
||||
const units = resolveUnitPreferences({
|
||||
unitPreference: profile?.unitPreference,
|
||||
glucoseUnit: profile?.glucoseUnit,
|
||||
});
|
||||
|
||||
const currentFrom = tzDayKey(
|
||||
new Date(now.getTime() - periodDays * MS_PER_DAY),
|
||||
@@ -523,10 +612,7 @@ export async function buildPeriodNarrativeContext(
|
||||
// comment for why it is one and not four.
|
||||
includeMoodFactors: true,
|
||||
extraMeasurementTypes: Array.from(
|
||||
new Set<MeasurementType>([
|
||||
...DELTA_METRICS.map((m) => m.type),
|
||||
...VITALS_BASELINE_TYPES,
|
||||
]),
|
||||
new Set<MeasurementType>([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -553,5 +639,6 @@ export async function buildPeriodNarrativeContext(
|
||||
discoverySeries,
|
||||
locale: opts.locale,
|
||||
computedAt: now.toISOString(),
|
||||
units,
|
||||
});
|
||||
}
|
||||
@@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized<string> = {
|
||||
export function getNoKeyMetricStatusText(
|
||||
locale: InsightLocale,
|
||||
signal?: MetricSignal | null,
|
||||
/**
|
||||
* Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or
|
||||
* pounds). Defaults to one, which was the only precision before the
|
||||
* signal could arrive in the reader's unit.
|
||||
*/
|
||||
digits = 1,
|
||||
): string {
|
||||
const grounded =
|
||||
signal && Number.isFinite(signal.current)
|
||||
@@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText(
|
||||
// localised upstream, so it stands for every locale.
|
||||
label: { en: signal.metric },
|
||||
...(signal.unit ? { unit: ` ${signal.unit}` } : {}),
|
||||
digits: 1,
|
||||
digits,
|
||||
pointer: GENERIC_METRIC_POINTER,
|
||||
},
|
||||
locale,
|
||||
|
||||
@@ -51,7 +51,12 @@ export interface TrendChartConfig {
|
||||
types: string[];
|
||||
/** Recharts stroke colours, positionally matched to `types`. */
|
||||
colors: string[];
|
||||
/** Display unit (chart legend / tooltip). */
|
||||
/**
|
||||
* Display unit (chart legend / tooltip) for a metric no preference
|
||||
* changes. A single-series slot whose type has a display transform
|
||||
* (weight, distance, body temperature) carries none: the renderer takes
|
||||
* the unit, scale and offset from the reader's preference instead.
|
||||
*/
|
||||
unit?: string;
|
||||
/** Y-axis unit label when it differs from the value unit. */
|
||||
yAxisUnit?: string;
|
||||
@@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record<
|
||||
kind: "health-chart",
|
||||
types: ["WEIGHT"],
|
||||
colors: ["var(--chart-1)"],
|
||||
unit: "kg",
|
||||
titleKey: "charts.weight",
|
||||
captionKey: "insights.trendsRow.caption.weight",
|
||||
annotationKey: "weight",
|
||||
@@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record<
|
||||
kind: "health-chart",
|
||||
types: ["WALKING_RUNNING_DISTANCE"],
|
||||
colors: ["var(--chart-4)"],
|
||||
unit: "km",
|
||||
yAxisUnit: "km",
|
||||
titleKey: "charts.distance",
|
||||
captionKey: "insights.trendsRow.caption.distance",
|
||||
detailHref: "/insights/walking-distance",
|
||||
@@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record<
|
||||
kind: "health-chart",
|
||||
types: ["BODY_TEMPERATURE"],
|
||||
colors: ["var(--destructive)"],
|
||||
unit: "°C",
|
||||
yAxisUnit: "°C",
|
||||
titleKey: "charts.bodyTemperature",
|
||||
captionKey: "insights.trendsRow.caption.body_temp",
|
||||
detailHref: "/insights/body-temperature",
|
||||
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user