mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(labs): surface lab results in the doctor report and FHIR export
Add a Lab results section to the report aggregator (latest reading per analyte over the window), a new labs section toggle (ON by default, the same stance as BP and weight since the user recorded these to share with a clinician), a PDF table with a quiet non-colour in/out-of-range marker, and one laboratory Observation per result in the FHIR bundle carrying the lab's reported referenceRange. Everything stays server-authoritative.
This commit is contained in:
1 parent
271cc0dcfa
commit
fbecb6b5c8
12 files changed
+394
No files matched your search
@@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: true,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -71,6 +72,7 @@ function setEmptyDataMocks() {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "testuser",
|
||||
dateOfBirth: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
// First findUnique = aggregator profile select; second = route KVNR select.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "sample",
|
||||
|
||||
@@ -87,6 +87,11 @@ interface SectionAvailability {
|
||||
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
|
||||
// typechecks. Optional — the availability endpoint may not provide it.
|
||||
cycle?: boolean;
|
||||
// v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog;
|
||||
// the flagship export panel surfaces the toggle. Optional — the
|
||||
// availability endpoint may not provide it. Present so the
|
||||
// `keyof DoctorReportPrefs` indexing typechecks.
|
||||
labs?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReportSubmitPayload {
|
||||
@@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
|
||||
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
|
||||
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
|
||||
cycle: "doctorReport.sections.cycle",
|
||||
// v1.17.1 — labs section; the flagship export panel drives the toggle,
|
||||
// present here so the `keyof DoctorReportPrefs` record stays exhaustive.
|
||||
labs: "doctorReport.sections.labs",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -230,6 +230,31 @@ export interface DoctorReportData {
|
||||
* still typecheck.
|
||||
*/
|
||||
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
|
||||
/**
|
||||
* v1.17.1 — structured lab results recorded over the report window.
|
||||
* Populated when the `labs` section toggle is ON (default) and the user
|
||||
* recorded at least one result in the period. Statistics + the single
|
||||
* latest reading per analyte; free-text notes never reach this surface.
|
||||
* Each entry carries the lab's reference bounds so the PDF can print an
|
||||
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
|
||||
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
|
||||
* Optional so pre-v1.17.1 fixtures still typecheck.
|
||||
*/
|
||||
labResults?: Array<{
|
||||
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
|
||||
panel: string | null;
|
||||
/** Biomarker name as the user recorded it. */
|
||||
analyte: string;
|
||||
/** Latest recorded value for this analyte over the window. */
|
||||
value: number;
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
/** ISO timestamp of the latest reading. */
|
||||
takenAt: string;
|
||||
/** Count of readings for this analyte in the window (≥ 1). */
|
||||
count: number;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/** The three persisted score types surfaced in the wellness summary. */
|
||||
@@ -1057,6 +1082,50 @@ export async function collectDoctorReportData(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.17.1 — structured lab results over the window. ON by default; the
|
||||
// user recorded these specifically to share with a clinician, so the
|
||||
// privacy stance matches BP / weight, not mood / cycle. We reduce to the
|
||||
// latest reading per analyte (with a count) so the report is a concise
|
||||
// panel, not a raw dump; notes are never read here.
|
||||
let labResults: DoctorReportData["labResults"] = null;
|
||||
if (sections.labs) {
|
||||
const labRows = await prisma.labResult.findMany({
|
||||
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: {
|
||||
panel: true,
|
||||
analyte: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
takenAt: true,
|
||||
},
|
||||
});
|
||||
// Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
|
||||
// together; rows are ascending so the last seen wins as the latest.
|
||||
const byAnalyte = new Map<
|
||||
string,
|
||||
NonNullable<DoctorReportData["labResults"]>[number]
|
||||
>();
|
||||
for (const r of labRows) {
|
||||
const key = r.analyte.toLowerCase();
|
||||
const prev = byAnalyte.get(key);
|
||||
byAnalyte.set(key, {
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt.toISOString(),
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
const collapsed = Array.from(byAnalyte.values());
|
||||
labResults = collapsed.length > 0 ? collapsed : null;
|
||||
}
|
||||
|
||||
return {
|
||||
period: {
|
||||
days,
|
||||
@@ -1109,6 +1178,7 @@ export async function collectDoctorReportData(
|
||||
glp1,
|
||||
wellnessScores,
|
||||
cycle,
|
||||
labResults,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,6 +1319,89 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.17.1 — structured lab-results section. Populated when the `labs`
|
||||
// toggle is ON (default) and the user recorded at least one result in the
|
||||
// window. One row per analyte (latest reading), with the lab's reference
|
||||
// range and a NEUTRAL in/out-of-range marker — informative, never an
|
||||
// alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
|
||||
// the clinical PDF stays calm.
|
||||
if (data.labResults && data.labResults.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.labsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const rangeText = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): string => {
|
||||
if (low !== null && high !== null) return `${num(low)}–${num(high)}`;
|
||||
if (high !== null) return `≤ ${num(high)}`;
|
||||
if (low !== null) return `≥ ${num(low)}`;
|
||||
return "—";
|
||||
};
|
||||
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
|
||||
// the table is not a field of warning marks; out-of-range reads as a
|
||||
// direction arrow the clinician can scan, with no alarm tint.
|
||||
const statusGlyph = (
|
||||
value: number,
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): string => {
|
||||
if (low === null && high === null) return "";
|
||||
if (low !== null && value < low) return "↓";
|
||||
if (high !== null && value > high) return "↑";
|
||||
return "—";
|
||||
};
|
||||
|
||||
const labRows = data.labResults.map((lr) => [
|
||||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||||
`${num(lr.value)} ${lr.unit}`.trim(),
|
||||
rangeText(lr.referenceLow, lr.referenceHigh),
|
||||
statusGlyph(lr.value, lr.referenceLow, lr.referenceHigh),
|
||||
fmtDate(lr.takenAt),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.labsColAnalyte"),
|
||||
t("doctorReport.labsColValue"),
|
||||
t("doctorReport.labsColReference"),
|
||||
t("doctorReport.labsColStatus"),
|
||||
t("doctorReport.labsColDate"),
|
||||
],
|
||||
],
|
||||
body: labRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
|
||||
// rendered ONLY when the user explicitly opted in. Clearly labelled and
|
||||
// flagged as not clinically validated so a physician never mistakes it
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
import { buildFhirDocumentBundle } from "../build-bundle";
|
||||
import type { FhirObservation } from "../types";
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
glp1: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
|
||||
return bundle.entry
|
||||
.map((e) => e.resource)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation");
|
||||
}
|
||||
|
||||
describe("lab-result FHIR Observations", () => {
|
||||
const labResults: NonNullable<DoctorReportData["labResults"]> = [
|
||||
{
|
||||
panel: "Lipid panel",
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: 116,
|
||||
takenAt: "2026-04-20T08:00:00.000Z",
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
panel: null,
|
||||
analyte: "HbA1c",
|
||||
value: 5.4,
|
||||
unit: "%",
|
||||
referenceLow: 4,
|
||||
referenceHigh: 5.6,
|
||||
takenAt: "2026-04-21T08:00:00.000Z",
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
it("emits one laboratory Observation per lab result", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const labObs = observationsOf(bundle).filter((o) =>
|
||||
o.category?.some((c) =>
|
||||
c.coding?.some((code) => code.code === "laboratory"),
|
||||
),
|
||||
);
|
||||
// Two lab observations (no glucose in this fixture).
|
||||
expect(labObs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("maps the value, unit, and effective date onto the Observation", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
|
||||
expect(ldl).toBeDefined();
|
||||
expect(ldl?.valueQuantity?.value).toBe(110);
|
||||
expect(ldl?.valueQuantity?.unit).toBe("mg/dL");
|
||||
expect(ldl?.effectiveDateTime).toBe("2026-04-20T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("emits a referenceRange with only the high bound when low is null", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
|
||||
expect(ldl?.referenceRange).toHaveLength(1);
|
||||
expect(ldl?.referenceRange?.[0].high?.value).toBe(116);
|
||||
expect(ldl?.referenceRange?.[0].low).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits a referenceRange with both bounds", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const a1c = observationsOf(bundle).find((o) => o.code.text === "HbA1c");
|
||||
expect(a1c?.referenceRange?.[0].low?.value).toBe(4);
|
||||
expect(a1c?.referenceRange?.[0].high?.value).toBe(5.6);
|
||||
});
|
||||
|
||||
it("omits referenceRange entirely when no bounds are present", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
labResults: [
|
||||
{
|
||||
panel: null,
|
||||
analyte: "CRP",
|
||||
value: 2.1,
|
||||
unit: "mg/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
takenAt: "2026-04-22T08:00:00.000Z",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const crp = observationsOf(bundle).find((o) => o.code.text === "CRP");
|
||||
expect(crp).toBeDefined();
|
||||
expect(crp?.referenceRange).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits no lab Observations when labResults is null", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults: null }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const labObs = observationsOf(bundle).filter((o) =>
|
||||
o.category?.some((c) =>
|
||||
c.coding?.some((code) => code.code === "laboratory"),
|
||||
),
|
||||
);
|
||||
expect(labObs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,47 @@ export function observationsFromReportData(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lab-result Observations (v1.17.1) ---------------------------------
|
||||
// One `laboratory` Observation per recorded analyte. `analyte` + `unit`
|
||||
// are user-recorded free text (no closed LOINC enum), so the code is a
|
||||
// text-only `CodeableConcept` — honest about what we have rather than
|
||||
// asserting a LOINC mapping we can't validate. The lab's reference bounds
|
||||
// map onto R4 `referenceRange`. UCUM `code` is omitted (the free-text
|
||||
// unit can't be asserted as a UCUM symbol); `unit` carries the display.
|
||||
for (const lab of data.labResults ?? []) {
|
||||
obsSeq += 1;
|
||||
const referenceRange =
|
||||
lab.referenceLow !== null || lab.referenceHigh !== null
|
||||
? [
|
||||
{
|
||||
...(lab.referenceLow !== null
|
||||
? { low: { value: lab.referenceLow, unit: lab.unit } }
|
||||
: {}),
|
||||
...(lab.referenceHigh !== null
|
||||
? { high: { value: lab.referenceHigh, unit: lab.unit } }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
push({
|
||||
resourceType: "Observation",
|
||||
id: `obs-${obsSeq}`,
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code: {
|
||||
text: lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectiveDateTime: lab.takenAt,
|
||||
valueQuantity: {
|
||||
value: lab.value,
|
||||
unit: lab.unit,
|
||||
system: UCUM_SYSTEM,
|
||||
},
|
||||
...(referenceRange ? { referenceRange } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Medication-adherence Observations (one per medication) ------------
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
|
||||
@@ -85,6 +85,18 @@ export interface FhirObservation {
|
||||
component?: FhirObservationComponent[];
|
||||
/** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */
|
||||
note?: FhirAnnotation[];
|
||||
/**
|
||||
* v1.17.1 — R4 `Observation.referenceRange`. Carries the lab's reported
|
||||
* normal bounds for a structured lab result; either bound may be absent
|
||||
* (a lab may report only an upper or lower limit).
|
||||
*/
|
||||
referenceRange?: FhirObservationReferenceRange[];
|
||||
}
|
||||
|
||||
/** R4 `Observation.referenceRange` element. */
|
||||
export interface FhirObservationReferenceRange {
|
||||
low?: FhirQuantity;
|
||||
high?: FhirQuantity;
|
||||
}
|
||||
|
||||
export interface FhirDosage {
|
||||
|
||||
@@ -57,6 +57,11 @@ describe("parseDoctorReportPrefs", () => {
|
||||
expect(out.mood).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults labs to ON (recorded to share with a clinician)", () => {
|
||||
const out = parseDoctorReportPrefs(null);
|
||||
expect(out.labs).toBe(true);
|
||||
});
|
||||
|
||||
it("fills missing keys from defaults", () => {
|
||||
const out = parseDoctorReportPrefs({ bp: false });
|
||||
expect(out).toEqual({
|
||||
@@ -86,6 +91,7 @@ describe("resolveDoctorReportPrefs", () => {
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
};
|
||||
const out = resolveDoctorReportPrefs(current, { mood: false });
|
||||
expect(out).toEqual({ ...current, mood: false });
|
||||
|
||||
@@ -32,6 +32,7 @@ export const doctorReportPrefsSchema = z
|
||||
compliance: z.boolean(),
|
||||
sleep: z.boolean(),
|
||||
cycle: z.boolean(),
|
||||
labs: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface DoctorReportPrefs {
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
cycle: boolean;
|
||||
labs: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
|
||||
// Cycle data is opt-in: a user sharing a BP report with a cardiologist
|
||||
// should not auto-leak reproductive data. Same privacy stance as mood.
|
||||
cycle: false,
|
||||
// Lab results the user recorded for exactly this purpose — sharing
|
||||
// bloodwork with a clinician. ON by default, like BP / weight.
|
||||
labs: true,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,9 @@ export const exportSectionsSchema = z
|
||||
bmi: z.boolean(),
|
||||
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
|
||||
cycle: z.boolean(),
|
||||
// Structured lab results — ON by default (recorded to share with a
|
||||
// clinician, same stance as BP / weight).
|
||||
labs: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -135,5 +138,6 @@ export function toDoctorReportPrefs(
|
||||
sleep: a.sleep ?? fallback.sleep,
|
||||
// Cycle is opt-in: only true when explicitly requested (privacy).
|
||||
cycle: s.cycle === true,
|
||||
labs: s.labs ?? fallback.labs,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user