Serve glucose clinical panel from one engine to all surfaces

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:03:34 +02:00
1 parent 3dfc3fe515
commit 3be7c3788b
11 files changed
+342 -1

No files matched your search

+136
View File
@@ -13688,6 +13688,8 @@ components:
propertyNames:
type: string
additionalProperties: {}
glucoseClinical:
$ref: "#/components/schemas/GlucoseClinicalMetrics"
required:
- bpInTargetPct
- bpInTargetPct7d
@@ -13696,6 +13698,7 @@ components:
- bpInTargetPctPriorMonth
- bpInTargetPctPriorYear
- glucoseByContext
- glucoseClinical
additionalProperties: false
- type: "null"
medsToday:
@@ -13817,6 +13820,139 @@ components:
`briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per
metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive
in-process from data already fetched, adding no DB round-trip."
GlucoseClinicalMetrics:
type: object
properties:
stillLearning:
type: boolean
stillLearningReason:
anyOf:
- type: string
- type: "null"
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
actualSpanDays:
type: number
readingCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
meanMgdl:
anyOf:
- type: number
- type: "null"
distribution:
anyOf:
- type: object
properties:
tir:
type: number
tbrLevel1:
type: number
tbrLevel2:
type: number
tarLevel1:
type: number
tarLevel2:
type: number
minutesEquivalent:
type: object
properties:
tir:
type: number
tbrLevel1:
type: number
tbrLevel2:
type: number
tarLevel1:
type: number
tarLevel2:
type: number
required:
- tir
- tbrLevel1
- tbrLevel2
- tarLevel1
- tarLevel2
additionalProperties: false
required:
- tir
- tbrLevel1
- tbrLevel2
- tarLevel1
- tarLevel2
- minutesEquivalent
additionalProperties: false
- type: "null"
gmi:
anyOf:
- type: number
- type: "null"
estimatedA1c:
anyOf:
- type: number
- type: "null"
variability:
anyOf:
- type: object
properties:
sd:
type: number
cv:
type: number
unstable:
type: boolean
required:
- sd
- cv
- unstable
additionalProperties: false
- type: "null"
advanced:
anyOf:
- type: object
properties:
jIndex:
anyOf:
- type: number
- type: "null"
lbgi:
type: number
hbgi:
type: number
required:
- jIndex
- lbgi
- hbgi
additionalProperties: false
- type: "null"
isSpotEstimate:
type: boolean
const: true
required:
- stillLearning
- stillLearningReason
- windowDays
- actualSpanDays
- readingCount
- meanMgdl
- distribution
- gmi
- estimatedA1c
- variability
- advanced
- isSpotEstimate
additionalProperties: false
description: Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING
ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning`
gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino
2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal
2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017
≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev
hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading
window.
InsightsComprehensiveResponseEnvelope:
type: object
properties:
+18
View File
@@ -20,6 +20,10 @@ import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fas
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
export const dynamic = "force-dynamic";
@@ -365,6 +369,19 @@ async function buildAnalyticsResponse(user: AuthedUser) {
}
}
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
// eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed
// ONCE here over the declared 30-day window (matching `glucoseSince`) so the
// insights panel, the coach snapshot, and the doctor report all render the
// same numbers instead of each re-deriving. The learning gate keeps a thin
// spot-data window from being asserted as a clinical AGP. Always present in
// the payload (even with zero readings) so the client renders the calm
// "still learning" state from a populated object rather than a missing key.
const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: 30, now: new Date() },
);
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
// v1.4.37 W2 — probe-gated helper. The 28-day scan window (down
// from 30 to keep the cold critical path tight while still
@@ -420,6 +437,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
correlations,
healthScore,
sleepStages,
@@ -12,6 +12,7 @@ import { ClinicianView } from "../clinician-view";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
function makeReport(
overrides: Partial<DoctorReportData> = {},
@@ -36,6 +37,9 @@ function makeReport(
},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-01-31T00:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: 24.5,
compliance: {},
@@ -10,6 +10,7 @@ import {
} from "../doctor-report-pdf-core";
import { measurementTypeEnum } from "../validations/measurement";
import type { DoctorReportData } from "../doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { getServerTranslator } from "../i18n/server-translator";
async function extractText(bytes: Uint8Array): Promise<string> {
@@ -67,6 +68,7 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
+59 -1
View File
@@ -24,6 +24,7 @@ import {
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -1344,9 +1345,66 @@ async function buildCoachSnapshotImpl(
);
contexts[ctx] = { recent, weekly };
}
// v1.17.0 — clinical panel summary from the ONE literature-locked
// engine the insights panel + doctor report also consume, so the coach
// never quotes a TIR / GMI / CV% figure the panel doesn't show. Computed
// on canonical mg/dL over the resolved coach window; gated by
// `stillLearning` so a thin spot-data window is offered as a calm
// "still learning" note rather than asserted as a clinical AGP. The
// headline mean is converted ONCE to the user's display unit for parity
// with the per-context lines above; the unit-agnostic fractions / indices
// travel as-is.
const clinicalRaw = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: windowDays, now },
);
const clinical = clinicalRaw.stillLearning
? {
stillLearning: true as const,
reason: clinicalRaw.stillLearningReason,
readingCount: clinicalRaw.readingCount,
spanDays: Math.round(clinicalRaw.actualSpanDays),
}
: {
stillLearning: false as const,
windowDays: clinicalRaw.windowDays,
spanDays: Math.round(clinicalRaw.actualSpanDays),
readingCount: clinicalRaw.readingCount,
meanInRange:
clinicalRaw.meanMgdl !== null
? Math.round(
convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) *
(glucoseUnit === "mmol/L" ? 10 : 1),
) / (glucoseUnit === "mmol/L" ? 10 : 1)
: null,
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
timeAbovePercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tarLevel1 * 100)
: null,
gmi:
clinicalRaw.gmi !== null
? Math.round(clinicalRaw.gmi * 10) / 10
: null,
estimatedA1c:
clinicalRaw.estimatedA1c !== null
? Math.round(clinicalRaw.estimatedA1c * 10) / 10
: null,
cvPercent: clinicalRaw.variability
? Math.round(clinicalRaw.variability.cv)
: null,
unstable: clinicalRaw.variability?.unstable ?? null,
// Spot-reading estimate, never a CGM AGP — pinned so the model
// never narrates these as continuous-trace clinical figures.
isSpotEstimate: true as const,
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts };
snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical };
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
+24
View File
@@ -46,6 +46,10 @@ import {
type DataSummary,
} from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -206,6 +210,17 @@ export interface DashboardSnapshotExtras {
bpInTargetCount90: number | null;
bpInTargetSpanDays90: number | null;
glucoseByContext: Record<string, DataSummary>;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the trailing
* 30-day window: the learning gate (+ reason), window/span/count, mean,
* Battelino TIR distribution, GMI, estimated A1C, SD/CV%/instability, and the
* advanced J-index + LBGI/HBGI tier. Computed ONCE by
* `computeGlucoseClinicalMetrics` so the iOS client renders the same numbers
* the web panel, the coach, and the doctor report do — iOS never re-derives.
* Always present (even with zero readings) so the native client paints the
* calm "still learning" state from a populated object.
*/
glucoseClinical: GlucoseClinicalMetrics;
}
/**
@@ -565,6 +580,14 @@ async function buildExtras(
}
}
// v1.17.0 — clinical panel from the SAME 30-day glucose rows already read
// above (no extra DB hop), computed by the one literature-locked engine so
// the iOS cold-launch seed matches the web panel / coach / doctor report.
const glucoseClinical = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: 30, now },
);
return {
extras: {
bpInTargetPct,
@@ -576,6 +599,7 @@ async function buildExtras(
bpInTargetCount90,
bpInTargetSpanDays90,
glucoseByContext,
glucoseClinical,
},
healthScore,
};
+27
View File
@@ -18,6 +18,10 @@ import {
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementSource,
@@ -96,6 +100,16 @@ export interface DoctorReportData {
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
/**
* v1.17.0 — server-authoritative glucose clinical panel over the report
* period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI
* tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export
* carries the SAME numbers the insights panel and the coach show. Values are
* canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried
* as a spot-reading estimate (`isSpotEstimate: true`), gated by
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
@@ -869,6 +883,18 @@ export async function collectDoctorReportData(
}
}
// v1.17.0 — clinical panel over the WHOLE report period (all contexts
// pooled), computed by the one literature-locked engine the insights panel
// and the coach also consume. `windowDays` is the report's own period so the
// TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report
// tabulates; `now: end` anchors the window to the report's upper bound. The
// learning gate keeps a thin period from asserting a clinical AGP off spot
// data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`.
const glucoseClinical = computeGlucoseClinicalMetrics(
glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })),
{ windowDays: days, now: end },
);
// `practiceName` is sanitised to a single-line string with a hard length
// cap. The PDF cover prints it verbatim — never let unbounded input land in
// a layout-sensitive header.
@@ -1056,6 +1082,7 @@ export async function collectDoctorReportData(
stats: filteredStats,
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: filteredBmi,
compliance: filteredCompliance,
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { buildFhirDocumentBundle } from "../build-bundle";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
FhirObservation,
FhirMedicationStatement,
@@ -77,6 +78,9 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-05-03T12:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import { cycleObservationsFromReportData } from "@/lib/fhir/resources";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
@@ -28,6 +29,9 @@ function baseData(overrides?: Partial<DoctorReportData>): DoctorReportData {
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], {
now: new Date("2026-05-03T12:00:00.000Z"),
}),
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
+55
View File
@@ -541,6 +541,60 @@ const insightsPregenerateResponse = z
// modules and the iOS client does not consume this web-only route.
const dataSummaryRecord = z.record(z.string(), z.unknown());
// v1.17.0 — server-authoritative glucose clinical panel. Mirrors
// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the
// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced
// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin
// spot-data window is never asserted as a clinical AGP. The iOS client
// renders these numbers verbatim and never re-derives them.
const glucoseClinicalSchema = z
.object({
stillLearning: z.boolean(),
stillLearningReason: z.string().nullable(),
windowDays: z.number().int(),
actualSpanDays: z.number(),
readingCount: z.number().int(),
meanMgdl: z.number().nullable(),
distribution: z
.object({
tir: z.number(),
tbrLevel1: z.number(),
tbrLevel2: z.number(),
tarLevel1: z.number(),
tarLevel2: z.number(),
minutesEquivalent: z.object({
tir: z.number(),
tbrLevel1: z.number(),
tbrLevel2: z.number(),
tarLevel1: z.number(),
tarLevel2: z.number(),
}),
})
.nullable(),
gmi: z.number().nullable(),
estimatedA1c: z.number().nullable(),
variability: z
.object({
sd: z.number(),
cv: z.number(),
unstable: z.boolean(),
})
.nullable(),
advanced: z
.object({
jIndex: z.number().nullable(),
lbgi: z.number(),
hbgi: z.number(),
})
.nullable(),
isSpotEstimate: z.literal(true),
})
.meta({
id: "GlucoseClinicalMetrics",
description:
"Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.",
});
const dashboardSnapshotResponse = z
.object({
user: z.object({
@@ -608,6 +662,7 @@ const dashboardSnapshotResponse = z
bpInTargetPctPriorMonth: z.number().nullable(),
bpInTargetPctPriorYear: z.number().nullable(),
glucoseByContext: dataSummaryRecord,
glucoseClinical: glucoseClinicalSchema,
})
.nullable(),
// Dashboard hero — today's medication block (fast phase, always
+9
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import type { DataSummary } from "@/lib/analytics/trends";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
/**
* v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`.
@@ -71,6 +72,14 @@ export interface AnalyticsRawPayload {
bpInTargetPctPriorMonth?: number | null;
bpInTargetPctPriorYear?: number | null;
glucoseByContext?: Record<string, unknown>;
/**
* v1.17.0 — server-authoritative glucose clinical panel (TIR / GMI / eA1C /
* CV% headline + the advanced J-index + LBGI/HBGI tier), computed once over
* the trailing-30-day window. Present on the thick default slice; absent on
* the slim `?slice=summaries` branch. The panel renders this verbatim and
* never re-derives a clinical number client-side.
*/
glucoseClinical?: GlucoseClinicalMetrics;
correlations?: unknown;
healthScore?: unknown;
sleepStages?: unknown;