diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 21a24b6c8..aa1883989 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -14126,7 +14126,6 @@ components:
- type: "null"
isSpotEstimate:
type: boolean
- const: true
required:
- stillLearning
- stillLearningReason
@@ -14141,8 +14140,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
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx
index 95f32c4fb..6f70e07ae 100644
--- a/src/components/insights/glucose/glucose-clinical-panel.tsx
+++ b/src/components/insights/glucose/glucose-clinical-panel.tsx
@@ -206,10 +206,15 @@ export function GlucoseClinicalPanel() {
) : null}
- {/* Spot-reading honesty caveat — always present. */}
-
- {t("insights.bloodGlucose.clinical.spotCaveat")}
-
+ {/* 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 ? (
+
+ {t("insights.bloodGlucose.clinical.spotCaveat")}
+
+ ) : null}
{/* Advanced indices behind a disclosure. */}
{clinical.advanced ? (
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 7fbdba570..d5221b81e 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -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
// " " and the EVIDENCE BLOCK tags glucose lines correctly.
diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts
index 1e4f6a498..68e1b3d91 100644
--- a/src/lib/analytics/__tests__/glucose-metrics.test.ts
+++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts
@@ -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", () => {
diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts
index 3098c2e0b..2faefa59d 100644
--- a/src/lib/analytics/glucose-metrics.ts
+++ b/src/lib/analytics/glucose-metrics.ts
@@ -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,
};
}
diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts
index 5db110d2b..c2f88ad6d 100644
--- a/src/lib/openapi/routes/insights.ts
+++ b/src/lib/openapi/routes/insights.ts
@@ -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