From 15c0a63c2272d91c384265b1bb3a3ec5f3a52e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:30:55 +0200 Subject: [PATCH] feat(glucose): add clinical-metrics core for TIR, GMI, eA1C and CV --- .../__tests__/glucose-metrics.test.ts | 234 +++++++++++ src/lib/analytics/glucose-metrics.ts | 380 ++++++++++++++++++ 2 files changed, 614 insertions(+) create mode 100644 src/lib/analytics/__tests__/glucose-metrics.test.ts create mode 100644 src/lib/analytics/glucose-metrics.ts diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts new file mode 100644 index 000000000..0fdfbd685 --- /dev/null +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -0,0 +1,234 @@ +/** + * Known-value pins for the glucose clinical-metrics core. + * + * Worked examples are computed straight from the cited primary literature: + * Battelino 2019 (TIR bands), Bergenstal 2018 (GMI), Nathan 2008 (eA1C), + * Monnier 2017 (CV% 36% cutoff). The numbers below are the formulas applied + * by hand, not back-derived from the implementation. + */ +import { describe, expect, it } from "vitest"; +import { + CV_INSTABILITY_THRESHOLD, + DEFAULT_WINDOW_DAYS, + computeGlucoseClinicalMetrics, + estimatedA1c, + glucoseSD, + glucoseVariability, + gmi, + timeInRange, + type GlucoseReading, +} from "../glucose-metrics"; + +const DAY = 24 * 60 * 60 * 1000; + +/** Build readings spaced one per day ending at `anchor`. */ +function dailyReadings(values: number[], anchor: Date): GlucoseReading[] { + return values.map((mgdl, i) => ({ + mgdl, + // oldest first, newest = anchor + measuredAt: new Date(anchor.getTime() - (values.length - 1 - i) * DAY), + })); +} + +describe("gmi (Bergenstal 2018)", () => { + it("maps a mean of 154 mg/dL to ~6.99%", () => { + // 3.31 + 0.02392 * 154 = 6.99368 + expect(gmi(154)).toBeCloseTo(6.99368, 5); + }); + + it("maps a mean of 100 mg/dL to 5.702%", () => { + expect(gmi(100)).toBeCloseTo(5.702, 5); + }); +}); + +describe("estimatedA1c (Nathan 2008 ADAG inverse)", () => { + it("maps a mean of 154 mg/dL to ~6.99%", () => { + // (154 + 46.7) / 28.7 = 6.99303... + expect(estimatedA1c(154)).toBeCloseTo(6.99303, 4); + }); + + it("maps a mean of 100 mg/dL to ~5.11%", () => { + // (100 + 46.7) / 28.7 = 5.11149... + expect(estimatedA1c(100)).toBeCloseTo(5.11149, 4); + }); +}); + +describe("glucoseSD", () => { + it("returns null for fewer than two values", () => { + expect(glucoseSD([])).toBeNull(); + expect(glucoseSD([120])).toBeNull(); + }); + + it("computes the sample (n-1) standard deviation", () => { + // values 2,4,4,4,5,5,7,9 → mean 5, sample SD = sqrt(32/7) = 2.13809 + expect(glucoseSD([2, 4, 4, 4, 5, 5, 7, 9])).toBeCloseTo(2.13809, 4); + }); +}); + +describe("glucoseVariability (Monnier 2017 CV cutoff)", () => { + it("flags instability at CV ≥ 36%", () => { + // mean 100, SD 36 → CV exactly 36 → unstable (>=) + const v = glucoseVariability([136, 64, 100]); + // mean = 100; SD of {136,64,100} = sqrt(((36^2)+(36^2)+0)/2) = 36 + expect(v).not.toBeNull(); + expect(v!.cv).toBeCloseTo(36, 6); + expect(v!.unstable).toBe(true); + expect(CV_INSTABILITY_THRESHOLD).toBe(36); + }); + + it("does not flag a stable low-variability set", () => { + const v = glucoseVariability([100, 102, 98, 101, 99]); + expect(v!.unstable).toBe(false); + expect(v!.cv).toBeLessThan(36); + }); +}); + +describe("timeInRange (Battelino 2019 bands)", () => { + it("returns null for an empty set", () => { + expect(timeInRange([])).toBeNull(); + }); + + it("computes exact per-band fractions with nested level-2 sub-bands", () => { + // 10 readings: + // 40 → <54 and <70 (TBR2 + TBR1) + // 60 → <70 only (TBR1) + // 70,120,150,180 → in range (4) + // 200,210 → >180 only (TAR1) + // 300 → >250 and >180 (TAR2 + TAR1) + const d = timeInRange([40, 60, 70, 120, 150, 180, 200, 210, 300, 90]); + expect(d).not.toBeNull(); + // in range: 70,120,150,180,90 = 5 + expect(d!.tir).toBeCloseTo(0.5, 10); + // <70: 40,60 = 2 + expect(d!.tbrLevel1).toBeCloseTo(0.2, 10); + // <54: 40 = 1 + expect(d!.tbrLevel2).toBeCloseTo(0.1, 10); + // >180: 200,210,300 = 3 + expect(d!.tarLevel1).toBeCloseTo(0.3, 10); + // >250: 300 = 1 + expect(d!.tarLevel2).toBeCloseTo(0.1, 10); + }); + + it("treats the band edges 70 and 180 as in range, 54 as below", () => { + const d = timeInRange([54, 70, 180, 181, 69]); + // in range: 70,180 = 2/5 + expect(d!.tir).toBeCloseTo(0.4, 10); + // <70: 54,69 = 2/5 + expect(d!.tbrLevel1).toBeCloseTo(0.4, 10); + // <54: none (54 is not < 54) + expect(d!.tbrLevel2).toBe(0); + // >180: 181 = 1/5 + expect(d!.tarLevel1).toBeCloseTo(0.2, 10); + }); + + it("emits minutes-of-a-day equivalents (fraction × 1440)", () => { + const d = timeInRange([100, 100, 100, 300]); // 3 in range, 1 above + expect(d!.minutesEquivalent.tir).toBeCloseTo(0.75 * 1440, 6); + expect(d!.minutesEquivalent.tarLevel1).toBeCloseTo(0.25 * 1440, 6); + }); +}); + +describe("computeGlucoseClinicalMetrics — window + adequacy", () => { + const now = new Date("2026-06-14T12:00:00Z"); + + it("defaults to a 14-day window", () => { + const m = computeGlucoseClinicalMetrics([], { now }); + expect(m.windowDays).toBe(DEFAULT_WINDOW_DAYS); + expect(DEFAULT_WINDOW_DAYS).toBe(14); + }); + + it("excludes readings older than the window", () => { + const readings: GlucoseReading[] = [ + { mgdl: 120, measuredAt: new Date(now.getTime() - 2 * DAY) }, + { mgdl: 130, measuredAt: new Date(now.getTime() - 20 * DAY) }, // out + ]; + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(1); + }); + + it("ignores non-finite values", () => { + const readings: GlucoseReading[] = [ + { mgdl: 120, measuredAt: new Date(now.getTime() - 1 * DAY) }, + { mgdl: NaN, measuredAt: new Date(now.getTime() - 2 * DAY) }, + ]; + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(1); + }); + + it("always marks the result as a spot estimate", () => { + const m = computeGlucoseClinicalMetrics( + dailyReadings([100], now), + { now }, + ); + expect(m.isSpotEstimate).toBe(true); + }); + + describe("learning gate", () => { + it("fires when there are no readings", () => { + const m = computeGlucoseClinicalMetrics([], { now }); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/no glucose readings/i); + expect(m.meanMgdl).toBeNull(); + expect(m.distribution).toBeNull(); + expect(m.gmi).toBeNull(); + expect(m.estimatedA1c).toBeNull(); + expect(m.variability).toBeNull(); + }); + + it("fires below the minimum reading count, reporting N and D", () => { + // 5 readings across 5 days — below the default 14-reading floor + const m = computeGlucoseClinicalMetrics( + dailyReadings([100, 110, 120, 130, 140], now), + { now }, + ); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/5 readings over 4 days/); + expect(m.readingCount).toBe(5); + // numbers are still populated for a calm preview + expect(m.meanMgdl).toBeCloseTo(120, 6); + expect(m.distribution).not.toBeNull(); + }); + + it("fires when the span is too short even with enough readings", () => { + // 16 readings all within a single day → span < 7 days + const values = Array.from({ length: 16 }, (_, i) => 100 + i); + const readings: GlucoseReading[] = values.map((mgdl, i) => ({ + mgdl, + measuredAt: new Date(now.getTime() - i * 60 * 60 * 1000), // hourly + })); + const m = computeGlucoseClinicalMetrics(readings, { now }); + expect(m.readingCount).toBe(16); + expect(m.stillLearning).toBe(true); + expect(m.stillLearningReason).toMatch(/span only/i); + }); + + it("clears above both thresholds and asserts the panel", () => { + // 14 readings, one per day → 13-day span, count 14 + const values = Array.from({ length: 14 }, () => 154); + const m = computeGlucoseClinicalMetrics( + dailyReadings(values, now), + { now }, + ); + expect(m.readingCount).toBe(14); + expect(m.actualSpanDays).toBeCloseTo(13, 6); + expect(m.stillLearning).toBe(false); + expect(m.stillLearningReason).toBeNull(); + // mean 154 → GMI ~6.99, eA1C ~6.99 + expect(m.meanMgdl).toBeCloseTo(154, 6); + expect(m.gmi).toBeCloseTo(6.99368, 5); + expect(m.estimatedA1c).toBeCloseTo(6.99303, 4); + expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range + expect(m.variability!.sd).toBe(0); + expect(m.variability!.unstable).toBe(false); + }); + + it("honours configurable thresholds", () => { + const m = computeGlucoseClinicalMetrics( + dailyReadings([100, 110, 120], now), + { now, minReadings: 3, minSpanDays: 2 }, + ); + // 3 readings over a 2-day span clears the lowered bars + expect(m.stillLearning).toBe(false); + }); + }); +}); diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts new file mode 100644 index 000000000..67d5de31b --- /dev/null +++ b/src/lib/analytics/glucose-metrics.ts @@ -0,0 +1,380 @@ +/** + * Glucose clinical-metrics core — server-authoritative analytics layer. + * + * Pure, side-effect-free functions over an array of spot blood-glucose + * readings (canonical **mg/dL**, the HealthLog store unit — see + * `src/lib/glucose.ts` for unit conversion; this module never duplicates it). + * One engine: the analytics route, the AI coach, the doctor-report PDF/FHIR + * export, and the iOS client all read these numbers. iOS RENDERS the output, + * 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. + * + * Thresholds and formulas are clean-room from the primary literature (the + * consensus band values and coefficients are mathematical facts, not + * copyrightable). Each function cites its source inline. Defer the + * dense-stream indices (MAGE, CONGA, MODD, ADRR, AGP, time-weighted mean) to + * the future CGM integration — they need ordered, closely-spaced samples this + * module deliberately does not assume. + * + * Citations: + * Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028. + * Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581. + * Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545. + * Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769. + */ + +// ── Inputs ─────────────────────────────────────────────── + +/** A single spot blood-glucose reading. Value is canonical mg/dL. */ +export interface GlucoseReading { + /** When the sample was taken. */ + measuredAt: Date; + /** Glucose value in mg/dL (HealthLog canonical store unit). */ + mgdl: number; +} + +/** Options for the clinical-window computation. */ +export interface GlucoseMetricsOptions { + /** + * Clinical reporting window in days. Default 14 — the Battelino 2019 + * consensus reporting period. Readings older than `now - windowDays` are + * excluded. Configurable so the doctor report or a longer trend view can + * widen it. + */ + windowDays?: number; + /** + * Anchor for the window. Defaults to `new Date()`. Injectable purely to keep + * the module deterministic under test — there is no other side effect. + */ + now?: Date; + /** + * Minimum number of in-window readings before TIR/GMI/eA1C/CV are asserted + * rather than gated behind `stillLearning`. Default 14 — roughly one reading + * per day across the window; below this a spot-data estimate is not + * clinically meaningful. + */ + minReadings?: number; + /** + * Minimum span (first→last reading, in days) the window's readings must + * cover. Default 7. A burst of readings inside a single day cannot stand in + * for two weeks of glycaemic behaviour. + */ + minSpanDays?: number; +} + +// ── Consensus thresholds (Battelino 2019) ──────────────── +// Bands in mg/dL. These are consensus FACTS, cited above. +const TBR_LEVEL2_MAX = 54; // very low: G < 54 +const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69) +const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250) +const TAR_LEVEL2_MIN = 250; // very high: G > 250 +// Time-in-range target band is [70, 180] inclusive. + +/** Monnier 2017 variability cutoff: CV% ≥ 36 is "unstable". */ +export const CV_INSTABILITY_THRESHOLD = 36; + +/** Battelino 2019 reporting defaults. */ +export const DEFAULT_WINDOW_DAYS = 14; +const DEFAULT_MIN_READINGS = 14; +const DEFAULT_MIN_SPAN_DAYS = 7; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// ── Output shapes ──────────────────────────────────────── + +/** + * Battelino 2019 time-in-range distribution as fractions of readings (0–1). + * `*Minutes` carry the minutes-equivalent over a 24h day (fraction × 1440) so + * the UI can echo the consensus " 0.70. */ + tir: number; + /** % readings G < 70 (includes the very-low sub-band). Goal < 0.04. */ + tbrLevel1: number; + /** % readings G < 54. Goal < 0.01. */ + tbrLevel2: number; + /** % readings G > 180 (includes the very-high sub-band). Goal < 0.25. */ + tarLevel1: number; + /** % readings G > 250. Goal < 0.05. */ + tarLevel2: number; + /** Minutes-of-a-day equivalents (fraction × 1440). */ + minutesEquivalent: { + tir: number; + tbrLevel1: number; + tbrLevel2: number; + tarLevel1: number; + tarLevel2: number; + }; +} + +/** Variability summary: SD + CV% with the Monnier stability flag. */ +export interface GlucoseVariability { + /** Sample standard deviation (n−1), mg/dL. */ + sd: number; + /** Coefficient of variation, percent: SD / mean × 100. */ + cv: number; + /** True when CV% ≥ 36 (Monnier 2017 instability cutoff). */ + unstable: boolean; +} + +/** The full clinical-metrics result for a window of spot readings. */ +export interface GlucoseClinicalMetrics { + /** + * When true, the window has too few readings or too short a span to assert a + * clinically meaningful estimate. The numeric fields below are still + * populated from whatever data exists (so a calm preview can render) but MUST + * NOT be presented as a clinical assessment. The UI should render a + * "still learning — N readings over D days" state. + */ + stillLearning: boolean; + /** Human/UI-facing reason for the gate (null once it clears). */ + stillLearningReason: string | null; + + /** Declared clinical window in days (the requested `windowDays`). */ + windowDays: number; + /** Actual span the in-window readings cover (first→last), in days. */ + actualSpanDays: number; + /** Number of readings used (those falling inside the window). */ + readingCount: number; + + /** Arithmetic mean glucose over the window, mg/dL (null if no readings). */ + meanMgdl: number | null; + + /** Battelino 2019 TIR/TBR/TAR distribution (null if no readings). */ + distribution: TimeInRangeDistribution | null; + /** Bergenstal 2018 Glucose Management Indicator, percent (null if no data). */ + gmi: number | null; + /** Nathan 2008 estimated A1C, percent (null if no data). */ + estimatedA1c: number | null; + /** SD + CV% + instability flag (null if < 2 readings). */ + variability: GlucoseVariability | 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. + */ + isSpotEstimate: true; +} + +// ── Core formulas ──────────────────────────────────────── + +/** Arithmetic mean. Caller guarantees a non-empty array. */ +function mean(values: number[]): number { + let sum = 0; + for (const v of values) sum += v; + return sum / values.length; +} + +/** + * Sample standard deviation, sqrt(Σ(Gᵢ−mean)² / (n−1)). + * Standard variability foundation reported alongside mean in Battelino 2019. + * Returns null for n < 2 (undefined sample SD). + */ +export function glucoseSD(values: number[]): number | null { + const n = values.length; + if (n < 2) return null; + const m = mean(values); + let ss = 0; + for (const v of values) { + const d = v - m; + ss += d * d; + } + return Math.sqrt(ss / (n - 1)); +} + +/** + * Coefficient of variation (%) with the Monnier 2017 instability flag. + * %CV = SD / mean × 100; CV% ≥ 36 → unstable. + * Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769; + * endorsed in Battelino 2019. + */ +export function glucoseVariability(values: number[]): GlucoseVariability | null { + const sd = glucoseSD(values); + if (sd === null) return null; + const m = mean(values); + if (m === 0) return null; + const cv = (sd / m) * 100; + return { sd, cv, unstable: cv >= CV_INSTABILITY_THRESHOLD }; +} + +/** + * Glucose Management Indicator (GMI), percent, from mean glucose in mg/dL. + * GMI% = 3.31 + 0.02392 × mean(mg/dL). + * Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581. + */ +export function gmi(meanMgdl: number): number { + return 3.31 + 0.02392 * meanMgdl; +} + +/** + * Estimated A1C (%) from mean glucose in mg/dL (ADAG inverse of eAG). + * eAG = 28.7 × A1C − 46.7 ⇒ A1C% = (mean + 46.7) / 28.7. + * Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545. + * Preferred for spot/SMBG data; GMI is the CGM-native sibling. + */ +export function estimatedA1c(meanMgdl: number): number { + return (meanMgdl + 46.7) / 28.7; +} + +/** + * Time-in-range distribution over the Battelino 2019 consensus bands, as + * fractions of readings (NOT % of time — spot data, labelled honestly). + * TIR : 70 ≤ G ≤ 180 (goal > 0.70) + * TBR1 : G < 70 (goal < 0.04) + * TBR2 : G < 54 (goal < 0.01) + * TAR1 : G > 180 (goal < 0.25) + * TAR2 : G > 250 (goal < 0.05) + * TBR2 ⊆ TBR1 and TAR2 ⊆ TAR1 (the consensus reports level-2 as a sub-band of + * level-1), so the five fractions are NOT mutually exclusive by design. + * Battelino 2019, Diabetes Care 42(8):1593-1603, DOI 10.2337/dci19-0028. + */ +export function timeInRange( + values: number[], +): TimeInRangeDistribution | null { + const n = values.length; + if (n === 0) return null; + + let inRange = 0; + let below70 = 0; + let below54 = 0; + let above180 = 0; + let above250 = 0; + + for (const g of values) { + if (g < TBR_LEVEL1_MAX) { + below70 += 1; + if (g < TBR_LEVEL2_MAX) below54 += 1; + } else if (g > TAR_LEVEL1_MIN) { + above180 += 1; + if (g > TAR_LEVEL2_MIN) above250 += 1; + } else { + // 70 ≤ G ≤ 180 + inRange += 1; + } + } + + const tir = inRange / n; + const tbrLevel1 = below70 / n; + const tbrLevel2 = below54 / n; + const tarLevel1 = above180 / n; + const tarLevel2 = above250 / n; + const MIN_PER_DAY = 1440; + + return { + tir, + tbrLevel1, + tbrLevel2, + tarLevel1, + tarLevel2, + minutesEquivalent: { + tir: tir * MIN_PER_DAY, + tbrLevel1: tbrLevel1 * MIN_PER_DAY, + tbrLevel2: tbrLevel2 * MIN_PER_DAY, + tarLevel1: tarLevel1 * MIN_PER_DAY, + tarLevel2: tarLevel2 * MIN_PER_DAY, + }, + }; +} + +// ── Window + adequacy orchestration ────────────────────── + +/** + * Compute the full clinical-metrics panel for a set of spot readings. + * + * Pins a declared clinical window (default 14d, Battelino 2019), reports the + * actual span + reading count used, and applies the learning/adequacy gate so + * the caller never presents a thin-data estimate as a clinical assessment. + * + * Pure: no Prisma, no I/O. `now` is injectable for deterministic tests. + */ +export function computeGlucoseClinicalMetrics( + readings: GlucoseReading[], + options: GlucoseMetricsOptions = {}, +): GlucoseClinicalMetrics { + const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = options.now ?? new Date(); + const minReadings = options.minReadings ?? DEFAULT_MIN_READINGS; + const minSpanDays = options.minSpanDays ?? DEFAULT_MIN_SPAN_DAYS; + + const cutoff = now.getTime() - windowDays * MS_PER_DAY; + const inWindow = readings + .filter( + (r) => + r.measuredAt.getTime() >= cutoff && + r.measuredAt.getTime() <= now.getTime() && + Number.isFinite(r.mgdl), + ) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const readingCount = inWindow.length; + const actualSpanDays = + readingCount >= 2 + ? (inWindow[readingCount - 1].measuredAt.getTime() - + inWindow[0].measuredAt.getTime()) / + MS_PER_DAY + : 0; + + const values = inWindow.map((r) => r.mgdl); + const meanMgdl = values.length > 0 ? mean(values) : null; + + const distribution = timeInRange(values); + const variability = glucoseVariability(values); + const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null; + const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null; + + // Learning/adequacy gate. Spot data cannot meet the CGM adequacy bar; this + // is the honest "is the estimate worth asserting yet" check. Order matters: + // report the most actionable reason first. + let stillLearning = false; + let stillLearningReason: string | null = null; + + if (readingCount === 0) { + stillLearning = true; + stillLearningReason = `No glucose readings in the last ${windowDays} days.`; + } else if (readingCount < minReadings) { + stillLearning = true; + stillLearningReason = `Still learning — ${readingCount} reading${ + readingCount === 1 ? "" : "s" + } over ${roundSpan(actualSpanDays)} day${ + roundSpan(actualSpanDays) === 1 ? "" : "s" + }; at least ${minReadings} are needed for a meaningful estimate.`; + } else if (actualSpanDays < minSpanDays) { + stillLearning = true; + stillLearningReason = `Still learning — readings span only ${roundSpan( + actualSpanDays, + )} day${ + roundSpan(actualSpanDays) === 1 ? "" : "s" + }; at least ${minSpanDays} days of coverage are needed.`; + } + + return { + stillLearning, + stillLearningReason, + windowDays, + actualSpanDays, + readingCount, + meanMgdl, + distribution, + gmi: gmiValue, + estimatedA1c: eA1cValue, + variability, + isSpotEstimate: true, + }; +} + +/** Round a span to whole days for the human-facing learning message. */ +function roundSpan(spanDays: number): number { + return Math.max(0, Math.round(spanDays)); +}