Merge branch 'fix/v1473-record' into release/v1.47.3

This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:18:59 +01:00
commit f519df4433
84 files changed
+1368 -322

No files matched your search

+4 -3
View File
@@ -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);
});
});
+22 -1
View File
@@ -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();
});
});
+18 -39
View File
@@ -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 =
+43
View File
@@ -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");
});
});
/**
+12 -2
View File
@@ -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();
+2 -11
View File
@@ -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();
+20 -23
View File
@@ -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
+4 -12
View File
@@ -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">
+2 -10
View File
@@ -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>
+10 -6
View File
@@ -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">
+39 -1
View File
@@ -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
+36
View File
@@ -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)",
]);
});
});
+5 -1
View File
@@ -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" },
+2 -13
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7 -3
View File
@@ -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:
+5 -1
View File
@@ -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;
+10 -8
View File
@@ -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();
+3 -4
View File
@@ -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)
+7 -14
View File
@@ -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.
},
}),
+3 -4
View File
@@ -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: {},
+1 -1
View File
@@ -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")}.`,
+1 -2
View File
@@ -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", () => {
+26 -5
View File
@@ -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],
};
}
/**
+30 -6
View File
@@ -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,
+2 -4
View File
@@ -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");
});
});
+2 -4
View File
@@ -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);
}
});
+3 -3
View File
@@ -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(
+2 -2
View File
@@ -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 ?? "",
+2 -4
View File
@@ -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 } },
});
}
+13
View File
@@ -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"),
);
});
});
+16
View File
@@ -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"),
);
});
});
+10 -4
View File
@@ -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();
});
});