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) ───────────────────────────