mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'fix/v1473-record' into release/v1.47.3
This commit is contained in:
commit
f519df4433
84 files changed
+1368
-322
No files matched your search
@@ -13,10 +13,11 @@ import type {
|
||||
DashboardTargetBands,
|
||||
} from "@/lib/dashboard/snapshot";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildBandsFromTrafficRange,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
@@ -77,8 +78,8 @@ function mockTargetBands(): DashboardTargetBands {
|
||||
upperBound: 55,
|
||||
}),
|
||||
weightRange: buildWeightRangeFromHeight(180),
|
||||
weightBands: buildWeightBandsFromHeight(180, {
|
||||
lowerBound: 30,
|
||||
weightBands: buildBandsFromTrafficRange(buildWeightRangeFromHeight(180), {
|
||||
lowerBound: METRIC_BOUNDS.WEIGHT.min,
|
||||
upperBound: 250,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 344,
|
||||
"total": 111520,
|
||||
"count": 343,
|
||||
"total": 111318,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -94,7 +94,6 @@
|
||||
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 242,
|
||||
"components/documents/document-facts-review.tsx :: FactRow": 261,
|
||||
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
|
||||
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
|
||||
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
|
||||
"components/documents/documents-view.tsx :: DocumentsView": 303,
|
||||
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2892,
|
||||
"total": 9220,
|
||||
"total": 9213,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -643,7 +643,7 @@
|
||||
"app/insights/walking-heart-rate/page.tsx": 1,
|
||||
"app/insights/walking-speed/page.tsx": 1,
|
||||
"app/insights/walking-steadiness/page.tsx": 1,
|
||||
"app/insights/weight/page.tsx": 8,
|
||||
"app/insights/weight/page.tsx": 5,
|
||||
"app/insights/workouts/page-client.tsx": 2,
|
||||
"app/insights/wrist-temperature/page.tsx": 1,
|
||||
"app/invite/[token]/__tests__/page.test.tsx": 1,
|
||||
@@ -1958,10 +1958,10 @@
|
||||
"lib/doctor-report-helpers.ts": 1,
|
||||
"lib/doctor-report-pdf-core.ts": 4,
|
||||
"lib/doctor-report-pdf/clinical-records-notes-section.ts": 6,
|
||||
"lib/doctor-report-pdf/header-profile-section.ts": 3,
|
||||
"lib/doctor-report-pdf/header-profile-section.ts": 2,
|
||||
"lib/doctor-report-pdf/measurements-charts-section.ts": 3,
|
||||
"lib/doctor-report-pdf/medication-mood-wellness-section.ts": 7,
|
||||
"lib/doctor-report-types.ts": 21,
|
||||
"lib/doctor-report-types.ts": 20,
|
||||
"lib/doctor-report/__tests__/leaf-gating-sweep.test.ts": 1,
|
||||
"lib/doctor-report/clinical-records.ts": 2,
|
||||
"lib/doctor-report/collect.ts": 4,
|
||||
|
||||
@@ -45,8 +45,9 @@ const REGISTRY = "lib/measurements/display-transform.ts";
|
||||
/**
|
||||
* The one module allowed to import the unrounded escape hatch. The target
|
||||
* adapter needs the unrounded pound edge of a canonical guardrail so it can
|
||||
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
|
||||
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
|
||||
* round it INWARD — rounding to the nearest tenth first can put the pound
|
||||
* edge outside the kilogram window, and the server rejects what converts
|
||||
* back from it.
|
||||
*/
|
||||
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function emptyReport(): DoctorReportData {
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
name: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
@@ -156,11 +156,10 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
kind: "rendered",
|
||||
data: {
|
||||
patient: {
|
||||
username: "shared-account",
|
||||
name: "A. Patient",
|
||||
dateOfBirth: "1979-04-02T00:00:00.000Z",
|
||||
gender: "FEMALE",
|
||||
heightCm: 171,
|
||||
fullName: "A. Patient",
|
||||
},
|
||||
},
|
||||
marker: "A. Patient",
|
||||
|
||||
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
biomarkerId: true,
|
||||
unit: true,
|
||||
value: true,
|
||||
sourceValue: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
sourceReferenceLow: true,
|
||||
@@ -65,6 +66,7 @@ export const GET = apiHandler(async () => {
|
||||
biomarkerId: r.biomarkerId,
|
||||
unit: r.unit,
|
||||
value: r.value as number,
|
||||
sourceValue: r.sourceValue,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
sourceReferenceLow: r.sourceReferenceLow,
|
||||
|
||||
@@ -96,7 +96,7 @@ beforeEach(() => {
|
||||
|
||||
describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// WEIGHT bounds are min=30, max=300. Sending min > max + a value
|
||||
// WEIGHT bounds are min=1, max=300. Sending min > max + a value
|
||||
// out of bounds on PULSE forces two range errors.
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
|
||||
@@ -97,7 +97,7 @@ function report(): DoctorReportData {
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
name: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* `/insights/bmi` decides from a height whether it can show anything. It used
|
||||
* the height of the person at the keyboard, so inside somebody else's record
|
||||
* a viewer without a height of their own was asked to set it, and a viewer
|
||||
* with one hid that the record has none.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const shellProps = vi.fn();
|
||||
vi.mock("@/components/insights/sub-page-shell", () => ({
|
||||
SubPageShell: (props: Record<string, unknown>) => {
|
||||
shellProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
|
||||
}));
|
||||
|
||||
const state = {
|
||||
viewerHeight: null as number | null,
|
||||
inSharedRecord: true,
|
||||
recordHeight: 118 as number | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { heightCm: state.viewerHeight, timezone: "UTC" },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({ isEmpty: false }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ enabled }: { enabled?: boolean }) => ({
|
||||
data:
|
||||
enabled === false
|
||||
? undefined
|
||||
: { profile: { heightCm: state.recordHeight } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import InsightsBmiPage from "../page";
|
||||
|
||||
/** True when the page rendered its "set your height" state. */
|
||||
function asksForHeight(): boolean {
|
||||
renderToStaticMarkup(<InsightsBmiPage />);
|
||||
const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean };
|
||||
// The chart branch launches the Coach from the shell; the empty states do not.
|
||||
return props.coachLaunch !== true;
|
||||
}
|
||||
|
||||
describe("/insights/bmi height gate", () => {
|
||||
beforeEach(() => {
|
||||
shellProps.mockClear();
|
||||
state.viewerHeight = null;
|
||||
state.inSharedRecord = true;
|
||||
state.recordHeight = 118;
|
||||
});
|
||||
|
||||
it("reads the record's height inside somebody else's record", () => {
|
||||
expect(asksForHeight()).toBe(false);
|
||||
state.viewerHeight = 180;
|
||||
state.recordHeight = null;
|
||||
expect(asksForHeight()).toBe(true);
|
||||
});
|
||||
|
||||
it("reads the account's own height in one's own record", () => {
|
||||
state.inSharedRecord = false;
|
||||
state.viewerHeight = 180;
|
||||
expect(asksForHeight()).toBe(false);
|
||||
state.viewerHeight = null;
|
||||
expect(asksForHeight()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Ruler } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
@@ -45,6 +49,23 @@ export default function InsightsBmiPage() {
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("BMI");
|
||||
|
||||
// `useAuth()` is the person at the keyboard. Inside somebody else's record
|
||||
// the height that decides is the record's, from the route that knows which
|
||||
// record is on screen (the reference panel below reads the same cell).
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () =>
|
||||
apiGet<{ profile?: { heightCm: number | null } }>(
|
||||
"/api/insights/targets",
|
||||
),
|
||||
enabled: isAuthenticated && inSharedRecord,
|
||||
});
|
||||
const heightCm = inSharedRecord
|
||||
? (targets?.profile?.heightCm ?? null)
|
||||
: (user?.heightCm ?? null);
|
||||
const heightKnown = !inSharedRecord || targets !== undefined;
|
||||
|
||||
// v1.4.27 — BMI is derived from WEIGHT. When no weight readings
|
||||
// exist yet, the existing "set your height" branch can never compute
|
||||
// anything useful either; surface the empty-state CTA pointing at
|
||||
@@ -75,7 +96,7 @@ export default function InsightsBmiPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user?.heightCm) {
|
||||
if (heightKnown && !heightCm) {
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* `/insights/weight` shades the band of the record on screen. It used to take
|
||||
* the height of the person at the keyboard (`useAuth()`) and their own target
|
||||
* (`/api/user/thresholds`, refused under a switch), so a parent of 180 cm on
|
||||
* a child's record saw the parent's healthy band behind the child's weights.
|
||||
*
|
||||
* `SubPageShell` is stubbed to a probe so the test reads the band the page
|
||||
* hands the chart, not rendered pixels.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const shellProps = vi.fn();
|
||||
vi.mock("@/components/insights/sub-page-shell", () => ({
|
||||
SubPageShell: (props: Record<string, unknown>) => {
|
||||
shellProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key, locale: "en" }),
|
||||
}));
|
||||
|
||||
// The viewer: 180 cm, signed in, looking at somebody else's record.
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" },
|
||||
}),
|
||||
useAccountOnceMounted: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({
|
||||
data: { summaries: { WEIGHT: { count: 10 } } },
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The record on screen: 118 cm, no target of its own.
|
||||
const targets = {
|
||||
current: {
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
} as unknown,
|
||||
};
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({
|
||||
data: JSON.stringify(queryKey).includes("targets")
|
||||
? targets.current
|
||||
: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
import InsightsGewichtPage from "../page";
|
||||
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
|
||||
|
||||
function chartBands(): Array<{ min: number; max: number }> | undefined {
|
||||
renderToStaticMarkup(<InsightsGewichtPage />);
|
||||
const props = shellProps.mock.calls.at(-1)![0] as { children: unknown };
|
||||
const children = Array.isArray(props.children)
|
||||
? props.children
|
||||
: [props.children];
|
||||
const chart = children.find(
|
||||
(el) =>
|
||||
el &&
|
||||
typeof el === "object" &&
|
||||
Array.isArray((el as { props?: { types?: unknown } }).props?.types),
|
||||
) as { props: { valueBands?: Array<{ min: number; max: number }> } };
|
||||
return chart.props.valueBands;
|
||||
}
|
||||
|
||||
describe("/insights/weight band", () => {
|
||||
beforeEach(() => shellProps.mockClear());
|
||||
|
||||
it("shades the WHO band for the record's height, not the viewer's", () => {
|
||||
const bands = chartBands();
|
||||
const record = buildWeightRangeFromHeight(118);
|
||||
const viewer = buildWeightRangeFromHeight(180);
|
||||
expect(bands).toContainEqual(
|
||||
expect.objectContaining({ min: record.greenMin, max: record.greenMax }),
|
||||
);
|
||||
expect(bands).not.toContainEqual(
|
||||
expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shades no band when the record's targets are not readable", () => {
|
||||
targets.current = undefined;
|
||||
try {
|
||||
expect(chartBands()).toBeUndefined();
|
||||
} finally {
|
||||
targets.current = {
|
||||
targets: [],
|
||||
profile: { heightCm: 118 },
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The weight band belongs to the record on screen. It used to be built from
|
||||
* the signed-in person's height, so a parent of 180 cm looking at a child's
|
||||
* record saw a healthy band of about 60 to 81 kg behind the child's weights.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
|
||||
import { selectWeightBandRange } from "../weight-band";
|
||||
|
||||
describe("selectWeightBandRange", () => {
|
||||
it("builds the WHO band from the record's height", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
});
|
||||
expect(range).toEqual(buildWeightRangeFromHeight(118));
|
||||
});
|
||||
|
||||
it("paints the record's own target with the override wings", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" },
|
||||
],
|
||||
profile: { heightCm: 180 },
|
||||
});
|
||||
expect(range).toEqual({
|
||||
greenMin: 70,
|
||||
greenMax: 80,
|
||||
orangeMin: 68.5,
|
||||
orangeMax: 81.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("paints nothing without a target or a height, or before the read lands", () => {
|
||||
expect(
|
||||
selectWeightBandRange({
|
||||
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
|
||||
profile: { heightCm: null },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(selectWeightBandRange(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,9 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation-
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
buildBandsFromTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
|
||||
import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands";
|
||||
import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band";
|
||||
|
||||
/**
|
||||
* v1.4.25 — `/insights/weight`.
|
||||
@@ -37,8 +35,8 @@ import {
|
||||
* (`chartKey="weight"`) lets the user toggle trend lines + comparison
|
||||
* overlay independently from the dashboard weight card.
|
||||
*
|
||||
* v1.34 — the bands follow the user's own weight target when one is set,
|
||||
* and fall back to the height-derived WHO band only when it is not.
|
||||
* The bands follow the record's own weight target when one is set, and fall
|
||||
* back to the WHO band for the record's height only when it is not.
|
||||
*
|
||||
* v1.4.28 — analytics fetch + empty-state
|
||||
* branch now consume `useInsightsAnalytics()` + `<MetricEmptyState>`.
|
||||
@@ -56,19 +54,13 @@ export default function InsightsGewichtPage() {
|
||||
} = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
|
||||
// v1.34 — the user's own weight target, read through the shared
|
||||
// `queryKeys.userThresholds()` cell the targets editor also subscribes to, so
|
||||
// editing the target repaints this chart's band without a reload.
|
||||
const { data: thresholds } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
return apiGet<{
|
||||
effective: Record<
|
||||
string,
|
||||
{ range: TrafficRange | null; isOverride: boolean }
|
||||
>;
|
||||
}>("/api/user/thresholds");
|
||||
},
|
||||
// The record's own target and height, from the route that knows which
|
||||
// record is on screen (see `selectWeightBandRange`). Shares the
|
||||
// `insightsTargets()` cell with the reference panel below; the targets
|
||||
// editor invalidates it on save, so the band repaints without a reload.
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () => apiGet<WeightTargetsSlice>("/api/insights/targets"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
@@ -136,27 +128,14 @@ export default function InsightsGewichtPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.34 — the shaded zone follows the user's OWN target when they set one.
|
||||
// The page used to shade the height-derived WHO band unconditionally, so
|
||||
// someone who had entered a target on `/targets` kept reading a chart that
|
||||
// ignored the answer. `/api/user/thresholds` already resolves the override
|
||||
// (with its orange wings) through `getEffectiveRange`, so the page reads the
|
||||
// resolved band rather than re-deriving one. No target and no height → no
|
||||
// bands, unchanged.
|
||||
const overrideRange = thresholds?.effective?.WEIGHT?.isOverride
|
||||
? (thresholds.effective.WEIGHT.range ?? null)
|
||||
: null;
|
||||
const weightBands = overrideRange
|
||||
? buildBandsFromTrafficRange(overrideRange, {
|
||||
lowerBound: 30,
|
||||
// No target and no height → no bands.
|
||||
const weightRange = selectWeightBandRange(targets ?? null);
|
||||
const weightBands = weightRange
|
||||
? buildBandsFromTrafficRange(weightRange, {
|
||||
lowerBound: METRIC_BOUNDS.WEIGHT.min,
|
||||
upperBound: 250,
|
||||
})
|
||||
: user?.heightCm
|
||||
? buildWeightBandsFromHeight(user.heightCm, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: undefined;
|
||||
: undefined;
|
||||
// The bands are kg bounds → scale into the display unit so the shaded zone
|
||||
// tracks the converted line (factor-only; weight carries no offset).
|
||||
const displayBands =
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getEffectiveRange } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
buildWeightRangeFromHeight,
|
||||
type TrafficRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
|
||||
/** The slice of `GET /api/insights/targets` the weight band needs. */
|
||||
export interface WeightTargetsSlice {
|
||||
targets: ReadonlyArray<{
|
||||
type: string;
|
||||
range: { min: number; max: number } | null;
|
||||
source: string;
|
||||
}>;
|
||||
profile?: { heightCm: number | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The weight band for the record on screen, as the server resolved it.
|
||||
*
|
||||
* The page used to read the person at the keyboard: their height from
|
||||
* `useAuth()` and their own target from `/api/user/thresholds`, which
|
||||
* refuses inside somebody else's record. A parent on a child's record saw a
|
||||
* band built from the parent's height. `/api/insights/targets` resolves the
|
||||
* record's own target and height, and the reference panel under the chart
|
||||
* reads the same payload. The record's own target (`source: "Custom"`) gets
|
||||
* the wings `getEffectiveRange` gives every override; otherwise the band is
|
||||
* the height-derived WHO one, and with neither there is none.
|
||||
*/
|
||||
export function selectWeightBandRange(
|
||||
data: WeightTargetsSlice | null,
|
||||
): TrafficRange | null {
|
||||
if (!data) return null;
|
||||
const weight = data.targets.find((entry) => entry.type === "WEIGHT");
|
||||
if (weight?.source === "Custom" && weight.range) {
|
||||
return getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
{ WEIGHT: weight.range },
|
||||
).range;
|
||||
}
|
||||
const heightCm = data.profile?.heightCm ?? null;
|
||||
return heightCm ? buildWeightRangeFromHeight(heightCm) : null;
|
||||
}
|
||||
@@ -42,11 +42,10 @@ const FULL_RECORD = {
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "shared-account",
|
||||
name: "A. Patient",
|
||||
dateOfBirth: "1980-01-01T00:00:00.000Z",
|
||||
gender: "FEMALE",
|
||||
heightCm: 170,
|
||||
fullName: "A. Patient",
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
|
||||
@@ -27,7 +27,7 @@ function makeReport(
|
||||
end: "2026-01-31T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "tester",
|
||||
name: "tester",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
@@ -72,6 +72,7 @@ function render(
|
||||
| "selection"
|
||||
| "timeFormat"
|
||||
| "dateFormat"
|
||||
| "timezone"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
@@ -501,6 +502,45 @@ describe("<ClinicianView>", () => {
|
||||
expect(html).not.toContain('<img src="/c/hls_tok/d/doc-b"');
|
||||
expect(html).toContain("Referral letter");
|
||||
});
|
||||
|
||||
it("prints the date of birth on its own day west of UTC", () => {
|
||||
const html = render(
|
||||
makeReport({
|
||||
patient: {
|
||||
name: "A. Patient",
|
||||
// Stored at UTC midnight: 13 May, 20:00 in New York.
|
||||
dateOfBirth: "1960-05-14T00:00:00.000Z",
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
},
|
||||
}),
|
||||
{ timezone: "America/New_York", locale: "en" },
|
||||
);
|
||||
expect(html).toContain("05/14/1960");
|
||||
expect(html).not.toContain("05/13/1960");
|
||||
});
|
||||
|
||||
it("dates a vaccination on its own day west of UTC", () => {
|
||||
const html = render(
|
||||
makeReport({
|
||||
immunizations: [
|
||||
{
|
||||
// A dose date at UTC midnight: the evening before in New York.
|
||||
occurredAt: "2026-04-11T00:00:00.000Z",
|
||||
antigenSlug: null,
|
||||
vaccineName: "Influenza",
|
||||
lotNumber: null,
|
||||
site: null,
|
||||
practitionerName: null,
|
||||
series: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ timezone: "America/New_York", locale: "en" },
|
||||
);
|
||||
expect(html).toContain("04/11/2026");
|
||||
expect(html).not.toContain("04/10/2026");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
* document list in `./documents-list`, the downloads in `./download-actions`.
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value";
|
||||
import {
|
||||
makeFormatters,
|
||||
@@ -133,6 +134,15 @@ interface ClinicianViewProps {
|
||||
dateFormat: DateFormatPreference;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date-only value (a date of birth, a dose date) as its calendar date in
|
||||
* the owner's date order, the same in every zone. Read as an instant in the
|
||||
* owner's zone it fell on the previous day west of UTC.
|
||||
*/
|
||||
function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) {
|
||||
return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale);
|
||||
}
|
||||
|
||||
export function ClinicianView({
|
||||
t,
|
||||
label,
|
||||
@@ -199,7 +209,7 @@ export function ClinicianView({
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
|
||||
/>
|
||||
|
||||
{/* ── measurements ─────────────────────────────────────── */}
|
||||
@@ -298,7 +308,7 @@ export function ClinicianView({
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
fmtCalendarDate={calendarDateIn(dateFormat, locale)}
|
||||
/>
|
||||
<AllergiesSection t={t} report={report} scope={scope} />
|
||||
|
||||
|
||||
@@ -302,12 +302,13 @@ export function ImmunizationsSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
fmtCalendarDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
/** A dose date is a calendar date at UTC midnight, never an instant. */
|
||||
fmtCalendarDate: (iso: string) => string;
|
||||
}) {
|
||||
const doses = report.immunizations ?? [];
|
||||
|
||||
@@ -342,7 +343,7 @@ export function ImmunizationsSection({
|
||||
: (dose.vaccineName ?? "—")
|
||||
}
|
||||
value={compose([
|
||||
fmtDate(dose.occurredAt),
|
||||
fmtCalendarDate(dose.occurredAt),
|
||||
doseDisplay(dose.series),
|
||||
dose.lotNumber
|
||||
? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}`
|
||||
|
||||
@@ -48,15 +48,15 @@ export function PatientIdentitySection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
fmtCalendarDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtDate: (iso: string) => string;
|
||||
fmtCalendarDate: (iso: string) => string;
|
||||
}) {
|
||||
const patient = report.patient;
|
||||
const name = patient.fullName ?? patient.username ?? null;
|
||||
const name = patient.name;
|
||||
const genderKey = patient.gender
|
||||
? GENDER_LABEL_KEYS[patient.gender]
|
||||
: undefined;
|
||||
@@ -67,7 +67,7 @@ export function PatientIdentitySection({
|
||||
if (patient.dateOfBirth) {
|
||||
rows.push({
|
||||
label: t("doctorReport.dateOfBirth"),
|
||||
value: fmtDate(patient.dateOfBirth),
|
||||
value: fmtCalendarDate(patient.dateOfBirth),
|
||||
});
|
||||
}
|
||||
if (genderKey) {
|
||||
|
||||
@@ -42,7 +42,11 @@ const idleQuery = {
|
||||
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
|
||||
|
||||
vi.mock("../use-cycle", () => ({
|
||||
localYmd: () => "2026-07-01",
|
||||
useCycleWindow: () => ({
|
||||
today: "2026-07-01",
|
||||
from: "2026-04-02",
|
||||
to: "2026-12-28",
|
||||
}),
|
||||
useCycleCalendar: () => errorQuery,
|
||||
useCycleHistory: () => idleQuery,
|
||||
useCycleInsights: () => idleQuery,
|
||||
|
||||
@@ -95,7 +95,7 @@ const idleQuery = {
|
||||
const mutationStub = { mutate: vi.fn(), isPending: false, isError: false };
|
||||
|
||||
vi.mock("../use-cycle", () => ({
|
||||
localYmd: () => TODAY,
|
||||
useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }),
|
||||
useCycleCalendar: () => calendarQuery,
|
||||
useCycleHistory: () => idleQuery,
|
||||
useCycleInsights: () => idleQuery,
|
||||
|
||||
@@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { localYmd, useCycleRingDial } from "../use-cycle";
|
||||
import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import { useCycleRingDial } from "../use-cycle";
|
||||
|
||||
/** The ring's window, around today in the display zone (Berlin when unset). */
|
||||
function window(): [string, string] {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 90);
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + 180);
|
||||
return [localYmd(from), localYmd(to)];
|
||||
const today = userDayKey(new Date(), DEFAULT_TIMEZONE);
|
||||
return [shiftDateKey(today, -90), shiftDateKey(today, 180)];
|
||||
}
|
||||
|
||||
function Probe({ enabled }: { enabled: boolean }) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The cycle surfaces take "today" from the record's display zone, the zone
|
||||
* the server keys cycle days in. They used to read the device clock, so with
|
||||
* a phone in another zone than the profile the highlighted day and the day a
|
||||
* log lands on sat one apart from the server's around midnight.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
|
||||
// UTC+14: already the next calendar day for most of the world's afternoon.
|
||||
useDisplayTimezone: () => "Pacific/Kiritimati",
|
||||
}));
|
||||
|
||||
import { useCycleWindow } from "../use-cycle";
|
||||
|
||||
function Probe() {
|
||||
const { today, from, to } = useCycleWindow();
|
||||
return <span>{`${from}|${today}|${to}`}</span>;
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useCycleWindow", () => {
|
||||
it("dates today in the display zone, not on the device clock", () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z"));
|
||||
expect(renderToStaticMarkup(<Probe />)).toContain(
|
||||
"2026-04-03|2026-07-02|2026-12-29",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Droplets } from "lucide-react";
|
||||
|
||||
@@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
|
||||
import {
|
||||
useCycleCalendar,
|
||||
useCycleInsights,
|
||||
useCycleWindow,
|
||||
} from "./use-cycle";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
/**
|
||||
@@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
* a broken or empty-framed state on the overview.
|
||||
*/
|
||||
|
||||
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
|
||||
function shiftToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
export function CycleInsightSummaryCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Mirror the cycle-view window so the cache key is shared (no extra request
|
||||
// when the user later opens the cycle page in the same session).
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
const { from, to } = useCycleWindow();
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const insights = useCycleInsights();
|
||||
|
||||
@@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
localYmd,
|
||||
useCycleWindow,
|
||||
useCycleCalendar,
|
||||
useCycleHistory,
|
||||
useCycleInsights,
|
||||
@@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice";
|
||||
* re-triggers the moment.
|
||||
*/
|
||||
|
||||
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
|
||||
function shiftToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
/** The four cycle tabs, in render order. The deep-link value is validated
|
||||
* against this set so a junk `?tab=` falls back to the calendar tab. */
|
||||
// The BBT chart is the only recharts consumer on /cycle — load it through
|
||||
@@ -114,9 +107,7 @@ export function CycleView() {
|
||||
// a later param change does not force-switch a tab the user is reading).
|
||||
const [tab, setTab] = useState<CycleTab>(initialTab);
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
const { today, from, to } = useCycleWindow();
|
||||
|
||||
const calendar = useCycleCalendar(from, to);
|
||||
const history = useCycleHistory();
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
@@ -72,16 +73,23 @@ export interface CycleInsightsResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
|
||||
* are tz-anchored on the server; the client must derive the key from the
|
||||
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
|
||||
* the date over near midnight).
|
||||
* Today, and the window the cycle page, its ring and its summary card read
|
||||
* (90 days back, 180 ahead), in the record's display zone. The server keys
|
||||
* cycle days, "today" included, in the profile zone; the device clock put
|
||||
* the highlighted day and the day a log lands on one apart from it when a
|
||||
* phone sat in another zone near midnight. One window, so the three readers
|
||||
* share one cache entry.
|
||||
*/
|
||||
export function localYmd(d: Date = new Date()): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
export function useCycleWindow(): { today: string; from: string; to: string } {
|
||||
const timeZone = useDisplayTimezone();
|
||||
return useMemo(() => {
|
||||
const today = userDayKey(new Date(), timeZone);
|
||||
return {
|
||||
today,
|
||||
from: shiftDateKey(today, -90),
|
||||
to: shiftDateKey(today, 180),
|
||||
};
|
||||
}, [timeZone]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD for `days` from today in the local zone. */
|
||||
function localYmdFromToday(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar window the cycle ring reads: the cycle page's own window, so
|
||||
* the ring, the page and the summary card share one cache entry.
|
||||
*/
|
||||
/** The calendar window the cycle ring reads: the cycle page's own. */
|
||||
export function useCycleRingCalendar(enabled = true) {
|
||||
const from = useMemo(() => localYmdFromToday(-90), []);
|
||||
const to = useMemo(() => localYmdFromToday(180), []);
|
||||
const { from, to } = useCycleWindow();
|
||||
return useCycleCalendar(from, to, enabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
@@ -161,6 +162,7 @@ export function DocumentRecordLinks({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const calendarDate = useCalendarDate();
|
||||
const { user } = useAuth();
|
||||
const vaccinationsOn = user?.modules?.vaccinations !== false;
|
||||
const anchor = doc.documentDate ?? doc.reportDate;
|
||||
@@ -186,10 +188,7 @@ export function DocumentRecordLinks({
|
||||
const [visitIds, setVisitIds] = useState<string[] | null>(null);
|
||||
const [doseIds, setDoseIds] = useState<string[] | null>(null);
|
||||
|
||||
const labels = {
|
||||
suggested: t("links.picker.suggested"),
|
||||
date: (iso: string) => format.date(iso),
|
||||
};
|
||||
const suggested = t("links.picker.suggested");
|
||||
|
||||
const visitOptions = recordOptions(
|
||||
[...encounters.items]
|
||||
@@ -203,7 +202,7 @@ export function DocumentRecordLinks({
|
||||
href: visitHref(visit.id),
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
{ suggested, date: format.date },
|
||||
);
|
||||
const doseOptions = recordOptions(
|
||||
(vaccinations.data?.vaccinations ?? []).map((dose) => ({
|
||||
@@ -213,7 +212,7 @@ export function DocumentRecordLinks({
|
||||
href: doseHref(dose.id),
|
||||
})),
|
||||
anchor,
|
||||
labels,
|
||||
{ suggested, date: calendarDate }, // a dose date is a calendar date
|
||||
);
|
||||
|
||||
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
|
||||
@@ -324,7 +323,7 @@ export function DocumentRecordLinks({
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
{calendarDate(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The card prints a lab figure the way the labs page does: in the reader's
|
||||
* notation and with the digits the reading holds. It used to print
|
||||
* `String(value)`, so a German reader saw "4.6" beside a labs page that says
|
||||
* "4,6", and a converted reading showed every digit of the conversion.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
present: true,
|
||||
latestDate: "2026-06-01",
|
||||
previousDate: "2026-03-01",
|
||||
generatedAt: "2026-06-02T00:00:00.000Z",
|
||||
changes: [
|
||||
{
|
||||
analyte: "TSH",
|
||||
unit: "mIU/L",
|
||||
latest: 0.009,
|
||||
previous: 0.004,
|
||||
delta: 0.005,
|
||||
direction: "up",
|
||||
status: "unknown",
|
||||
},
|
||||
{
|
||||
analyte: "Potassium",
|
||||
unit: "mmol/L",
|
||||
latest: 4.6,
|
||||
previous: 4.9,
|
||||
delta: -0.3,
|
||||
direction: "down",
|
||||
status: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LabsChangesCard } from "../labs-changes-card";
|
||||
|
||||
describe("<LabsChangesCard>", () => {
|
||||
it("prints values and deltas in the reader's notation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<LabsChangesCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("0,009 mIU/L");
|
||||
expect(html).toContain("+0,005 mIU/L");
|
||||
expect(html).toContain("4,6 mmol/L");
|
||||
expect(html).toContain("-0,3 mmol/L");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/insights/weight",
|
||||
}));
|
||||
|
||||
// The target gear asks whether this is somebody else's record; the shell
|
||||
// renders here without the session it would read that from.
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: false }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.25 — `<SubPageShell>` unit tests.
|
||||
*
|
||||
|
||||
@@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({
|
||||
useTargetAdjust: () => useTargetAdjustMock(),
|
||||
}));
|
||||
|
||||
const capabilities = { inSharedRecord: false };
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => capabilities,
|
||||
}));
|
||||
|
||||
const { TargetAdjustButton } = await import("../target-adjust-button");
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
@@ -53,6 +58,22 @@ describe("<TargetAdjustButton>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing inside somebody else's record", () => {
|
||||
// The editor reads and writes the signed-in account's own targets, which
|
||||
// the server refuses under a switch: the gear would open a dead sheet.
|
||||
useTargetAdjustMock.mockReturnValue({
|
||||
canAdjust: true,
|
||||
requestAdjust: vi.fn(),
|
||||
register: vi.fn(),
|
||||
});
|
||||
capabilities.inSharedRecord = true;
|
||||
try {
|
||||
expect(render(<TargetAdjustButton />)).toBe("");
|
||||
} finally {
|
||||
capabilities.inSharedRecord = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the gear once an editable target is registered", () => {
|
||||
useTargetAdjustMock.mockReturnValue({
|
||||
canAdjust: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -55,6 +56,7 @@ export function LabsChangesCard({
|
||||
}: LabsChangesCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const labNumber = useLabNumber();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
@@ -82,7 +84,10 @@ export function LabsChangesCard({
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
|
||||
: withLabUnit(
|
||||
`${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`,
|
||||
c.unit,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
@@ -94,7 +99,7 @@ export function LabsChangesCard({
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{withLabUnit(String(c.latest), c.unit)}
|
||||
{withLabUnit(labNumber(c.latest), c.unit)}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
/**
|
||||
* Header gear that opens the per-metric target editor.
|
||||
@@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
* has registered for the page (e.g. a metric without a numeric
|
||||
* target band, where the card paints nothing), so the header never
|
||||
* shows a dead control;
|
||||
* - renders nothing inside somebody else's record: the editor loads and
|
||||
* saves the signed-in account's own targets (`/api/user/thresholds`),
|
||||
* which refuses under a switch, so the gear would open a sheet that
|
||||
* can do neither;
|
||||
* - opens the primary registered target's `<TargetEditSheet>` on click.
|
||||
*
|
||||
* Sized to match `<CoachLaunchButton variant="icon">` so the gear + the
|
||||
@@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
export function TargetAdjustButton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const adjust = useTargetAdjust();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
|
||||
// No provider, or no editable target registered for this page.
|
||||
if (!adjust || !adjust.canAdjust) return null;
|
||||
// No provider, no editable target registered for this page, or a record
|
||||
// whose targets this editor cannot reach.
|
||||
if (!adjust || !adjust.canAdjust || inSharedRecord) return null;
|
||||
|
||||
// Reuse the action's existing copy ("Adjust target range" /
|
||||
// "Zielbereich anpassen") — the gear performs exactly what the old
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { accountName } from "@/lib/account-name";
|
||||
import { accountName, nameInitials } from "@/lib/account-name";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -98,14 +98,6 @@ export function writeSidebarCollapsedPref(collapsed: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar footer's name-and-avatar block.
|
||||
*
|
||||
@@ -170,7 +162,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<Avatar className="h-9 w-9 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(name)}
|
||||
{nameInitials(name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<Avatar className="h-8 w-8" aria-hidden="true">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(name)}
|
||||
{nameInitials(name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(name)}
|
||||
{nameInitials(name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Link from "next/link";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { accountName } from "@/lib/account-name";
|
||||
import { accountName, nameInitials } from "@/lib/account-name";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -41,14 +41,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
const { user, isLoading } = useAuth();
|
||||
// v1.36.0 — acting on somebody else's record; see the menu below. Read from
|
||||
@@ -147,7 +139,7 @@ export function TopBar() {
|
||||
<AvatarImage src={avatarUrl} alt={accountName(user)} />
|
||||
)}
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
|
||||
{getInitials(accountName(user))}
|
||||
{nameInitials(accountName(user))}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden sm:inline">{accountName(user)}</span>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type EntityLinkOption,
|
||||
} from "@/components/links/entity-link-picker";
|
||||
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import {
|
||||
pickerPivot,
|
||||
useLinkPickerPages,
|
||||
@@ -133,6 +134,7 @@ export function RelatedRecordsList({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const calendarDate = useCalendarDate();
|
||||
const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -142,7 +144,12 @@ export function RelatedRecordsList({
|
||||
const via = viaText(t, row.via);
|
||||
const meta = [
|
||||
kindText(t, row.kind),
|
||||
row.date ? format.date(row.date) : null,
|
||||
row.date
|
||||
? // A dose date is a calendar date at UTC midnight.
|
||||
row.kind === "vaccination"
|
||||
? calendarDate(row.date)
|
||||
: format.date(row.date)
|
||||
: null,
|
||||
via,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -381,7 +388,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) {
|
||||
|
||||
function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const calendarDate = useCalendarDate();
|
||||
const doses = useVaccinations(null, true);
|
||||
const options = recordOptions(
|
||||
(doses.data?.vaccinations ?? []).map((dose) => ({
|
||||
@@ -393,10 +400,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
|
||||
href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`,
|
||||
})),
|
||||
anchor,
|
||||
{
|
||||
suggested: t("links.picker.suggested"),
|
||||
date: (iso: string) => format.date(iso),
|
||||
},
|
||||
{ suggested: t("links.picker.suggested"), date: calendarDate },
|
||||
);
|
||||
return (
|
||||
<EntityLinkPicker
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The avatar fallback on the settings card spells the name the rest of the
|
||||
* app shows. It used to take its letters from the login, so an account named
|
||||
* "Robin Example" with the login "rex" read "RE" in the top bar and the
|
||||
* sidebar and "R" here.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "rex",
|
||||
displayName: "Robin Example",
|
||||
avatarUrl: null,
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AvatarSection } from "../avatar-section";
|
||||
|
||||
describe("the settings avatar fallback", () => {
|
||||
it("builds its initials from the display name, not the login", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AvatarSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain(">RE<");
|
||||
expect(html).not.toContain(">R<");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { statusText, type StatusMessage } from "./account-section-utils";
|
||||
import { accountName, nameInitials } from "@/lib/account-name";
|
||||
|
||||
// Accepted upload types + byte cap mirror the server contract in
|
||||
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
|
||||
@@ -21,14 +22,6 @@ import { statusText, type StatusMessage } from "./account-section-utils";
|
||||
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function getAvatarInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
@@ -123,9 +116,9 @@ export function AvatarSection() {
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={accountName(user)} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
{nameInitials(accountName(user))}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { accountName } from "../account-name";
|
||||
import { accountName, nameInitials, patientName } from "../account-name";
|
||||
|
||||
describe("accountName", () => {
|
||||
it("names the account by its display name when one is set", () => {
|
||||
@@ -19,3 +19,41 @@ describe("accountName", () => {
|
||||
expect(accountName({ username: "jdoe" })).toBe("jdoe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("patientName", () => {
|
||||
const own = {
|
||||
username: "jdoe",
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
managedProfileAt: null,
|
||||
};
|
||||
const managed = {
|
||||
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
it("prefers the full name, then the display name, then the login", () => {
|
||||
expect(
|
||||
patientName({ ...own, fullName: "Jane Doe", displayName: "Jane" }),
|
||||
).toBe("Jane Doe");
|
||||
expect(patientName({ ...own, fullName: " ", displayName: "Jane" })).toBe(
|
||||
"Jane",
|
||||
);
|
||||
expect(patientName(own)).toBe("jdoe");
|
||||
});
|
||||
|
||||
it("never prints a managed record's generated login", () => {
|
||||
expect(patientName({ ...managed, displayName: "Robin" })).toBe("Robin");
|
||||
expect(patientName(managed)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameInitials", () => {
|
||||
it("takes the first letter of the first two words", () => {
|
||||
expect(nameInitials("Jane Doe")).toBe("JD");
|
||||
expect(nameInitials("max.mustermann")).toBe("MM");
|
||||
expect(nameInitials("Robin")).toBe("R");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "testuser",
|
||||
name: "testuser",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
@@ -1003,12 +1003,11 @@ describe("doctor-report emergency first page", () => {
|
||||
});
|
||||
|
||||
describe("extracted doctor-report section boundaries", () => {
|
||||
it("header/profile prefers the legal name and renders insurance identity", async () => {
|
||||
it("header/profile prints the resolved name and renders insurance identity", async () => {
|
||||
const data = makeData({
|
||||
patient: {
|
||||
...makeData().patient,
|
||||
fullName: "Ada Lovelace",
|
||||
username: "ignored-handle",
|
||||
name: "Ada Lovelace",
|
||||
insurerName: "Example Health",
|
||||
},
|
||||
});
|
||||
@@ -1022,7 +1021,6 @@ describe("extracted doctor-report section boundaries", () => {
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Ada Lovelace");
|
||||
expect(text).not.toContain("ignored-handle");
|
||||
expect(text).toContain("Example Health");
|
||||
expect(text).toContain("A123456789");
|
||||
});
|
||||
@@ -1178,6 +1176,63 @@ describe("doctor report — the user's date order", () => {
|
||||
);
|
||||
expect(text).toContain("05/03/2026");
|
||||
});
|
||||
|
||||
it("prints the date of birth on its own day west of UTC", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(
|
||||
makeData({
|
||||
patient: {
|
||||
...makeData().patient,
|
||||
// Stored at UTC midnight: 13 May, 20:00 in New York.
|
||||
dateOfBirth: "1960-05-14T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
{
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
userTz: "America/New_York",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(text).toContain("05/14/1960");
|
||||
expect(text).not.toContain("05/13/1960");
|
||||
});
|
||||
|
||||
it("dates a vaccination on its own day west of UTC", async () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
const text = await extractText(
|
||||
renderDoctorReportPdfBytes(
|
||||
makeData({
|
||||
immunizations: [
|
||||
{
|
||||
// A dose date at UTC midnight: 10 April, 20:00 in New York.
|
||||
occurredAt: "2026-04-11T00:00:00.000Z",
|
||||
antigenSlug: null,
|
||||
vaccineName: "Influenza",
|
||||
lotNumber: null,
|
||||
site: null,
|
||||
practitionerName: null,
|
||||
series: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
userTz: "America/New_York",
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(text).toContain("04/11/2026");
|
||||
expect(text).not.toContain("04/10/2026");
|
||||
});
|
||||
});
|
||||
|
||||
// ── script coverage ── the report is read in every shipped locale and carries
|
||||
|
||||
@@ -11,3 +11,39 @@ export function accountName(user: {
|
||||
const displayName = user.displayName?.trim();
|
||||
return displayName ? displayName : user.username;
|
||||
}
|
||||
|
||||
/** The columns `patientName()` reads, for a Prisma `select`. */
|
||||
export const PATIENT_NAME_SELECT = {
|
||||
username: true,
|
||||
displayName: true,
|
||||
fullName: true,
|
||||
managedProfileAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The name a clinical document gives a person: the full name when one is
|
||||
* set, the display name next, the login last. A managed record's login is a
|
||||
* handle generated when the record was created (`managed-<hex>`), not a name
|
||||
* anybody chose, so it never prints; with no other name the line is left out.
|
||||
*/
|
||||
export function patientName(user: {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
fullName: string | null;
|
||||
managedProfileAt: Date | null;
|
||||
}): string | null {
|
||||
const fullName = user.fullName?.trim();
|
||||
if (fullName) return fullName;
|
||||
const displayName = user.displayName?.trim();
|
||||
if (displayName) return displayName;
|
||||
return user.managedProfileAt ? null : user.username;
|
||||
}
|
||||
|
||||
/** Up to two letters for an avatar, from a name such as `accountName()`. */
|
||||
export function nameInitials(name: string): string {
|
||||
return name
|
||||
.split(/[\s._-]+/)
|
||||
.slice(0, 2)
|
||||
.map((s) => s[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
getAllEffectiveRanges,
|
||||
@@ -313,3 +315,37 @@ describe("resolveWeightTargetOverride", () => {
|
||||
expect(resolveWeightTargetOverride(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a child's weight target", () => {
|
||||
// A guardian keeping a record for a six-year-old sets 20 to 23 kg. The old
|
||||
// 30 kg floor refused it with nothing but "please check your entries", and
|
||||
// the chart's hard-coded 30 kg lower edge dropped the low red zone.
|
||||
const child = { WEIGHT: { min: 20, max: 23 } };
|
||||
|
||||
it("is accepted by the same schema both save paths parse", () => {
|
||||
expect(thresholdsUpdateSchema.safeParse(child).success).toBe(true);
|
||||
expect(
|
||||
thresholdsUpdateSchema.safeParse({ WEIGHT: { min: 0.5, max: 3 } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("paints every band below and above the target", () => {
|
||||
const range = getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{ heightCm: 118, dateOfBirth: null, gender: null },
|
||||
child,
|
||||
).range!;
|
||||
const bands = buildBandsFromTrafficRange(range, {
|
||||
lowerBound: METRIC_BOUNDS.WEIGHT.min,
|
||||
upperBound: 250,
|
||||
});
|
||||
expect(bands.map((band) => band.color)).toEqual([
|
||||
"var(--destructive)",
|
||||
"var(--warning)",
|
||||
"var(--success)",
|
||||
"var(--warning)",
|
||||
"var(--destructive)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
* consistently.
|
||||
*/
|
||||
|
||||
import { VALUE_RANGES } from "@/lib/measurements/value-ranges";
|
||||
import { binaryReferenceSex } from "@/lib/profile/sex";
|
||||
import { getBpTargets, type BpTargets } from "./bp-targets";
|
||||
import {
|
||||
@@ -65,7 +66,10 @@ export const METRIC_BOUNDS: Record<
|
||||
ThresholdMetric,
|
||||
{ min: number; max: number; unit: string }
|
||||
> = {
|
||||
WEIGHT: { min: 30, max: 300, unit: "kg" },
|
||||
// The floor is the measurement floor: a target refused for a weight the
|
||||
// record can hold (a child's 20 kg under a 30 kg floor) left a guardian
|
||||
// with nothing but "please check your entries".
|
||||
WEIGHT: { min: VALUE_RANGES.WEIGHT.min, max: 300, unit: "kg" },
|
||||
BLOOD_PRESSURE_SYS: { min: 80, max: 220, unit: "mmHg" },
|
||||
BLOOD_PRESSURE_DIA: { min: 40, max: 140, unit: "mmHg" },
|
||||
PULSE: { min: 30, max: 220, unit: "bpm" },
|
||||
|
||||
@@ -100,10 +100,9 @@ export function buildWeightRangeFromHeight(heightCm: number): TrafficRange {
|
||||
*/
|
||||
export function buildBandsFromTrafficRange(
|
||||
range: TrafficRange,
|
||||
options: { lowerBound?: number; upperBound?: number } = {},
|
||||
options: { lowerBound: number; upperBound: number },
|
||||
): ValueBand[] {
|
||||
const lowerBound = options.lowerBound ?? 30;
|
||||
const upperBound = options.upperBound ?? 250;
|
||||
const { lowerBound, upperBound } = options;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -139,16 +138,6 @@ export function buildBandsFromTrafficRange(
|
||||
].filter((band) => band.max > band.min);
|
||||
}
|
||||
|
||||
export function buildWeightBandsFromHeight(
|
||||
heightCm: number,
|
||||
options: { lowerBound?: number; upperBound?: number } = {},
|
||||
): ValueBand[] {
|
||||
return buildBandsFromTrafficRange(
|
||||
buildWeightRangeFromHeight(heightCm),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-fat target range — the "green" band shown on the dashboard /
|
||||
* targets page / chart value-bands.
|
||||
|
||||
@@ -10,10 +10,11 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildTargetBands } from "../snapshot";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
buildTrafficLightBands,
|
||||
buildTrafficRange,
|
||||
buildWeightBandsFromHeight,
|
||||
buildBandsFromTrafficRange,
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
} from "@/lib/analytics/value-bands";
|
||||
@@ -38,8 +39,8 @@ function clientBands(profile: {
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
const weightBands = profile.heightCm
|
||||
? buildWeightBandsFromHeight(profile.heightCm, {
|
||||
lowerBound: 30,
|
||||
? buildBandsFromTrafficRange(buildWeightRangeFromHeight(profile.heightCm), {
|
||||
lowerBound: METRIC_BOUNDS.WEIGHT.min,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
METRIC_BOUNDS,
|
||||
resolveEffectiveBpTargets,
|
||||
resolveThresholdOverride,
|
||||
type ThresholdMetric,
|
||||
@@ -136,7 +137,7 @@ export function buildDashboardBands(profile: {
|
||||
(profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null);
|
||||
const weightBands = weightRange
|
||||
? buildBandsFromTrafficRange(weightRange, {
|
||||
lowerBound: 30,
|
||||
lowerBound: METRIC_BOUNDS.WEIGHT.min,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* A vaccination and an allergy onset carry a date, not a time of day: a dose
|
||||
* is stored at UTC midnight on its date, an onset typed in the form at noon
|
||||
* UTC. The day view, the day index and the timeline used to read both as
|
||||
* instants in the person's zone, so west of UTC a dose sat on the previous
|
||||
* day, and the day view printed a clock time nobody entered ("01:00" for a
|
||||
* dose in Berlin).
|
||||
*
|
||||
* The rows below are filtered by the window each reader asks for, so the
|
||||
* assertions read which day a row lands on, not how the query is spelled.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const { tables } = vi.hoisted(() => ({
|
||||
tables: {} as Record<string, Row[]>,
|
||||
}));
|
||||
|
||||
function inWindow(value: unknown, window: unknown): boolean {
|
||||
if (window === undefined) return true;
|
||||
if (!(value instanceof Date)) return false;
|
||||
const { gte, lt } = window as { gte?: Date; lt?: Date };
|
||||
return (!gte || value >= gte) && (!lt || value < lt);
|
||||
}
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const model = (name: string) => ({
|
||||
findMany: async (args?: { where?: Record<string, unknown> }) => {
|
||||
const where = args?.where ?? {};
|
||||
return (tables[name] ?? []).filter(
|
||||
(row) =>
|
||||
inWindow(row.occurredAt, where.occurredAt) &&
|
||||
inWindow(row.onsetAt, where.onsetAt),
|
||||
);
|
||||
},
|
||||
});
|
||||
return {
|
||||
prisma: new Proxy({} as Record<string, unknown>, {
|
||||
get: (_target, name: string) => model(name),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/day/daily-stats", () => ({
|
||||
readLocalDaysWithReadings: async () => [],
|
||||
}));
|
||||
vi.mock("@/lib/day/notable", () => ({ loadNotableRange: async () => [] }));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: async () => null,
|
||||
}));
|
||||
|
||||
import { RECORD_SECTION_READERS, type DayFrame } from "@/lib/day/records";
|
||||
import { loadDayIndex } from "@/lib/day/day-index";
|
||||
import { loadSinceLastVisit } from "@/lib/day/visit-preparation";
|
||||
import type { DaySectionKey } from "@/lib/day/contract";
|
||||
import type { DayAccess } from "@/lib/day/sections";
|
||||
import { LANE_READERS, type TimelineAccess } from "@/lib/timeline/lanes";
|
||||
import { MODULE_KEYS } from "@/lib/modules/gate";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
const TZ = "America/New_York";
|
||||
|
||||
function frame(day: string): DayFrame {
|
||||
return {
|
||||
userId: "u1",
|
||||
day,
|
||||
tz: TZ,
|
||||
dayStart: startOfLocalDayKey(day, TZ),
|
||||
dayEnd: startOfLocalDayKey(shiftDateKey(day, 1), TZ),
|
||||
documentsReadable: false,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tables.vaccinationRecord = [
|
||||
{
|
||||
id: "dose-1",
|
||||
occurredAt: new Date("2026-11-11T00:00:00.000Z"),
|
||||
vaccineName: "Influenza",
|
||||
antigenSlug: null,
|
||||
doseNumber: null,
|
||||
noteEncrypted: null,
|
||||
customVaccine: null,
|
||||
},
|
||||
];
|
||||
tables.allergy = [
|
||||
{
|
||||
id: "allergy-1",
|
||||
substance: "Penicillin",
|
||||
onsetAt: new Date("2026-11-11T12:00:00.000Z"),
|
||||
severity: null,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
describe("the day view", () => {
|
||||
it("files a dose and an allergy onset under their own date west of UTC", async () => {
|
||||
const dose = await RECORD_SECTION_READERS.vaccinations!(
|
||||
frame("2026-11-11"),
|
||||
);
|
||||
const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11"));
|
||||
expect(dose.events.map((e) => e.id)).toEqual(["dose-1"]);
|
||||
expect(onset.events.map((e) => e.id)).toEqual(["allergy-1"]);
|
||||
|
||||
const before = await RECORD_SECTION_READERS.vaccinations!(
|
||||
frame("2026-11-10"),
|
||||
);
|
||||
expect(before.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives neither a clock time", async () => {
|
||||
const dose = await RECORD_SECTION_READERS.vaccinations!(
|
||||
frame("2026-11-11"),
|
||||
);
|
||||
const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11"));
|
||||
expect(dose.events[0].at).toBeNull();
|
||||
expect(onset.events[0].at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the day index", () => {
|
||||
it("marks the date itself, not the evening before", async () => {
|
||||
const access = {
|
||||
readable: new Set<DaySectionKey>(["vaccinations", "allergies"]),
|
||||
notShared: [],
|
||||
modules: {},
|
||||
moduleOff: [],
|
||||
} as unknown as DayAccess;
|
||||
const index = await loadDayIndex({
|
||||
recordId: "u1",
|
||||
from: "2026-11-01",
|
||||
to: "2026-11-30",
|
||||
access,
|
||||
tz: TZ,
|
||||
});
|
||||
expect(index.days).toEqual({
|
||||
"2026-11-11": ["allergies", "vaccinations"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the visit preparation", () => {
|
||||
it("lists a dose under its own date", async () => {
|
||||
const window = await loadSinceLastVisit({
|
||||
recordId: "u1",
|
||||
access: {
|
||||
readable: new Set<DaySectionKey>(["vaccinations"]),
|
||||
notShared: [],
|
||||
modules: {},
|
||||
moduleOff: [],
|
||||
} as unknown as DayAccess,
|
||||
tz: TZ,
|
||||
today: "2026-11-30",
|
||||
from: "2026-11-11",
|
||||
to: "2026-11-30",
|
||||
});
|
||||
expect(window.changes.map((c) => [c.date, c.kind])).toEqual([
|
||||
["2026-11-11", "vaccination"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the timeline", () => {
|
||||
it("starts a dose and an allergy on their own date", async () => {
|
||||
const laneFrame = {
|
||||
userId: "u1",
|
||||
tz: TZ,
|
||||
access: {
|
||||
modules: Object.fromEntries(
|
||||
MODULE_KEYS.map((key) => [key, true]),
|
||||
) as TimelineAccess["modules"],
|
||||
domainVisible: () => true,
|
||||
owner: true,
|
||||
},
|
||||
};
|
||||
const doses = await LANE_READERS.vaccinations(laneFrame);
|
||||
const allergies = await LANE_READERS.allergies(laneFrame);
|
||||
expect(doses.items[0].start).toBe("2026-11-11");
|
||||
expect(allergies.items[0].start).toBe("2026-11-11");
|
||||
});
|
||||
});
|
||||
+31
-16
@@ -33,7 +33,11 @@ import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import { effectiveMoodTz } from "@/lib/mood/date-key";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import {
|
||||
dateOnlyKey,
|
||||
dateOnlyWindow,
|
||||
dayKeyAsUtcMidnight,
|
||||
} from "@/lib/tz/date-only";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
@@ -102,6 +106,21 @@ const instantWindow = (frame: IndexFrame) => ({
|
||||
lt: frame.end,
|
||||
});
|
||||
|
||||
const calendarWindow = (frame: IndexFrame) =>
|
||||
dateOnlyWindow(frame.from, frame.to);
|
||||
|
||||
/** Mark the calendar date of every date-only value. */
|
||||
function markDates(
|
||||
frame: IndexFrame,
|
||||
section: DaySectionKey,
|
||||
dates: ReadonlyArray<Date | null>,
|
||||
): void {
|
||||
for (const date of dates) {
|
||||
if (date)
|
||||
mark(frame.marks, dateOnlyKey(date), section, frame.from, frame.to);
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-section readers of the index. */
|
||||
const INDEX_READERS: Partial<
|
||||
Record<DaySectionKey, (frame: IndexFrame) => Promise<void>>
|
||||
@@ -232,11 +251,11 @@ const INDEX_READERS: Partial<
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
onsetAt: instantWindow(frame),
|
||||
onsetAt: calendarWindow(frame),
|
||||
},
|
||||
select: { onsetAt: true },
|
||||
});
|
||||
markInstants(
|
||||
markDates(
|
||||
frame,
|
||||
"allergies",
|
||||
rows.map((r) => r.onsetAt),
|
||||
@@ -278,11 +297,11 @@ const INDEX_READERS: Partial<
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: instantWindow(frame),
|
||||
occurredAt: calendarWindow(frame),
|
||||
},
|
||||
select: { occurredAt: true },
|
||||
});
|
||||
markInstants(
|
||||
markDates(
|
||||
frame,
|
||||
"vaccinations",
|
||||
rows.map((r) => r.occurredAt),
|
||||
@@ -305,24 +324,20 @@ const INDEX_READERS: Partial<
|
||||
);
|
||||
},
|
||||
async documents(frame) {
|
||||
const start = dayKeyAsUtcMidnight(frame.from);
|
||||
const end = dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1));
|
||||
const days = calendarWindow(frame);
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ reportDate: { gte: start, lt: end } },
|
||||
{ reportDate: null, documentDate: { gte: start, lt: end } },
|
||||
],
|
||||
OR: [{ reportDate: days }, { reportDate: null, documentDate: days }],
|
||||
},
|
||||
select: { reportDate: true, documentDate: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const date = row.reportDate ?? row.documentDate;
|
||||
if (date)
|
||||
mark(frame.marks, dateOnlyKey(date), "documents", frame.from, frame.to);
|
||||
}
|
||||
markDates(
|
||||
frame,
|
||||
"documents",
|
||||
rows.map((r) => r.reportDate ?? r.documentDate),
|
||||
);
|
||||
},
|
||||
async mood(frame) {
|
||||
const rows = await prisma.moodEntry.findMany({
|
||||
|
||||
+13
-15
@@ -39,7 +39,11 @@ import { phaseForDate } from "@/lib/cycle/engine-adapter";
|
||||
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
|
||||
import { resolveCycleDay } from "@/lib/cycle/verdict";
|
||||
import { effectiveMoodTz } from "@/lib/mood/date-key";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import {
|
||||
dateOnlyKey,
|
||||
dateOnlyWindow,
|
||||
dayKeyAsUtcMidnight,
|
||||
} from "@/lib/tz/date-only";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
import {
|
||||
@@ -578,15 +582,15 @@ async function allergiesPart(frame: DayFrame): Promise<SectionPart> {
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
onsetAt: { gte: frame.dayStart, lt: frame.dayEnd },
|
||||
onsetAt: dateOnlyWindow(frame.day),
|
||||
},
|
||||
select: { id: true, substance: true, onsetAt: true, severity: true },
|
||||
select: { id: true, substance: true, severity: true },
|
||||
});
|
||||
return {
|
||||
running: [],
|
||||
events: rows.map((row) =>
|
||||
event({
|
||||
at: row.onsetAt ? row.onsetAt.toISOString() : null,
|
||||
at: null, // a date without a time of day
|
||||
kind: "allergyOnset",
|
||||
section: "allergies",
|
||||
id: row.id,
|
||||
@@ -648,11 +652,10 @@ async function vaccinationsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
|
||||
occurredAt: dateOnlyWindow(frame.day),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
vaccineName: true,
|
||||
antigenSlug: true,
|
||||
doseNumber: true,
|
||||
@@ -670,7 +673,7 @@ async function vaccinationsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
running: [],
|
||||
events: rows.map((row) =>
|
||||
event({
|
||||
at: row.occurredAt.toISOString(),
|
||||
at: null, // a date without a time of day
|
||||
kind: "vaccination",
|
||||
section: "vaccinations",
|
||||
id: row.id,
|
||||
@@ -761,18 +764,13 @@ async function checkupsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
|
||||
async function documentsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
// A document's date is a calendar date stored at noon UTC (older rows at
|
||||
// UTC midnight): both anchors fall inside this UTC calendar day, and the
|
||||
// date key decides. The stated report date wins over the filing date.
|
||||
const start = dayKeyAsUtcMidnight(frame.day);
|
||||
const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1));
|
||||
// UTC midnight). The stated report date wins over the filing date.
|
||||
const day = dateOnlyWindow(frame.day);
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where: {
|
||||
userId: frame.userId,
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ reportDate: { gte: start, lt: end } },
|
||||
{ reportDate: null, documentDate: { gte: start, lt: end } },
|
||||
],
|
||||
OR: [{ reportDate: day }, { reportDate: null, documentDate: day }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -23,7 +23,11 @@ import { openText } from "@/lib/day/records";
|
||||
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import {
|
||||
dateOnlyKey,
|
||||
dateOnlyWindow,
|
||||
dayKeyAsUtcMidnight,
|
||||
} from "@/lib/tz/date-only";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
@@ -240,7 +244,7 @@ async function vaccinationChanges(w: Window): Promise<DayChange[]> {
|
||||
where: {
|
||||
userId: w.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: w.start, lt: w.end },
|
||||
occurredAt: dateOnlyWindow(w.from, w.to), // a date, not an instant
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -251,7 +255,7 @@ async function vaccinationChanges(w: Window): Promise<DayChange[]> {
|
||||
},
|
||||
});
|
||||
return rows.map((row) =>
|
||||
change(userDayKey(row.occurredAt, w.tz), "vaccinations", {
|
||||
change(dateOnlyKey(row.occurredAt), "vaccinations", {
|
||||
kind: "vaccination",
|
||||
id: row.id,
|
||||
title:
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { isValidTimezone } from "./tz/format";
|
||||
import { formatDate } from "./date-format";
|
||||
import type { DoctorReportData } from "./doctor-report-data";
|
||||
import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-first-page-section";
|
||||
import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section";
|
||||
@@ -146,6 +147,8 @@ export function buildDoctorReportPdfDocument(
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
const fmtCalendarDate = (iso: string) =>
|
||||
formatDate(iso.slice(0, 10), dateFormat, locale);
|
||||
const footerTz =
|
||||
userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE;
|
||||
|
||||
@@ -164,7 +167,7 @@ export function buildDoctorReportPdfDocument(
|
||||
title: t("doctorReport.title"),
|
||||
subject: t("doctorReport.subtitle"),
|
||||
creator: "HealthLog",
|
||||
author: data.patient.fullName ?? data.patient.username ?? "HealthLog",
|
||||
author: data.patient.name ?? "HealthLog",
|
||||
});
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
@@ -187,6 +190,7 @@ export function buildDoctorReportPdfDocument(
|
||||
t,
|
||||
num,
|
||||
fmtDate,
|
||||
fmtCalendarDate,
|
||||
dateShort: formatters.dateShort,
|
||||
dateTime: formatters.dateTime,
|
||||
now,
|
||||
|
||||
@@ -97,7 +97,7 @@ function data(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
name: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
|
||||
@@ -32,8 +32,7 @@ function renderPatientLines(gender: string | null): string[] {
|
||||
doc,
|
||||
data: {
|
||||
patient: {
|
||||
username: "testuser",
|
||||
fullName: null,
|
||||
name: "testuser",
|
||||
dateOfBirth: null,
|
||||
gender,
|
||||
heightCm: null,
|
||||
|
||||
@@ -422,7 +422,7 @@ export function buildClinicalRecordsNotesSection(
|
||||
};
|
||||
|
||||
const immunizationRows = data.immunizations.map((row) => [
|
||||
fmtDate(row.occurredAt),
|
||||
context.fmtCalendarDate(row.occurredAt),
|
||||
row.antigenSlug
|
||||
? t(`vaccinations.catalog.${row.antigenSlug}`)
|
||||
: (row.vaccineName ?? "—"),
|
||||
|
||||
@@ -16,6 +16,7 @@ export function buildHeaderProfileSection(
|
||||
t,
|
||||
num,
|
||||
fmtDate,
|
||||
fmtCalendarDate,
|
||||
now,
|
||||
insuranceNumber,
|
||||
margin,
|
||||
@@ -62,16 +63,13 @@ export function buildHeaderProfileSection(
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const patientInfo: string[] = [];
|
||||
// v1.7.0 — prefer the legal full name on the cover; fall back to the
|
||||
// username when no full name is set (collapses exactly like the
|
||||
// practice name when neither is present).
|
||||
const patientName = data.patient.fullName ?? data.patient.username ?? null;
|
||||
if (patientName) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${patientName}`);
|
||||
// Collapses like the practice name when the record has no name to print.
|
||||
if (data.patient.name) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.name}`);
|
||||
}
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtCalendarDate(data.patient.dateOfBirth)}`,
|
||||
);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface DoctorReportPdfRenderContext {
|
||||
t: DoctorReportTranslator;
|
||||
num: DoctorReportNumberFormatter;
|
||||
fmtDate: (iso: string) => string;
|
||||
/**
|
||||
* A date-only value (a date of birth, a dose date): its calendar date, the
|
||||
* same in every zone. `fmtDate` reads an instant in the report zone, which
|
||||
* put a UTC-midnight date on the previous day west of UTC.
|
||||
*/
|
||||
fmtCalendarDate: (iso: string) => string;
|
||||
dateShort: (iso: string) => string;
|
||||
dateTime: (date: Date) => string;
|
||||
now: Date;
|
||||
|
||||
@@ -80,17 +80,19 @@ export interface DoctorReportData {
|
||||
*/
|
||||
period: { days: number; since: string; start: string; end: string };
|
||||
patient: {
|
||||
username: string | null;
|
||||
/**
|
||||
* The name the cover, the PDF metadata, the FHIR Patient and the share
|
||||
* page print, resolved once by `patientName()`: full name, display name,
|
||||
* login, never a managed record's generated handle. Null prints no line.
|
||||
*/
|
||||
name: string | null;
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
// v1.7.0 — optional patient-identity fields for the export cover +
|
||||
// FHIR Patient. `fullName` + `insurerName` are plaintext columns; the
|
||||
// KVNR is NOT carried here (it's encrypted at rest and decrypted by
|
||||
// the route, then handed to the builders separately). Optional in the
|
||||
// type so pre-v1.7.0 fixtures that omit them still typecheck — the
|
||||
// renderer treats absent and null identically.
|
||||
fullName?: string | null;
|
||||
// `insurerName` is a plaintext column; the KVNR is NOT carried here (it's
|
||||
// encrypted at rest and decrypted by the route, then handed to the
|
||||
// builders separately). Optional so fixtures that omit it still
|
||||
// typecheck; the renderer treats absent and null identically.
|
||||
insurerName?: string | null;
|
||||
// v1.8.6 — German insurer institution number (IKNR), plaintext. Carried
|
||||
// here for the FHIR `Coverage` payor Organization identifier. Optional
|
||||
|
||||
@@ -130,6 +130,8 @@ function seed() {
|
||||
rows.userUnique = [
|
||||
{
|
||||
username: "owner",
|
||||
displayName: null,
|
||||
managedProfileAt: null,
|
||||
dateOfBirth: new Date("1980-05-05T00:00:00.000Z"),
|
||||
gender: "OTHER",
|
||||
heightCm: 180,
|
||||
@@ -328,7 +330,7 @@ describe("per-leaf gating sweep", () => {
|
||||
0,
|
||||
);
|
||||
expect(Object.keys(allPayload.compliance).length).toBeGreaterThan(0);
|
||||
expect(allPayload.patient.username).toBe("owner");
|
||||
expect(allPayload.patient.name).toBe("Owner Name");
|
||||
expect(allPayload.patient.insurerName).toBe("Sample Insurer");
|
||||
expect(allPayload.bmi).not.toBeNull();
|
||||
expect(Object.keys(allPayload.glucoseStats).length).toBeGreaterThan(0);
|
||||
@@ -457,6 +459,47 @@ describe("the derived figures ride their own leaf", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the name the report prints", () => {
|
||||
const identity = selectionFromLeaves(["PATIENT_IDENTITY"]);
|
||||
const asUser = (fields: Record<string, unknown>) => {
|
||||
rows.userUnique = [{ ...(rows.userUnique[0] as object), ...fields }];
|
||||
};
|
||||
|
||||
it("names a managed record by its display name, never its generated login", async () => {
|
||||
asUser({
|
||||
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
|
||||
displayName: "Robin",
|
||||
fullName: null,
|
||||
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
const payload = await collectDoctorReportData("u1", RANGE, identity, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
expect(payload.patient.name).toBe("Robin");
|
||||
});
|
||||
|
||||
it("prints no name for a managed record that has none", async () => {
|
||||
asUser({
|
||||
username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
|
||||
displayName: null,
|
||||
fullName: null,
|
||||
managedProfileAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
const payload = await collectDoctorReportData("u1", RANGE, identity, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
expect(payload.patient.name).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the display name over the login on an own account", async () => {
|
||||
asUser({ username: "owner", displayName: "Jane Doe", fullName: null });
|
||||
const payload = await collectDoctorReportData("u1", RANGE, identity, {
|
||||
moduleMap: moduleMap(),
|
||||
});
|
||||
expect(payload.patient.name).toBe("Jane Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed exclusion", () => {
|
||||
it("excludes a measurement type the catalogue does not know", () => {
|
||||
const gate = resolveReportGate(
|
||||
@@ -571,7 +614,7 @@ describe("the empty selection serves nothing", () => {
|
||||
selectionFromLeaves([]),
|
||||
{ moduleMap: moduleMap() },
|
||||
);
|
||||
expect(payload.patient.username).toBeNull();
|
||||
expect(payload.patient.name).toBeNull();
|
||||
expect(payload.patient.insurerName).toBeNull();
|
||||
expect(payload.labResults).toBeNull();
|
||||
expect(payload.allergies).toBeNull();
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { loadScreenerUnvalidatedTranslations } from "./screener-translations";
|
||||
|
||||
@@ -178,7 +179,7 @@ export async function collectDoctorReportData(
|
||||
const userProfile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
...PATIENT_NAME_SELECT,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
@@ -186,7 +187,6 @@ export async function collectDoctorReportData(
|
||||
thresholdsJson: true,
|
||||
timezone: true,
|
||||
sourcePriorityJson: true,
|
||||
fullName: true,
|
||||
insurerName: true,
|
||||
insurerIkNumber: true,
|
||||
},
|
||||
@@ -720,14 +720,13 @@ export async function collectDoctorReportData(
|
||||
end: end.toISOString(),
|
||||
},
|
||||
patient: {
|
||||
username: identityOn ? (userProfile?.username ?? null) : null,
|
||||
name: identityOn && userProfile ? patientName(userProfile) : null,
|
||||
dateOfBirth:
|
||||
identityOn && userProfile?.dateOfBirth
|
||||
? userProfile.dateOfBirth.toISOString()
|
||||
: null,
|
||||
gender: identityOn ? (userProfile?.gender ?? null) : null,
|
||||
heightCm: identityOn ? (userProfile?.heightCm ?? null) : null,
|
||||
fullName: identityOn ? (userProfile?.fullName ?? null) : null,
|
||||
insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null,
|
||||
insurerIkNumber: insuranceOn
|
||||
? (userProfile?.insurerIkNumber ?? null)
|
||||
|
||||
@@ -69,11 +69,10 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
},
|
||||
practiceName: null,
|
||||
@@ -181,11 +180,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
},
|
||||
@@ -215,11 +213,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
},
|
||||
@@ -238,11 +235,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
// no insurerIkNumber
|
||||
},
|
||||
@@ -262,11 +258,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
},
|
||||
@@ -284,11 +279,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
fullName: "Sample Patient",
|
||||
// neither insurerName nor insurerIkNumber
|
||||
},
|
||||
}),
|
||||
@@ -303,11 +297,10 @@ describe("buildFhirDocumentBundle", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
fullName: "Sample Patient",
|
||||
// neither insurerName nor insurerIkNumber — just a bare KVNR.
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -36,11 +36,10 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "Sample Patient",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
fullName: "Sample Patient",
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
},
|
||||
@@ -286,7 +285,7 @@ describe("document Bundle — identity and resolvability", () => {
|
||||
fullBundle(),
|
||||
fullBundle({
|
||||
patient: {
|
||||
username: "u",
|
||||
name: "u",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
@@ -375,7 +374,7 @@ describe("Patient.gender", () => {
|
||||
for (const [stored, expected] of cases) {
|
||||
const bundle = fullBundle({
|
||||
patient: {
|
||||
username: "u",
|
||||
name: "u",
|
||||
dateOfBirth: null,
|
||||
gender: stored,
|
||||
heightCm: null,
|
||||
|
||||
@@ -15,7 +15,7 @@ function baseData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "sample-user",
|
||||
dateOfBirth: "1990-06-15T00:00:00.000Z",
|
||||
gender: "FEMALE",
|
||||
heightCm: 168,
|
||||
|
||||
@@ -22,7 +22,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
|
||||
@@ -18,7 +18,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
|
||||
@@ -17,7 +17,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
name: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
|
||||
@@ -22,7 +22,7 @@ function codings(overrides: Partial<DoctorReportData>): FhirCoding[] {
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: { username: "sample-user", dateOfBirth: null, gender: null },
|
||||
patient: { name: "sample-user", dateOfBirth: null, gender: null },
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {},
|
||||
@@ -118,7 +118,7 @@ describe("medication codings", () => {
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: { username: "sample-user", dateOfBirth: null, gender: null },
|
||||
patient: { name: "sample-user", dateOfBirth: null, gender: null },
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {},
|
||||
|
||||
@@ -315,7 +315,7 @@ export function buildFhirDocumentBundle(
|
||||
// v1.9.0 — when the aggregator capped the administration set, disclose
|
||||
// it in the narrative so the export is honest: it carries the
|
||||
// most-recent N of M acted intakes, the oldest having been omitted.
|
||||
const displayName = data.patient.fullName ?? data.patient.username ?? null;
|
||||
const displayName = data.patient.name;
|
||||
const truncation = data.medicationAdministrationsTruncation;
|
||||
const narrativeText = [
|
||||
`Health record for ${escapeXml(displayName ?? "patient")}.`,
|
||||
|
||||
@@ -49,8 +49,7 @@ export function patientResource(
|
||||
resourceType: "Patient",
|
||||
id: PATIENT_RESOURCE_ID,
|
||||
};
|
||||
const displayName = data.patient.fullName ?? data.patient.username ?? null;
|
||||
if (displayName) patient.name = [{ text: displayName }];
|
||||
if (data.patient.name) patient.name = [{ text: data.patient.name }];
|
||||
const gender = administrativeGender(data.patient.gender);
|
||||
if (gender) patient.gender = gender;
|
||||
if (data.patient.dateOfBirth) {
|
||||
|
||||
@@ -27,6 +27,7 @@ function row(
|
||||
sourceHigh?: number | null;
|
||||
sourceText?: string | null;
|
||||
biomarkerId?: string | null;
|
||||
sourceValue?: number | null;
|
||||
} = {},
|
||||
): LabChangeRow {
|
||||
return {
|
||||
@@ -34,6 +35,7 @@ function row(
|
||||
biomarkerId: opts.biomarkerId ?? null,
|
||||
unit: opts.unit ?? "mg/dL",
|
||||
value,
|
||||
sourceValue: opts.sourceValue ?? null,
|
||||
referenceLow: opts.low ?? null,
|
||||
referenceHigh: opts.high ?? null,
|
||||
sourceReferenceLow: opts.sourceLow ?? null,
|
||||
@@ -179,6 +181,53 @@ describe("summariseLabChanges", () => {
|
||||
expect(out.present).toBe(false);
|
||||
});
|
||||
|
||||
it("calls a small change in a small value a change", () => {
|
||||
// Two decimals for every analyte rounded this delta to 0: "no change"
|
||||
// for a TSH that more than doubled.
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
row("TSH", 0.009, "2026-06-01", { unit: "mIU/L" }),
|
||||
row("TSH", 0.004, "2026-03-01", { unit: "mIU/L" }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({ delta: 0.005, direction: "up" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a converted value at the precision its report printed", () => {
|
||||
// 104.83 nmol/L converted to ng/mL is 41.999198717948715.
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
row("Vitamin D", 41.999198717948715, "2026-06-01", {
|
||||
unit: "ng/mL",
|
||||
sourceValue: 104.83,
|
||||
}),
|
||||
row("Vitamin D", 30, "2026-03-01", { unit: "ng/mL" }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
latest: 41.999,
|
||||
previous: 30,
|
||||
delta: 11.999,
|
||||
direction: "up",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("still calls an unchanged reading flat", () => {
|
||||
const out = summariseLabChanges(
|
||||
[row("LDL", 130, "2026-06-01"), row("LDL", 130, "2026-03-01")],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({ delta: 0, direction: "flat" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no delta across two units", () => {
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
|
||||
@@ -27,6 +27,12 @@ describe("classifyBmi (WHO)", () => {
|
||||
it("obese ≥ 30 (red)", () => {
|
||||
expect(classifyBmi(31)).toEqual({ category: "obese", band: "red" });
|
||||
});
|
||||
it("puts each WHO cut-off in the upper category, every obesity grade obese", () => {
|
||||
expect(classifyBmi(18.5).category).toBe("normal");
|
||||
expect(classifyBmi(25).category).toBe("overweight");
|
||||
expect(classifyBmi(30).category).toBe("obese");
|
||||
expect(classifyBmi(45)).toEqual({ category: "obese", band: "red" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBmi", () => {
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
classifyBMI,
|
||||
type BmiClassification,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
@@ -63,15 +67,32 @@ export interface BmiValue {
|
||||
series: number[];
|
||||
}
|
||||
|
||||
/** WHO category + band for a BMI value. Pure. */
|
||||
const CATEGORY_OF: Readonly<Record<string, BmiCategory>> = {
|
||||
Underweight: "underweight",
|
||||
Normal: "normal",
|
||||
Overweight: "overweight",
|
||||
};
|
||||
|
||||
const BAND_OF: Readonly<Record<BmiClassification["severity"], BmiBand>> = {
|
||||
normal: "green",
|
||||
warning: "yellow",
|
||||
danger: "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* WHO category + band for a BMI value, in this payload's vocabulary. The
|
||||
* cut-offs are `classifyBMI`'s, so the derived card and the targets panel
|
||||
* cannot disagree about where a reading falls. Pure.
|
||||
*/
|
||||
export function classifyBmi(bmi: number): {
|
||||
category: BmiCategory;
|
||||
band: BmiBand;
|
||||
} {
|
||||
if (bmi < 18.5) return { category: "underweight", band: "yellow" };
|
||||
if (bmi < 25) return { category: "normal", band: "green" };
|
||||
if (bmi < 30) return { category: "overweight", band: "yellow" };
|
||||
return { category: "obese", band: "red" };
|
||||
const { category, severity } = classifyBMI(bmi);
|
||||
return {
|
||||
category: CATEGORY_OF[category] ?? "obese",
|
||||
band: BAND_OF[severity],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing
|
||||
* only — a delta is not a diagnosis.
|
||||
*/
|
||||
import { recordedPrecision } from "@/lib/format/recorded-number";
|
||||
import {
|
||||
classifyAgainstEffectiveRange,
|
||||
resolveEffectiveReferenceRange,
|
||||
@@ -33,6 +34,11 @@ export interface LabChangeRow extends SourceReferenceRange {
|
||||
biomarkerId?: string | null;
|
||||
unit: string;
|
||||
value: number;
|
||||
/**
|
||||
* The figure the source report printed when `value` was converted from
|
||||
* another unit; it sets the precision the converted value is read at.
|
||||
*/
|
||||
sourceValue?: number | null;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
takenAt: Date;
|
||||
@@ -41,9 +47,13 @@ export interface LabChangeRow extends SourceReferenceRange {
|
||||
export interface LabChange {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
/** The readings at the precision they were recorded at (`recordedPrecision`). */
|
||||
latest: number;
|
||||
previous: number;
|
||||
/** Signed latest − previous, rounded to 2dp. */
|
||||
/**
|
||||
* Signed latest − previous, at the finer of the two readings' precisions,
|
||||
* so TSH 0.004 → 0.009 is +0.005 and not a rounded-away 0.
|
||||
*/
|
||||
delta: number;
|
||||
direction: "up" | "down" | "flat";
|
||||
status: ReferenceRangeStatus;
|
||||
@@ -108,8 +118,6 @@ function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean {
|
||||
return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Summarise the change between the two most-recent numeric lab panels. `rows`
|
||||
* should already be the user's live (non-deleted) numeric results; this helper
|
||||
@@ -161,14 +169,30 @@ export function summariseLabChanges(
|
||||
const previousRow = previous.get(key);
|
||||
// A delta across two units is no change, it is a conversion; skip it.
|
||||
if (!previousRow || !sameUnit(latestRow, previousRow)) continue;
|
||||
const delta = round2(latestRow.value - previousRow.value);
|
||||
// Each reading at the digits it was recorded with; the delta at the
|
||||
// finer of the two, so it says what the printed figures say.
|
||||
const latestReading = recordedPrecision(
|
||||
latestRow.value,
|
||||
latestRow.sourceValue,
|
||||
);
|
||||
const previousReading = recordedPrecision(
|
||||
previousRow.value,
|
||||
previousRow.sourceValue,
|
||||
);
|
||||
const digits = Math.max(
|
||||
latestReading.fractionDigits,
|
||||
previousReading.fractionDigits,
|
||||
);
|
||||
const delta = Number(
|
||||
(latestReading.value - previousReading.value).toFixed(digits),
|
||||
);
|
||||
const direction: LabChange["direction"] =
|
||||
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
||||
changes.push({
|
||||
analyte: latestRow.analyte,
|
||||
unit: latestRow.unit,
|
||||
latest: latestRow.value,
|
||||
previous: previousRow.value,
|
||||
latest: latestReading.value,
|
||||
previous: previousReading.value,
|
||||
delta,
|
||||
direction,
|
||||
status: classifyAgainstEffectiveRange(
|
||||
|
||||
@@ -74,7 +74,7 @@ const REPORT_FIXTURE = {
|
||||
start: "2026-03-29T00:00:00.000Z",
|
||||
end: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
patient: { username: "tester" },
|
||||
patient: { name: "tester" },
|
||||
stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } },
|
||||
compliance: {},
|
||||
medications: [],
|
||||
|
||||
@@ -29,7 +29,7 @@ function payload(overrides: Partial<DoctorReportData> = {}): DoctorReportData {
|
||||
end: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: null,
|
||||
name: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
|
||||
@@ -73,8 +73,7 @@ function reportFixture(
|
||||
end: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "tester",
|
||||
fullName: "A Person",
|
||||
name: "A Person",
|
||||
dateOfBirth: "1990-01-01",
|
||||
gender: "female",
|
||||
heightCm: 170,
|
||||
@@ -213,8 +212,7 @@ describe("doctor_visit_summary — grounding + structure", () => {
|
||||
.text,
|
||||
);
|
||||
const patient = data.patient as Record<string, unknown>;
|
||||
expect(patient).not.toHaveProperty("fullName");
|
||||
expect(patient).not.toHaveProperty("username");
|
||||
expect(patient).not.toHaveProperty("name");
|
||||
expect(patient.dateOfBirth).toBe("1990-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,8 +224,7 @@ describe("healthlog://report/doctor-visit", () => {
|
||||
end: "2026-06-27T00:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "tester",
|
||||
fullName: "A Person",
|
||||
name: "A Person",
|
||||
dateOfBirth: "1990-01-01",
|
||||
gender: "female",
|
||||
heightCm: 170,
|
||||
@@ -267,8 +266,7 @@ describe("healthlog://report/doctor-visit", () => {
|
||||
expect(result).toHaveProperty("illnessEpisodes");
|
||||
// Patient name never leaks.
|
||||
const patient = result.patient as Record<string, unknown>;
|
||||
expect(patient).not.toHaveProperty("fullName");
|
||||
expect(patient).not.toHaveProperty("username");
|
||||
expect(patient).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("omits absent sections honestly (no zero-fill)", async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
|
||||
expect(units.toCanonical(value)).toBe(value);
|
||||
expect(units.toDisplayDelta(value)).toBe(value);
|
||||
}
|
||||
expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 30, max: 300 });
|
||||
expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 1, max: 300 });
|
||||
expect(units.step).toBe(0.1);
|
||||
});
|
||||
|
||||
@@ -115,9 +115,9 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => {
|
||||
|
||||
it("rounds guardrails inward so a value typed at the limit still saves", () => {
|
||||
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
|
||||
// A naive round of the 30 kg floor gives 66.1 lb, which inverts to
|
||||
// 29.98 kg and the server rejects it. The ceiling keeps it legal.
|
||||
expect(bounds.min).toBe(66.2);
|
||||
// The 1 kg floor is 2.2046 lb: the floor rounds up to 2.3, never down,
|
||||
// so a value typed at the limit converts back inside the window.
|
||||
expect(bounds.min).toBe(2.3);
|
||||
expect(bounds.max).toBe(661.3);
|
||||
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
|
||||
METRIC_BOUNDS.WEIGHT.min,
|
||||
@@ -144,7 +144,7 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => {
|
||||
|
||||
it("still rejects a genuinely out-of-range value", () => {
|
||||
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
|
||||
for (const typed of [12, 66.1, 661.4, 900]) {
|
||||
for (const typed of [0.5, 2.2, 661.4, 900]) {
|
||||
expect(typed < bounds.min || typed > bounds.max).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* 68.04 kg, not 68.03885550000001.
|
||||
* 3. Rules 1 + 2 compose back to the typed number: 150 lb → 68.04 kg
|
||||
* → 150 lb. Save-then-reopen is stable.
|
||||
* 4. Guardrails round INWARD (min up, max down). A naive round of the
|
||||
* 4. Guardrails round INWARD (min up, max down). A naive round of a
|
||||
* 30 kg floor gives 66.1 lb, which inverts to 29.98 kg and the
|
||||
* server rejects it. Ceiling it to 66.2 lb inverts to 30.03 kg and
|
||||
* passes.
|
||||
@@ -205,8 +205,8 @@ export function resolveTargetUnitAdapter(
|
||||
applyDisplayTransformDelta(canonicalDelta, transform),
|
||||
// A guardrail is not a rendered value — it is the window a typed
|
||||
// number is checked against, and it has to round INWARD (rule 4).
|
||||
// That needs the UNROUNDED edge: the shared helper would round 30 kg
|
||||
// to 66.1 lb first and the ceiling below would then be a no-op on an
|
||||
// That needs the UNROUNDED edge: the shared helper would round a 30 kg
|
||||
// floor to 66.1 lb first and the ceiling below would then be a no-op on an
|
||||
// edge that already inverts to 29.98 kg.
|
||||
bounds: (canonical) => ({
|
||||
min: ceilTo(
|
||||
|
||||
@@ -204,7 +204,7 @@ async function allergiesLane(frame: LaneFrame): Promise<LaneRead> {
|
||||
item({
|
||||
id: r.id,
|
||||
kind: "allergy",
|
||||
start: userDayKey(r.onsetAt, frame.tz),
|
||||
start: dateOnlyKey(r.onsetAt),
|
||||
end: null,
|
||||
open: r.status === "ACTIVE",
|
||||
label: r.substance,
|
||||
@@ -388,7 +388,7 @@ async function vaccinationsLane(frame: LaneFrame): Promise<LaneRead> {
|
||||
item({
|
||||
id: r.id,
|
||||
kind: "vaccination",
|
||||
start: userDayKey(r.occurredAt, frame.tz),
|
||||
start: dateOnlyKey(r.occurredAt),
|
||||
end: null,
|
||||
open: false,
|
||||
label: r.vaccineName ?? r.customVaccine?.name ?? r.antigenSlug ?? "",
|
||||
|
||||
@@ -271,9 +271,7 @@ async function allergiesLane(frame: Frame) {
|
||||
const detail = { key: "allergies", params: { count: rows.length } };
|
||||
const dated = rows.filter((r) => r.onsetAt !== null);
|
||||
if (dated.length > 0) {
|
||||
const since = dated
|
||||
.map((r) => userDayKey(r.onsetAt as Date, frame.tz))
|
||||
.sort()[0];
|
||||
const since = dated.map((r) => dateOnlyKey(r.onsetAt as Date)).sort()[0];
|
||||
return lane("carries", rows.length, since, { detail });
|
||||
}
|
||||
// Undated allergies are standing items, not a shortcoming: no gap.
|
||||
@@ -351,7 +349,7 @@ async function vaccinationsLane(frame: Frame) {
|
||||
gaps: [gap("vaccinationsEmpty", 0, "/vaccinations")],
|
||||
});
|
||||
}
|
||||
return lane("carries", count, userDayKey(first.occurredAt, frame.tz), {
|
||||
return lane("carries", count, dateOnlyKey(first.occurredAt), {
|
||||
detail: { key: "vaccinations", params: { count } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isNoonUtcAnchor,
|
||||
dateOnlyAtNoonUtc,
|
||||
dateOnlyKey,
|
||||
dateOnlyWindow,
|
||||
dayKeyAsUtcMidnight,
|
||||
isCalendarDateKey,
|
||||
isDateOnlyKey,
|
||||
@@ -60,3 +61,15 @@ describe("isNoonUtcAnchor", () => {
|
||||
expect(isNoonUtcAnchor(new Date("2025-10-04T20:30:00.000Z"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateOnlyWindow", () => {
|
||||
it("spans UTC midnight to the next UTC midnight, both days included", () => {
|
||||
expect(dateOnlyWindow("2026-11-11")).toEqual({
|
||||
gte: new Date("2026-11-11T00:00:00.000Z"),
|
||||
lt: new Date("2026-11-12T00:00:00.000Z"),
|
||||
});
|
||||
expect(dateOnlyWindow("2026-12-31", "2027-01-01").lt).toEqual(
|
||||
new Date("2027-01-02T00:00:00.000Z"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -98,3 +98,19 @@ export function dateOnlyKey(value: Date): string {
|
||||
export function dayKeyAsUtcMidnight(key: string): Date {
|
||||
return new Date(`${key}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window over a date-only column that holds the calendar days `fromKey`
|
||||
* to `toKey`, both included: UTC midnight to the next UTC midnight, so both
|
||||
* anchors (midnight and noon UTC) fall on their own date. A date-only value
|
||||
* read through a person's local-day window sat on the evening before west of
|
||||
* UTC.
|
||||
*/
|
||||
export function dateOnlyWindow(
|
||||
fromKey: string,
|
||||
toKey: string = fromKey,
|
||||
): { gte: Date; lt: Date } {
|
||||
const lt = dayKeyAsUtcMidnight(toKey);
|
||||
lt.setUTCDate(lt.getUTCDate() + 1);
|
||||
return { gte: dayKeyAsUtcMidnight(fromKey), lt };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The first booster is due one interval after the dose. The dose date is a
|
||||
* calendar date stored at UTC midnight; adding the interval to that instant
|
||||
* put the anchor on the evening before for anyone west of UTC, so the
|
||||
* reminder came due a day early there.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
|
||||
function fakeTx() {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const tx = {
|
||||
vaccinationRecord: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "dose-1",
|
||||
antigenSlug: "tetanus",
|
||||
customVaccineId: null,
|
||||
occurredAt: new Date("2026-11-11T00:00:00.000Z"),
|
||||
})),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
measurementReminder: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
created.push(data);
|
||||
return { id: "reminder-1" };
|
||||
}),
|
||||
},
|
||||
};
|
||||
return { tx: tx as unknown as Prisma.TransactionClient, created };
|
||||
}
|
||||
|
||||
describe("mintOrReanchorBooster", () => {
|
||||
it("counts the interval from the dose's own date west of UTC", async () => {
|
||||
const { tx, created } = fakeTx();
|
||||
// 12 months → round(12 × 30.4375) = 365 days after 11 November 2026.
|
||||
await mintOrReanchorBooster(
|
||||
tx,
|
||||
"u1",
|
||||
{ vaccinationId: "dose-1", intervalMonths: 12, label: "Tetanus" },
|
||||
"America/New_York",
|
||||
);
|
||||
expect(created[0].anchorDate).toEqual(
|
||||
startOfLocalDayKey("2027-11-11", "America/New_York"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import {
|
||||
componentsForDose,
|
||||
loadCustomVaccineLookup,
|
||||
@@ -111,10 +114,13 @@ export async function mintOrReanchorBooster(
|
||||
// dose date plus the interval — a never-satisfied rolling reminder is due at
|
||||
// its anchor, and the dose date itself is not when the next shot is due. Once
|
||||
// the person logs that next dose, `satisfyReminder` re-anchors on the satisfy
|
||||
// instant and this anchor no longer matters.
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const anchorDate = new Date(
|
||||
record.occurredAt.getTime() + intervalDays * DAY_MS,
|
||||
// instant and this anchor no longer matters. The dose date is a calendar
|
||||
// date at UTC midnight, so the interval counts from its date part to the
|
||||
// start of that day in the person's zone, where adding days to the stored
|
||||
// instant fell on the evening before west of UTC.
|
||||
const anchorDate = startOfLocalDayKey(
|
||||
shiftDateKey(dateOnlyKey(record.occurredAt), intervalDays),
|
||||
timezone,
|
||||
);
|
||||
const nextDueAt = computeReminderNextDueAt(
|
||||
{
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("doctor-report — per-section toggles", () => {
|
||||
expect(data.labResults).toBeNull();
|
||||
expect(data.allergies).toBeNull();
|
||||
expect(data.familyHistory).toBeNull();
|
||||
expect(data.patient.username).toBeNull();
|
||||
expect(data.patient.name).toBeNull();
|
||||
expect(data.patient.insurerName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user