fix(doctor-report): keep untagged glucose readings in the panel

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
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 23:03:32 +02:00
1 parent 971c922a14
commit 126383b352
6 files changed
+49 -25

No files matched your search

@@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record<string, string> = {
"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 {
@@ -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<GlucoseBlockContext>): 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);
@@ -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<keyof typeof GLUCOSE_LABEL_KEYS> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
...GLUCOSE_CONTEXT_BUCKETS,
];
/** A single charted reading. */
+19 -14
View File
@@ -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] = {
+6 -2
View File
@@ -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,
+4
View File
@@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
},
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) ───────────────────────────