diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index c8df7de9c..8e3ae7574 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -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, }), }; diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 62f62b42f..1ab04100f 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e3107e593..0804205bb 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index f6d32f37a..8d0a4a5fd 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -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"]; diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index bd50f93f0..6dba245a7 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.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 = { 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", diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 3436b15b6..18099e912 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -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, diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index c7865e32f..6a3468a1e 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -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({ diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts index b52bbf05c..228857a33 100644 --- a/src/app/c/[token]/__tests__/report-download.test.ts +++ b/src/app/c/[token]/__tests__/report-download.test.ts @@ -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, diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx new file mode 100644 index 000000000..b0f342bc4 --- /dev/null +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -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) => { + 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(); + 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); + }); +}); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 735c4d7ea..cc6db7390 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -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: (props: Record) => { + 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(); + 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 }, + }; + } + }); +}); diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts new file mode 100644 index 000000000..0a4da57a5 --- /dev/null +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -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(); + }); +}); diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c5ac87fff..75101098f 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -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()` + ``. @@ -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("/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 = diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts new file mode 100644 index 000000000..0c645f8c6 --- /dev/null +++ b/src/app/insights/weight/weight-band.ts @@ -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; +} diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index 4fa3094b9..75980ba2f 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -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: {}, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index da8a4a3f3..21666b4eb 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -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("", () => { expect(html).not.toContain(' { + 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"); + }); }); /** diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2be46e88b..43be0b2a6 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -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)} /> diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index dd6a25138..8b66da8b4 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -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}` diff --git a/src/components/clinician/identity-sections.tsx b/src/components/clinician/identity-sections.tsx index 9dfef4aef..01debc6e0 100644 --- a/src/components/clinician/identity-sections.tsx +++ b/src/components/clinician/identity-sections.tsx @@ -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) { diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx index cedad4149..63602a290 100644 --- a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -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, diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx index f727d776e..89c58407a 100644 --- a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -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, diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 4b872315e..0ff861570 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -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 }) { diff --git a/src/components/cycle/__tests__/use-cycle-window.test.tsx b/src/components/cycle/__tests__/use-cycle-window.test.tsx new file mode 100644 index 000000000..225a5ea4e --- /dev/null +++ b/src/components/cycle/__tests__/use-cycle-window.test.tsx @@ -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()), + // 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 {`${from}|${today}|${to}`}; +} + +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()).toContain( + "2026-04-03|2026-07-02|2026-12-29", + ); + }); +}); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index ee1b2e0c1..8d620d7f4 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -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(); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 586fe4b14..b748a1a8b 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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(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(); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index cbe9afe92..0707607ca 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -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); } diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index c3cbd6827..c4de521b8 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -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(null); const [doseIds, setDoseIds] = useState(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({ {link.occurredAt ? ( - {format.date(link.occurredAt)} + {calendarDate(link.occurredAt)} ) : null} diff --git a/src/components/insights/__tests__/labs-changes-card.test.tsx b/src/components/insights/__tests__/labs-changes-card.test.tsx new file mode 100644 index 000000000..ac78f10e4 --- /dev/null +++ b/src/components/insights/__tests__/labs-changes-card.test.tsx @@ -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("", () => { + it("prints values and deltas in the reader's notation", () => { + const html = renderToStaticMarkup( + + + , + ); + 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"); + }); +}); diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index 78ad13403..db226e1d0 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -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 — `` unit tests. * diff --git a/src/components/insights/__tests__/target-adjust-button.test.tsx b/src/components/insights/__tests__/target-adjust-button.test.tsx index 758eb1cce..8a3a3387d 100644 --- a/src/components/insights/__tests__/target-adjust-button.test.tsx +++ b/src/components/insights/__tests__/target-adjust-button.test.tsx @@ -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("", () => { 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()).toBe(""); + } finally { + capabilities.inSharedRecord = false; + } + }); + it("renders the gear once an editable target is registered", () => { useTargetAdjustMock.mockReturnValue({ canAdjust: true, diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx index 30a8e146a..f912b27c4 100644 --- a/src/components/insights/labs-changes-card.tsx +++ b/src/components/insights/labs-changes-card.tsx @@ -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 (

- {withLabUnit(String(c.latest), c.unit)} + {withLabUnit(labNumber(c.latest), c.unit)} {labelKey ? ` · ${t(labelKey)}` : ""}

diff --git a/src/components/insights/target-adjust-button.tsx b/src/components/insights/target-adjust-button.tsx index bcb8df982..c8bff7a46 100644 --- a/src/components/insights/target-adjust-button.tsx +++ b/src/components/insights/target-adjust-button.tsx @@ -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 `` on click. * * Sized to match `` 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 diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 28062cbb8..cae4fb514 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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 }) { {avatarUrl && } - {getInitials(name)} + {nameInitials(name)}
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { @@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 4d499d1a2..53c220210 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -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() { )} - {getInitials(accountName(user))} + {nameInitials(accountName(user))} {accountName(user)} diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index 85ff071f4..682aa3b1a 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -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 ( ({ + 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( + + + , + ); + expect(html).toContain(">RE<"); + expect(html).not.toContain(">R<"); + }); +}); diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index bff3eb9e5..6297cdb61 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -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() {

- {avatarUrl && } + {avatarUrl && } - {getAvatarInitials(user.username)} + {nameInitials(accountName(user))}
diff --git a/src/lib/__tests__/account-name.test.ts b/src/lib/__tests__/account-name.test.ts index 3d080ebb1..99e0ff3cc 100644 --- a/src/lib/__tests__/account-name.test.ts +++ b/src/lib/__tests__/account-name.test.ts @@ -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"); + }); +}); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 081dd0a57..528da87fc 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -27,7 +27,7 @@ function makeData(overrides?: Partial): 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 diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts index 2340acaa6..b02c371ab 100644 --- a/src/lib/account-name.ts +++ b/src/lib/account-name.ts @@ -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-`), 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(""); +} diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 863f00cd0..2650d4f93 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -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)", + ]); + }); +}); diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 38f02113b..0e00c6a87 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -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" }, diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 4f7cf68db..2d34febd8 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -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. diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index a60b3eb36..1883e175d 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -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; diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index 1bfe1649d..5f03dacab 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -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; diff --git a/src/lib/day/__tests__/date-only-sections.test.ts b/src/lib/day/__tests__/date-only-sections.test.ts new file mode 100644 index 000000000..1c36c036e --- /dev/null +++ b/src/lib/day/__tests__/date-only-sections.test.ts @@ -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; + +const { tables } = vi.hoisted(() => ({ + tables: {} as Record, +})); + +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 }) => { + 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, { + 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(["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(["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"); + }); +}); diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index 70b7f7504..7773c2379 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -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, +): 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 Promise> @@ -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({ diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index ec2073c8e..a41d23bac 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -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 { 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 { 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 { 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 { async function documentsPart(frame: DayFrame): Promise { // 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, diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index f3ab26c74..7c83d7a0f 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -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 { 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 { }, }); return rows.map((row) => - change(userDayKey(row.occurredAt, w.tz), "vaccinations", { + change(dateOnlyKey(row.occurredAt), "vaccinations", { kind: "vaccination", id: row.id, title: diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index ad1d366a7..825f9d2b2 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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, diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts index 85587fa4d..9ccd51995 100644 --- a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -97,7 +97,7 @@ function data(overrides: Partial = {}): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts index ce126d2b5..db5846db6 100644 --- a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts @@ -32,8 +32,7 @@ function renderPatientLines(gender: string | null): string[] { doc, data: { patient: { - username: "testuser", - fullName: null, + name: "testuser", dateOfBirth: null, gender, heightCm: null, diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 59b2c265e..32a8d02ab 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -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 ?? "—"), diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts index 27975a81b..93328d8a6 100644 --- a/src/lib/doctor-report-pdf/header-profile-section.ts +++ b/src/lib/doctor-report-pdf/header-profile-section.ts @@ -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) { diff --git a/src/lib/doctor-report-pdf/render-context.ts b/src/lib/doctor-report-pdf/render-context.ts index f5f1c2d85..213972cc7 100644 --- a/src/lib/doctor-report-pdf/render-context.ts +++ b/src/lib/doctor-report-pdf/render-context.ts @@ -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; diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index b82c4fffe..2b7a73697 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -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 diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index da880ef5d..6650e83a6 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -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) => { + 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(); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index e40039f56..f55d2f66c 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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) diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 473758eae..0bdc2e550 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -69,11 +69,10 @@ function makeData(overrides?: Partial): 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. }, }), diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 693da6fdf..6e924c033 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -36,11 +36,10 @@ function makeData(overrides?: Partial): 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, diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts index 330a83d63..0bed3d981 100644 --- a/src/lib/fhir/__tests__/cycle-observations.test.ts +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -15,7 +15,7 @@ function baseData(overrides?: Partial): 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, diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts index 8562be554..c773239fc 100644 --- a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -22,7 +22,7 @@ function makeData(overrides?: Partial): 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, diff --git a/src/lib/fhir/__tests__/illness-snomed.test.ts b/src/lib/fhir/__tests__/illness-snomed.test.ts index ce4b162c7..3377b24c8 100644 --- a/src/lib/fhir/__tests__/illness-snomed.test.ts +++ b/src/lib/fhir/__tests__/illness-snomed.test.ts @@ -18,7 +18,7 @@ function makeData(overrides?: Partial): 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, diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 3cb005613..02ff11d73 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -17,7 +17,7 @@ function makeData(overrides?: Partial): 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, diff --git a/src/lib/fhir/__tests__/terminology-displays.test.ts b/src/lib/fhir/__tests__/terminology-displays.test.ts index 70ede4b70..a4c766e85 100644 --- a/src/lib/fhir/__tests__/terminology-displays.test.ts +++ b/src/lib/fhir/__tests__/terminology-displays.test.ts @@ -22,7 +22,7 @@ function codings(overrides: Partial): 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: {}, diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index db9225600..0f2ddf71b 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -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")}.`, diff --git a/src/lib/fhir/resources/patient.ts b/src/lib/fhir/resources/patient.ts index 47f14bc93..dfca26ac4 100644 --- a/src/lib/fhir/resources/patient.ts +++ b/src/lib/fhir/resources/patient.ts @@ -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) { diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index f1f003b72..d5bf7744a 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -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( [ diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts index bc329d599..3e304f452 100644 --- a/src/lib/insights/derived/__tests__/bmi.test.ts +++ b/src/lib/insights/derived/__tests__/bmi.test.ts @@ -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", () => { diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts index 0766f01e8..51ce69881 100644 --- a/src/lib/insights/derived/bmi.ts +++ b/src/lib/insights/derived/bmi.ts @@ -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> = { + Underweight: "underweight", + Normal: "normal", + Overweight: "overweight", +}; + +const BAND_OF: Readonly> = { + 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], + }; } /** diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index 455e02c6f..f3f732515 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -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( diff --git a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts index c85a7f8a7..db607e77d 100644 --- a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts +++ b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts @@ -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: [], diff --git a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts index 01e81b3b4..5b82a4b5c 100644 --- a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts +++ b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts @@ -29,7 +29,7 @@ function payload(overrides: Partial = {}): DoctorReportData { end: "2026-04-01T00:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts index d321aa8e9..ffd37d7ad 100644 --- a/src/lib/mcp/__tests__/prompts.test.ts +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -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; - expect(patient).not.toHaveProperty("fullName"); - expect(patient).not.toHaveProperty("username"); + expect(patient).not.toHaveProperty("name"); expect(patient.dateOfBirth).toBe("1990-01-01"); }); }); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index c402eaf6f..945023533 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -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; - expect(patient).not.toHaveProperty("fullName"); - expect(patient).not.toHaveProperty("username"); + expect(patient).not.toHaveProperty("name"); }); it("omits absent sections honestly (no zero-fill)", async () => { diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index 3d2fa1522..590ce8063 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -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); } }); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index 0afb78a7d..e1d3fd6c6 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -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( diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index eeb0f9053..3d440a628 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -204,7 +204,7 @@ async function allergiesLane(frame: LaneFrame): Promise { 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 { 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 ?? "", diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index a65e313d4..db122b9a5 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -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 } }, }); } diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 541fddbce..3aca0df8c 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -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"), + ); + }); +}); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 5018f530a..4a94de2cd 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -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 }; +} diff --git a/src/lib/vaccinations/__tests__/booster-mint.test.ts b/src/lib/vaccinations/__tests__/booster-mint.test.ts new file mode 100644 index 000000000..48c2f4364 --- /dev/null +++ b/src/lib/vaccinations/__tests__/booster-mint.test.ts @@ -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> = []; + 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 }) => { + 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"), + ); + }); +}); diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts index 871fc68f1..8faeeb47a 100644 --- a/src/lib/vaccinations/booster-mint.ts +++ b/src/lib/vaccinations/booster-mint.ts @@ -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( { diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 8b857cf4d..759dd957a 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -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(); }); });