From 126383b35280c2e1f4c4ee6949897e682a4aa241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:32 +0200 Subject: [PATCH] fix(doctor-report): keep untagged glucose readings in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both report paths dropped them: the raw path iterated the named contexts, and the dense path skipped any row without a context outright, so a report built for a meter that records no meal time carried an empty glucose panel while the readings sat in the database. The FHIR export dropped them for the third time, at the LOINC lookup — its own comment already said an unspecified reading codes as 2339-0, but the table had no entry to hit. The insights context breakdown names the bucket, and the Coach's glucose block now resolves through the shared helper rather than its own copy. Refs #943 --- .../glucose/glucose-advanced-disclosure.tsx | 2 ++ .../ai/coach/snapshot-blocks/glucose-block.ts | 17 +++++++--- .../measurements-charts-section.ts | 10 +++--- src/lib/doctor-report/collect.ts | 33 +++++++++++-------- src/lib/doctor-report/measurement-series.ts | 8 +++-- src/lib/fhir/loinc-map.ts | 4 +++ 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 1e3ab6165..7fbe90613 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record = { "insights.bloodGlucose.clinical.advanced.byContext.postprandial", RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random", BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime", + // #943 — readings whose source recorded no meal-time context. + UNSPECIFIED: "insights.bloodGlucose.clinical.advanced.byContext.unspecified", }; export interface GlucoseAdvancedDisclosureProps { diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index 7f0e855e1..cd8ac806f 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -12,7 +12,11 @@ * read plus the shared accumulators, so the emitted shape and ordering * are unchanged. */ -import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { + convertGlucose, + glucoseContextBucket, + type GlucoseUnit, +} from "@/lib/glucose"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -80,15 +84,18 @@ export function buildGlucoseBlock(ctx: Readonly): void { meta: { cluster: "glucose", source: "glucose" }, }); } else { - // Group by context (NULL → "unspecified"), then per-day mean. + // Group by context (NULL → "unspecified"), then per-day mean. The bucket + // resolver is the shared one (#943) — this block named the untagged + // bucket first, and the rest of the tree now resolves through the same + // helper rather than repeating the NULL arm at each surface. const byContext = new Map< string, Array<{ measuredAt: Date; value: number }> >(); for (const r of glucoseRows) { - const ctxKey = r.glucoseContext - ? String(r.glucoseContext).toLowerCase() - : "unspecified"; + const ctxKey = glucoseContextBucket( + typeof r.glucoseContext === "string" ? r.glucoseContext : null, + ).toLowerCase(); const list = byContext.get(ctxKey) ?? []; list.push({ measuredAt: r.measuredAt, value: r.value }); byContext.set(ctxKey, list); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index c13cfddca..fef72fd68 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -1,4 +1,5 @@ import type { jsPDF } from "jspdf"; +import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; import { convertGlucose, resolveGlucoseUnit } from "../glucose"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -20,6 +21,8 @@ const GLUCOSE_LABEL_KEYS = { POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", RANDOM: "doctorReport.typeGlucoseRandom", BEDTIME: "doctorReport.typeGlucoseBedtime", + // #943 — readings the source never tagged with a meal time. + UNSPECIFIED: "doctorReport.typeGlucoseUnspecified", } as const; const DEFAULT_GLUCOSE_RANGES = { @@ -27,13 +30,12 @@ const DEFAULT_GLUCOSE_RANGES = { POSTPRANDIAL: { min: 70, max: 140 }, RANDOM: { min: 70, max: 140 }, BEDTIME: { min: 90, max: 150 }, + // Same band as RANDOM: an untagged reading is a spot reading. + UNSPECIFIED: { min: 70, max: 140 }, } as const; const GLUCOSE_CONTEXTS: Array = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", + ...GLUCOSE_CONTEXT_BUCKETS, ]; /** A single charted reading. */ diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 42ce9e3dc..e932224c8 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -20,12 +20,14 @@ import { getEffectiveRange, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; -import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose"; +import { + GLUCOSE_CONTEXT_BUCKETS, + groupByGlucoseContext, + resolveGlucoseUnit, + thresholdMetricForContext, +} from "@/lib/glucose"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; -import type { - GlucoseContext, - MeasurementType, -} from "@/generated/prisma/client"; +import type { MeasurementType } from "@/generated/prisma/client"; import { reconstructSleepNights, type SleepStageRow, @@ -70,13 +72,6 @@ import { loadLabResults, } from "./clinical-records"; -const GLUCOSE_CONTEXTS: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", -]; - const DENSE_REPORT_RAW_WINDOW_DAYS = 90; /** No medication row is read unless at least one medication leaf is admitted. */ @@ -413,9 +408,19 @@ export async function collectDoctorReportData( gender: userProfile?.gender ?? null, }; if (glucosePanelOn) { - for (const ctx of GLUCOSE_CONTEXTS) { + // #943 — the untagged bucket is a bucket. A report built from a meter + // that records no meal-time context used to carry an empty glucose panel. + const groupedGlucose = groupByGlucoseContext( + glucoseRows, + (m) => m.glucoseContext, + ); + const glucoseBuckets = denseGlucose + ? GLUCOSE_CONTEXT_BUCKETS + : groupedGlucose.map(([bucket]) => bucket); + for (const ctx of glucoseBuckets) { if (!denseGlucose) { - const rows = glucoseRows.filter((m) => m.glucoseContext === ctx); + const rows = + groupedGlucose.find(([bucket]) => bucket === ctx)?.[1] ?? []; if (rows.length === 0) continue; const values = rows.map((r) => r.value); glucoseStats[ctx] = { diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts index 5fdb12474..f6d6e1d07 100644 --- a/src/lib/doctor-report/measurement-series.ts +++ b/src/lib/doctor-report/measurement-series.ts @@ -9,6 +9,7 @@ * Split out of the aggregator with the selection rework. */ import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers"; +import { glucoseContextBucket } from "@/lib/glucose"; import type { DoctorReportStats } from "@/lib/doctor-report-types"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { DenseMeasurementBucket } from "./dense-buckets"; @@ -117,8 +118,11 @@ export function summariseDenseBuckets( } for (const row of canonical) { - if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue; - accumulate(glucoseStatsAcc, row.glucoseContext, { + if (row.type !== "BLOOD_GLUCOSE") continue; + // #943 — a reading with no meal-time context is filed under the shared + // untagged bucket. Skipping it here dropped the whole panel for an + // account whose meter never records one. + accumulate(glucoseStatsAcc, glucoseContextBucket(row.glucoseContext), { count: row.count, sum: row.sumValue, min: row.minValue, diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 814ca0558..4920d04fa 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record = }, RANDOM: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, + // #943 — the untagged bucket. Already named in the table above; without + // the entry the exported bundle silently dropped every reading whose + // source recorded no meal time. + UNSPECIFIED: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, }; /* ── Clinical glucose-panel LOINCs (v1.18.0) ───────────────────────────