fix(v1.17.0): reconcile QA findings across glucose, nightscout and contracts

Derive the glucose spot-estimate flag from reading density so a continuous
stream is no longer mislabeled a spot estimate, redact the Nightscout token
from a recorded sync error, rate-limit the Polar and Oura disconnect routes to
match the others, declare the two blood-pressure window fields the dashboard
snapshot already emits, make the reorder-lead-days migration re-runnable, and
guard the Polar and Oura sync queues against a silent drop.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:00:36 +02:00
commit d84c73af0d
14 files changed
+339 -40

No files matched your search

+17 -3
View File
@@ -13879,6 +13879,18 @@ components:
anyOf:
- type: number
- type: "null"
bpInTargetCount90:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
bpInTargetSpanDays90:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
glucoseByContext:
type: object
propertyNames:
@@ -13893,6 +13905,8 @@ components:
- bpInTargetPctAllTime
- bpInTargetPctPriorMonth
- bpInTargetPctPriorYear
- bpInTargetCount90
- bpInTargetSpanDays90
- glucoseByContext
- glucoseClinical
additionalProperties: false
@@ -14126,7 +14140,6 @@ components:
- type: "null"
isSpotEstimate:
type: boolean
const: true
required:
- stillLearning
- stillLearningReason
@@ -14141,8 +14154,9 @@ components:
- 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`
description: Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data
are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from
reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) 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
@@ -16,4 +16,4 @@
-- up without a migration of its own.
ALTER TABLE "medications"
ADD COLUMN "reorder_lead_days" INTEGER;
ADD COLUMN IF NOT EXISTS "reorder_lead_days" INTEGER;
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: rateLimitMock,
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
const post = POST as unknown as () => Promise<RouteResult>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 });
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" });
});
describe("POST /api/oura/disconnect", () => {
it("disconnects a connected user", async () => {
const res = await post();
expect(res.status).toBe(200);
expect(res.data).toEqual({ disconnected: true });
});
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
const res = await post();
expect(res.status).toBe(429);
// The user lookup never runs once the bucket is exhausted.
expect(userFind).not.toHaveBeenCalled();
});
});
+8
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
/**
@@ -16,6 +17,13 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.disconnect" } });
const rl = await checkRateLimit(`oura-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock } = vi.hoisted(() => ({ rateLimitMock: vi.fn() }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: rateLimitMock,
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
const post = POST as unknown as () => Promise<RouteResult>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 19, resetAt: 0 });
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc:tok" });
});
describe("POST /api/polar/disconnect", () => {
it("disconnects a connected user", async () => {
const res = await post();
expect(res.status).toBe(200);
expect(res.data).toEqual({ disconnected: true });
});
it("rate-limits the disconnect surface (429 on bucket exhaustion)", async () => {
rateLimitMock.mockResolvedValue({ allowed: false, remaining: 0, resetAt: 0 });
const res = await post();
expect(res.status).toBe(429);
// The user lookup never runs once the bucket is exhausted.
expect(userFind).not.toHaveBeenCalled();
});
});
+8
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
/**
@@ -16,6 +17,13 @@ export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.disconnect" } });
const rl = await checkRateLimit(`polar-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
@@ -206,10 +206,15 @@ export function GlucoseClinicalPanel() {
) : null}
</section>
{/* Spot-reading honesty caveat — always present. */}
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
{/* Spot-reading honesty caveat — only for sparse spot data. A dense
continuous stream (a CGM such as Nightscout) clears the density bar
and reports `isSpotEstimate: false`, so the caveat would undersell
the data. */}
{clinical.isSpotEstimate ? (
<p className="text-muted-foreground/80 text-xs" data-slot="glucose-spot-caveat">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
) : null}
{/* Advanced indices behind a disclosure. */}
{clinical.advanced ? (
+4 -3
View File
@@ -1539,9 +1539,10 @@ async function buildCoachSnapshotImpl(
? 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,
// Density-derived: a sparse spot series stays a spot-reading
// estimate, a continuous CGM stream (Nightscout) reads false so the
// model can narrate the TIR/GMI as continuous-trace figures.
isSpotEstimate: clinicalRaw.isSpotEstimate,
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
@@ -222,12 +222,48 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => {
expect(m.advanced).not.toBeNull();
});
it("always marks the result as a spot estimate", () => {
const m = computeGlucoseClinicalMetrics(
dailyReadings([100], now),
{ now },
);
expect(m.isSpotEstimate).toBe(true);
describe("isSpotEstimate — density-derived", () => {
it("marks sparse spot data as a spot estimate (one reading/day)", () => {
// 14 readings across 14 days = 1/day, far below the hourly CGM bar.
const m = computeGlucoseClinicalMetrics(
dailyReadings(Array(14).fill(120), now),
{ now },
);
expect(m.readingCount).toBe(14);
expect(m.isSpotEstimate).toBe(true);
});
it("marks a dense CGM-density stream as continuous (false)", () => {
// ~288 readings/day over 2 days, every 5 minutes — a Nightscout CGM.
const FIVE_MIN = 5 * 60 * 1000;
const total = 2 * 288;
const readings: GlucoseReading[] = Array.from(
{ length: total },
(_, i) => ({
mgdl: 110,
measuredAt: new Date(now.getTime() - (total - 1 - i) * FIVE_MIN),
}),
);
const m = computeGlucoseClinicalMetrics(readings, { now });
expect(m.readingCount).toBe(total);
expect(m.isSpotEstimate).toBe(false);
});
it("does not let a single-day burst masquerade as continuous", () => {
// 30 readings all inside one day → span floors at 1 day, so density is
// 30/day. That clears the bar legitimately, but a handful in a day must
// not: 10 readings in one day = 10/day < 24 → still a spot estimate.
const HOUR = 60 * 60 * 1000;
const readings: GlucoseReading[] = Array.from(
{ length: 10 },
(_, i) => ({
mgdl: 115,
measuredAt: new Date(now.getTime() - i * HOUR),
}),
);
const m = computeGlucoseClinicalMetrics(readings, { now });
expect(m.isSpotEstimate).toBe(true);
});
});
describe("learning gate", () => {
+38 -13
View File
@@ -9,14 +9,17 @@
* it never recomputes — keeping the clinical math in one literature-locked
* place.
*
* IMPORTANT honesty contract: HealthLog stores SPOT readings (FASTING /
* POSTPRANDIAL / RANDOM / BEDTIME), NOT a dense CGM stream. The classic
* CGM-adequacy bar (Battelino 2019: 14 days with ≥70% of possible readings)
* cannot be met by spot data. Therefore every TIR / GMI / eA1C produced here
* is a SPOT-READING ESTIMATE — a "% of readings" distribution, NOT a "% of
* time" AGP report. The {@link GlucoseClinicalMetrics.stillLearning} gate and
* the per-metric labelling exist so callers never assert a clinical AGP off
* thin spot data.
* IMPORTANT honesty contract: HealthLog ingests both SPOT readings (manual
* FASTING / POSTPRANDIAL / RANDOM / BEDTIME entries) AND, since v1.17, a dense
* Nightscout CGM stream (~288 readings/day). The classic CGM-adequacy bar
* (Battelino 2019: 14 days with ≥70% of possible readings) cannot be met by
* spot data, so for a sparse series every TIR / GMI / eA1C is a SPOT-READING
* ESTIMATE — a "% of readings" distribution, NOT a "% of time" AGP report.
* {@link GlucoseClinicalMetrics.isSpotEstimate} is derived from reading DENSITY
* (see {@link CGM_READINGS_PER_DAY_THRESHOLD}): a continuous CGM stream reads
* `false` and the spot caveat comes off; sparse spot data stays `true`. The
* {@link GlucoseClinicalMetrics.stillLearning} gate additionally holds back any
* assertion off thin data regardless of source.
*
* Thresholds and formulas are clean-room from the primary literature (the
* consensus band values and coefficients are mathematical facts, not
@@ -91,6 +94,17 @@ export const DEFAULT_WINDOW_DAYS = 14;
const DEFAULT_MIN_READINGS = 14;
const DEFAULT_MIN_SPAN_DAYS = 7;
/**
* Readings-per-day at or above which the series is treated as a CONTINUOUS
* stream (a CGM such as Nightscout, ~288/day) rather than spot fingersticks (a
* handful/day). Roughly hourly — wide enough that a real CGM (≈288/day) clears
* it by an order of magnitude while no realistic spot-checking cadence reaches
* it. Above the threshold the TIR/GMI/eA1C are no longer framed as a
* spot-reading estimate (`isSpotEstimate: false`); below it the conservative
* spot caveat stays.
*/
const CGM_READINGS_PER_DAY_THRESHOLD = 24;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ── Output shapes ────────────────────────────────────────
@@ -212,11 +226,15 @@ export interface GlucoseClinicalMetrics {
} | null;
/**
* Always true for this module: every number is a spot-reading estimate, not a
* CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the
* caveat without re-deriving it.
* Whether every number here should be framed as a SPOT-READING ESTIMATE (a
* "% of readings") rather than a CGM time-in-range AGP. Derived from reading
* DENSITY, not hard-coded: a continuous stream (a Nightscout CGM at ~288
* readings/day) clears the {@link CGM_READINGS_PER_DAY_THRESHOLD} hourly bar
* and reports `false`; sparse spot fingersticks stay `true`. Surfaced
* explicitly so serializers (panel/coach/PDF/iOS) carry the caveat without
* re-deriving it.
*/
isSpotEstimate: true;
isSpotEstimate: boolean;
}
// ── Core formulas ────────────────────────────────────────
@@ -488,6 +506,13 @@ export function computeGlucoseClinicalMetrics(
}; at least ${minSpanDays} days of coverage are needed.`;
}
// Density-derived spot vs continuous framing. Readings/day = count over the
// covered span (floored at one day so a single-day burst can't divide by a
// sub-day span and look continuous). At or above the hourly CGM bar the
// series is a continuous stream and the spot-reading caveat comes off.
const readingsPerDay = readingCount / Math.max(actualSpanDays, 1);
const isSpotEstimate = readingsPerDay < CGM_READINGS_PER_DAY_THRESHOLD;
return {
stillLearning,
stillLearningReason,
@@ -500,7 +525,7 @@ export function computeGlucoseClinicalMetrics(
estimatedA1c: eA1cValue,
variability,
advanced,
isSpotEstimate: true,
isSpotEstimate,
};
}
@@ -1,15 +1,13 @@
/**
* v1.17.0 — pg-boss queue registration for the Nightscout CGM sync layer.
* v1.17.0 — pg-boss queue registration for the three new poll-only sync layers:
* Nightscout CGM, Polar, and Oura.
*
* Source-as-text guard (the reminder-worker pulls heavy infra we don't boot
* here): assert the queue constant + cron + handler wiring + allQueues
* here): assert each queue constant + cron + handler wiring + allQueues
* membership are present — the fast guard against the v1.4.37 dead-queue class
* (a queue declared but never registered in `allQueues`, which silently never
* drains).
*
* Nightscout is poll-only (no webhook, no OAuth, no backfill queue): a single
* hourly `nightscout-sync` tick walks every configured instance via
* `syncUserNightscout`.
* drains). All three syncs are poll-only (no webhook, no backfill queue): a
* single hourly tick walks every configured account.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -45,3 +43,41 @@ describe("reminder-worker — Nightscout sync queue", () => {
);
});
});
describe("reminder-worker — Polar / Oura sync queues", () => {
// The same v1.4.37 dead-queue class applies to the two OAuth-wearable polls
// shipped alongside Nightscout in v1.17.0 — assert their allQueues membership
// so a future refactor can't silently drop one and starve the sync.
const allQueuesBlock = () => {
const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
expect(block).not.toBeNull();
return block![1]!;
};
it("declares the polar-sync and oura-sync queue constants", () => {
expect(source).toMatch(/POLAR_SYNC_QUEUE\s*=\s*["']polar-sync["']/);
expect(source).toMatch(/OURA_SYNC_QUEUE\s*=\s*["']oura-sync["']/);
});
it("registers POLAR_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => {
expect(allQueuesBlock()).toContain("POLAR_SYNC_QUEUE");
});
it("registers OURA_SYNC_QUEUE in allQueues (v1.4.37 dead-queue guard)", () => {
expect(allQueuesBlock()).toContain("OURA_SYNC_QUEUE");
});
it("schedules the hourly poll cron for both", () => {
expect(source).toMatch(/\[POLAR_SYNC_QUEUE,\s*POLAR_SYNC_CRON\]/);
expect(source).toMatch(/\[OURA_SYNC_QUEUE,\s*OURA_SYNC_CRON\]/);
});
it("registers a boss.work handler for each queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,160}POLAR_SYNC_QUEUE[\s\S]{0,160}handlePolarSync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,160}OURA_SYNC_QUEUE[\s\S]{0,160}handleOuraSync/,
);
});
});
+28
View File
@@ -149,6 +149,34 @@ describe("syncUserNightscout", () => {
expect(recordFailureMock.mock.calls[0]![0].kind).toBe("transient");
});
it("redacts the token from the recorded lastError message", async () => {
// A SafeFetchError embeds the full target URL — including `?token=<secret>`
// — in its message; the recorded lastError must never leak it.
fetchSgvEntriesMock.mockRejectedValue(
new Error(
"safeFetch network error: https://ns.example.com/api/v1/entries.json?count=576&type=sgv&token=ns-secret-abc123",
),
);
await expect(syncUserNightscout("u1")).rejects.toThrow();
const message = recordFailureMock.mock.calls[0]![0].message as string;
expect(message).not.toContain("ns-secret-abc123");
expect(message).toContain("token=REDACTED");
// The rest of the diagnostic is preserved.
expect(message).toContain("ns.example.com");
});
it("also redacts a classic api-secret query param", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new Error(
"safeFetch refused private or non-public host: https://ns.lan/api/v1/entries.json?api-secret=deadbeef",
),
);
await expect(syncUserNightscout("u1")).rejects.toThrow();
const message = recordFailureMock.mock.calls[0]![0].message as string;
expect(message).not.toContain("deadbeef");
expect(message).toContain("api-secret=REDACTED");
});
it("recomputes rollup buckets for the imported readings", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]);
await syncUserNightscout("u1");
+25 -1
View File
@@ -48,6 +48,28 @@ import { getUserNightscoutCredentials } from "./credentials";
*/
export const NIGHTSCOUT_SYNC_COUNT = 576;
/**
* Redact the Nightscout auth secret from an error message before it reaches the
* integration ledger / the `/api/nightscout/status` response.
*
* The entries URL carries the access token as a `?token=<secret>` query param
* (and a classic instance may carry `api-secret`). A `SafeFetchError` embeds the
* full target URL verbatim in its message — a private-host refusal, a timeout,
* or a network failure would otherwise persist the token into `lastError` and
* echo it back through the status endpoint. Rewrite any URL in the message so
* the secret-bearing params read `=REDACTED`; everything else is preserved so
* the operator still sees a useful diagnostic.
*/
export function redactNightscoutSecret(message: string): string {
// Match `token=...` / `api-secret=...` up to the next param / whitespace /
// quote boundary. Case-insensitive on the key; the value is whatever a URL
// query value can hold short of a delimiter.
return message.replace(
/\b(token|api-secret)=[^\s&"']+/gi,
"$1=REDACTED",
);
}
/** Map a Nightscout HTTP status / network error onto the shared ledger kind. */
export function classifyNightscoutFailure(err: unknown): FailureKind {
if (err instanceof NightscoutApiError && err.status != null) {
@@ -85,7 +107,9 @@ export async function syncUserNightscout(
userId,
integration: "nightscout",
kind: classifyNightscoutFailure(err),
message: err instanceof Error ? err.message : String(err),
message: redactNightscoutSecret(
err instanceof Error ? err.message : String(err),
),
errorCode:
err instanceof NightscoutApiError && err.status != null
? String(err.status)
+4 -2
View File
@@ -587,12 +587,12 @@ const glucoseClinicalSchema = z
hbgi: z.number(),
})
.nullable(),
isSpotEstimate: z.literal(true),
isSpotEstimate: z.boolean(),
})
.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.",
"Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) 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
@@ -661,6 +661,8 @@ const dashboardSnapshotResponse = z
bpInTargetPctAllTime: z.number().nullable(),
bpInTargetPctPriorMonth: z.number().nullable(),
bpInTargetPctPriorYear: z.number().nullable(),
bpInTargetCount90: z.number().int().nullable(),
bpInTargetSpanDays90: z.number().int().nullable(),
glucoseByContext: dataSummaryRecord,
glucoseClinical: glucoseClinicalSchema,
})