mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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. */
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
Reference in new issue
Block a user