mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Serve glucose clinical panel from one engine to all surfaces
This commit is contained in:
1 parent
3dfc3fe515
commit
3be7c3788b
11 files changed
+342
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user