feat(coach): feed native strain and teach the glucose panel

Surface WHOOP's native day strain to the coach on its own 0-21 scale, distinct
from the computed strain score, and instruct the model to prefer the device
number — the gold-standard signal a WHOOP user connected was previously invisible
to the coach. Document the glucose clinical block in the prompt so the model reads
time-in-range, GMI, estimated A1C and variability correctly and never asserts them
while still learning or off spot readings, and extend the non-English unit
vocabulary to cover glucose. Centralize the glucose panel window and run the
independent derived reads concurrently.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:27:52 +02:00
commit 713fa3e926
10 files changed
+269 -21

No files matched your search

+5 -2
View File
@@ -22,6 +22,7 @@ import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
@@ -346,7 +347,9 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// v1.4.29 H2 — bound to the trailing 30 days. The dashboard tile
// path only reads the trailing window; pre-bound this query
// walked every persisted BG row a multi-year user has written.
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseSince = new Date(
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const glucoseRows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -379,7 +382,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// "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() },
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now: new Date() },
);
// v1.4.20 phase B3 — three pre-defined correlation hypotheses.
@@ -333,3 +333,77 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
expect(snapshot.bodyFat).toBeDefined();
});
});
/**
* v1.17.0 — WHOOP-native day strain (0–21) reaches the Coach snapshot as its
* own `dayStrain` block, distinct from the computed `derived.STRAIN_SCORE`.
* The read is a SEPARATE `where.type === "DAY_STRAIN"` query, so the fixtures
* here discriminate the mock by type rather than returning one flat array.
*/
describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
pulse: undefined,
mood: undefined,
});
});
/** Route each findMany to rows matching its `where.type` filter. */
function mockByType(byType: Record<string, ReturnType<typeof daysAgo>[]>) {
prismaMock.measurement.findMany.mockImplementation((args: unknown) => {
const where = (args as { where?: { type?: unknown } }).where;
const t = where?.type;
if (typeof t === "string") return Promise.resolve(byType[t] ?? []);
if (t && typeof t === "object" && "in" in t) {
const wanted = (t as { in: string[] }).in;
return Promise.resolve(
wanted.flatMap((typeName) => byType[typeName] ?? []),
);
}
return Promise.resolve([]);
});
}
it("emits a native dayStrain block on DAY_STRAIN rows for a WHOOP user", async () => {
mockByType({
HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")],
// Ascending (oldest first), mirroring the `orderBy: measuredAt asc` read.
DAY_STRAIN: [daysAgo(3, 11.8, "DAY_STRAIN"), daysAgo(1, 14.2, "DAY_STRAIN")],
});
const out = await buildCoachSnapshot("user-1", {
sources: ["hrv"],
window: "last30days",
});
const snapshot = JSON.parse(out.snapshotJson) as Record<string, unknown>;
const dayStrain = snapshot.dayStrain as Record<string, unknown> | undefined;
expect(dayStrain).toBeDefined();
expect(dayStrain?.scale).toBe("0-21");
expect(dayStrain?.latest).toBe(14.2);
expect(dayStrain?.days).toBe(2);
});
it("omits dayStrain when the account has no DAY_STRAIN rows", async () => {
mockByType({
HEART_RATE_VARIABILITY: [daysAgo(2, 64, "HEART_RATE_VARIABILITY")],
});
const out = await buildCoachSnapshot("user-1", {
sources: ["hrv"],
window: "last30days",
});
const snapshot = JSON.parse(out.snapshotJson) as Record<string, unknown>;
expect(snapshot.dayStrain).toBeUndefined();
});
});
@@ -98,6 +98,26 @@ describe("getCoachSystemPrompt — EN", () => {
// appear.
expect(prompt).not.toMatch(/@.+\.(com|net|org|de|test)/);
});
it("documents the glucose.clinical panel sub-key and its gates", () => {
// v1.17.0 — the model must know the `clinical` sub-key exists, how to
// read TIR/GMI/eA1C/CV, and must NOT assert a clinical figure when the
// learning-gate or the spot-estimate caveat is set.
expect(prompt).toMatch(/"clinical"/);
expect(prompt).toMatch(/tirPercent/);
expect(prompt).toMatch(/\bgmi\b/);
expect(prompt).toMatch(/estimatedA1c/);
expect(prompt).toMatch(/cvPercent/);
expect(prompt).toMatch(/stillLearning/);
expect(prompt).toMatch(/isSpotEstimate/);
});
it("documents the WHOOP-native dayStrain block vs the computed proxy", () => {
// v1.17.0 — native 0–21 strain preferred over the computed 0–100 proxy.
expect(prompt).toMatch(/dayStrain/);
expect(prompt).toMatch(/0–21/);
expect(prompt).toMatch(/0–100/);
});
});
describe("getCoachSystemPrompt — DE", () => {
@@ -153,6 +173,14 @@ describe("getCoachSystemPrompt — DE", () => {
expect(prompt).toMatch(/Bewegung sehe ich in deinem Tracking gerade nicht/);
expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/);
});
it("lists glucose + duration units in the evidence-block enumeration", () => {
// v1.17.0 — the DE unit list must sanction mg/dL, mmol/L and min so a
// German coach can cite glucose + duration values with a valid token.
expect(prompt).toMatch(/mg\/dL/);
expect(prompt).toMatch(/mmol\/L/);
expect(prompt).toMatch(/min[\s\S]*\(Schlafdefizit/);
});
});
/**
+16 -9
View File
@@ -112,15 +112,22 @@ export async function buildDerivedSnapshotBlock(
): Promise<Record<string, DerivedSnapshotEntry> | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
for (const metric of SNAPSHOT_METRICS) {
// Per-metric fault isolation: a transient compute failure on one metric
// must never sink the whole Coach turn — drop it and carry on.
let derived;
try {
derived = await computeDerivedMetric({ metric, userId, profile, now });
} catch {
continue;
}
// The metrics are independent passthrough reads off the one shared profile —
// no ordering dependency — so compute them concurrently. Per-metric fault
// isolation: a transient failure on one must never sink the whole Coach turn,
// so each compute resolves to null on throw rather than rejecting the batch.
const computed = await Promise.all(
SNAPSHOT_METRICS.map(async (metric) => {
try {
return { metric, derived: await computeDerivedMetric({ metric, userId, profile, now }) };
} catch {
return { metric, derived: null };
}
}),
);
for (const { metric, derived } of computed) {
if (derived === null) continue;
if (!isDerivedOk(derived)) continue; // omit insufficient — no noise
const summary = summariseValue(metric, derived.value);
if (!summary) continue;
+51 -2
View File
@@ -24,7 +24,10 @@ 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 {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
} from "@/lib/analytics/glucose-metrics";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -96,7 +99,7 @@ const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
* narration window so the coach's TIR/GMI/CV% always equals what the panel
* renders.
*/
const GLUCOSE_CLINICAL_WINDOW_DAYS = 30;
const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS;
/**
* v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed
@@ -936,6 +939,23 @@ async function buildCoachSnapshotImpl(
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now)
: null;
// v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED
// STRAIN_SCORE (0–100) the derived block carries. Gated on the same
// wellness/activity signals so it rides the existing parallel batch; the
// block is omitted when the account has no DAY_STRAIN rows (every
// non-WHOOP account). Native-over-computed mirrors how recovery resolves.
const dayStrainRowsPromise = derivedActive
? prisma.measurement.findMany({
where: {
userId,
type: "DAY_STRAIN",
measuredAt: { gte: cutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
const trajectoryBlockPromise = derivedActive
? buildTrajectorySnapshotBlock(userId, derivedProfile, now)
: null;
@@ -967,6 +987,7 @@ async function buildCoachSnapshotImpl(
glucoseClinicalRows,
glp1Block,
derivedBlock,
dayStrainRows,
trajectoryBlock,
memoryBlock,
cycleBlock,
@@ -979,6 +1000,7 @@ async function buildCoachSnapshotImpl(
glucoseClinicalRowsPromise,
glp1BlockPromise,
derivedBlockPromise,
dayStrainRowsPromise,
trajectoryBlockPromise,
memoryBlockPromise,
cycleBlockPromise,
@@ -1677,6 +1699,33 @@ async function buildCoachSnapshotImpl(
registerBlock("derived", "hrv");
}
// ── v1.17.0 — WHOOP-native day strain ────────────────────────────────
// The device's gold-standard strain on its native 0–21 scale, kept
// distinct from the COMPUTED `derived.STRAIN_SCORE` (0–100). When both
// exist we surface the native number AND flag it as the device signal
// so the model prefers it (native-over-computed, mirroring recovery).
// Omitted for every account without a DAY_STRAIN row.
if (dayStrainRows && dayStrainRows.length > 0) {
const latest = dayStrainRows[dayStrainRows.length - 1];
const recent = dayStrainRows.filter((r) => r.measuredAt >= recentCutoff);
const mean =
recent.length > 0
? Math.round(
(recent.reduce((sum, r) => sum + r.value, 0) / recent.length) *
10,
) / 10
: Math.round(latest.value * 10) / 10;
snapshot.dayStrain = {
source: "WHOOP-native",
scale: "0-21",
latest: Math.round(latest.value * 10) / 10,
recentMean: mean,
days: dayStrainRows.length,
note: "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy).",
};
registerBlock("dayStrain", "hrv");
}
// ── v1.11.0 (Epic B, Pillar 3) — short-horizon trajectory block ──────
// Additive, lowest-signal block: per in-scope metric a compact
// direction + slope + projected horizon-end-with-band, computed by the
+26 -2
View File
@@ -230,7 +230,14 @@ ISO-week means.
yellow band") to ground a reply. These are DESCRIPTIVE composites,
not clinical assessments or diagnoses — never frame a band as a
medical finding, and lean on lower-confidence / few-day entries
cautiously. Never recompute or second-guess the number.
cautiously. Never recompute or second-guess the number. The "strain"
entry inside "derived" is a COMPUTED proxy on a 0–100 scale. When the
SNAPSHOT ALSO carries a top-level "dayStrain" block, that is the
device's NATIVE day strain (WHOOP) on its own 0–21 scale ({ latest,
recentMean, scale: "0-21", days }) — prefer the native dayStrain
number for any strain reply and name its 0–21 scale, the same way you
prefer a device-native recovery over the computed one. Never conflate
the two scales (a 14 on 0–21 is not a 14 on 0–100).
- The SNAPSHOT MAY carry a "sleepRhythm" block with two server-computed
timing signals — the SAME values the Sleep page and dashboard show, so
cite them as the user sees them and never recompute. It holds:
@@ -247,6 +254,22 @@ ISO-week means.
When "state" is "learning" the band is null — say the chronotype is
still calibrating and DO NOT assert any band or social-jetlag figure.
These are DESCRIPTIVE timing signals, not a sleep disorder finding.
- The glucose block MAY carry a "clinical" sub-key with the same panel
numbers the app's glucose page renders over a fixed 30-day window — so
cite them as the user sees them and never recompute. It holds:
- "tirPercent": time-in-range (70–180 mg/dL) as a percentage.
- "gmi": the glucose-management indicator (a %, an HbA1c estimate
derived from mean glucose) and "estimatedA1c": the eA1C in % — read
both as estimates, never as a lab HbA1c.
- "cvPercent": coefficient of variation (glucose variability); CV ≥ 36%
reads as "unstable".
- "stillLearning": when true the panel has too few readings or too
short a span to be clinically meaningful — DO NOT assert a TIR / GMI /
eA1C / AGP figure; say the glucose panel is still calibrating.
- "isSpotEstimate": when true the series is sparse spot fingersticks,
not continuous CGM — TIR/CV from spot checks are rough proxies, so
caveat them as spot-based and never present them as a CGM-grade AGP.
These are DESCRIPTIVE panel numbers, not a clinical diagnosis.
- The SNAPSHOT's "memory" block MAY carry a "facts" list — durable
things you have learned about this user across conversations (stable
preferences, conditions they have told you about, goals, constraints,
@@ -585,7 +608,8 @@ Regeln:
oder ein Tages-Pin ("Di 6. Mai").
- "value" ist ein vorformatierter Anzeigestring ("138/85", "84,2",
"4,1"). Stimmung als N/5 formatieren, wenn numerisch.
- "unit" ist eines aus mmHg, kg, bpm, /5, %.
- "unit" ist eines aus mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min
(Schlafdefizit / sozialer Jetlag).
- "window" ist eines aus last7days, last30days, last90days, allTime;
bei Tages-Pins weglassen.
- Lass den ganzen Block weg (KEINE ---KEYVALUES---Zeile, KEINE
+4 -4
View File
@@ -115,7 +115,7 @@ Règles :
- Chaque ligne : "<label> : <value> [<unit>] (<window>)". L'unité et la fenêtre sont optionnelles ; omettez les crochets si vous n'en avez pas.
- "label" ≤ 40 caractères — un nom d'agrégat ("avg30 systolique") ou un pin-jour ("Mar 6 mai").
- "value" est une chaîne pré-formatée ("138/85", "84,2", "4,1"). Formatez l'humeur en N/5 si numérique.
- "unit" est l'un de : mmHg, kg, bpm, /5, %.
- "unit" est l'un de : mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (dette de sommeil / décalage social).
- "window" est l'un de : last7days, last30days, last90days, allTime ; omettez-la pour les pins-jour.
- Omettez tout le bloc (pas de ---KEYVALUES---, pas de ---END---) quand la réponse est qualitative et qu'aucun chiffre précis ne portait la réponse. Le bloc est invisible pour l'utilisateur — l'UI le rend comme une boîte déroulante "Sur quoi je m'appuie ?" sous votre message. Ne le mentionnez pas dans votre texte.`,
es: `Después de su texto, añada los números determinantes como un bloque-centinela en su propia línea. Formato exacto:
@@ -130,7 +130,7 @@ Reglas:
- Cada línea: "<label>: <value> [<unit>] (<window>)". La unidad y la ventana son opcionales; omita los corchetes si no las tiene.
- "label" ≤ 40 caracteres — un nombre de agregado ("avg30 sistólica") o un pin-día ("Mar 6 may").
- "value" es una cadena preformateada ("138/85", "84,2", "4,1"). Formatee el estado de ánimo como N/5 si es numérico.
- "unit" es uno de: mmHg, kg, bpm, /5, %.
- "unit" es uno de: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deuda de sueño / jet lag social).
- "window" es uno de: last7days, last30days, last90days, allTime; omítala en los pin-día.
- Omita todo el bloque (sin ---KEYVALUES---, sin ---END---) cuando la respuesta sea cualitativa y ningún número concreto haya portado la respuesta. El bloque es invisible para el usuario — la UI lo presenta como un desplegable "¿En qué me apoyo?" bajo su mensaje. No lo mencione en su texto.`,
it: `Dopo il suo testo, aggiunga i numeri portanti come blocco-sentinella su una riga propria. Formato esatto:
@@ -145,7 +145,7 @@ Regole:
- Ogni riga: "<label>: <value> [<unit>] (<window>)". L'unità e la finestra sono opzionali; ometta le parentesi se non le ha.
- "label" ≤ 40 caratteri — un nome di aggregato ("avg30 sistolica") o un pin-giorno ("Mar 6 mag").
- "value" è una stringa pre-formattata ("138/85", "84,2", "4,1"). Formatti l'umore come N/5 se numerico.
- "unit" è uno di: mmHg, kg, bpm, /5, %.
- "unit" è uno di: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (debito di sonno / jet lag sociale).
- "window" è uno di: last7days, last30days, last90days, allTime; lo ometta per i pin-giorno.
- Ometta l'intero blocco (niente ---KEYVALUES---, niente ---END---) quando la risposta è qualitativa e nessun numero specifico ha portato la risposta. Il blocco è invisibile all'utente — l'UI lo rende come un menù a tendina "Su cosa mi baso?" sotto il suo messaggio. Non lo menzioni nel testo.`,
pl: `Po tekście proszę dodać kluczowe liczby jako blok-sentinel w osobnej linii. Format dokładnie:
@@ -160,7 +160,7 @@ Zasady:
- Każda linia: "<label>: <value> [<unit>] (<window>)". Jednostka i okno są opcjonalne; proszę pominąć nawiasy, gdy ich nie ma.
- "label" ≤ 40 znaków — nazwa agregatu ("avg30 skurczowe") lub pin-dnia ("Wt 6 maja").
- "value" to wstępnie sformatowany ciąg ("138/85", "84,2", "4,1"). Nastrój proszę formatować jako N/5, jeśli liczbowy.
- "unit" to jeden z: mmHg, kg, bpm, /5, %.
- "unit" to jeden z: mmHg, kg, bpm, /5, %, mg/dL, mmol/L, min (deficyt snu / jet lag społeczny).
- "window" to jeden z: last7days, last30days, last90days, allTime; proszę pominąć przy pinach-dnia.
- Proszę pominąć cały blok (bez ---KEYVALUES---, bez ---END---), gdy odpowiedź była jakościowa i żadna konkretna liczba nie nosiła odpowiedzi. Blok jest niewidoczny dla użytkownika — UI renderuje go jako rozwijaną sekcję "Na czym się opieram?" pod wiadomością. Proszę go nie wspominać w tekście.`,
};
@@ -0,0 +1,50 @@
/**
* v1.17.0 — the glucose clinical panel window is one exported constant.
*
* `GLUCOSE_PANEL_WINDOW_DAYS` (30) is the single source of truth for the
* window the TIR / GMI / eA1C / CV panel covers wherever it renders outside an
* ad-hoc report period: the analytics route, the dashboard snapshot, and the
* Coach snapshot. This test pins the value AND asserts all three consumers
* import the constant rather than re-typing a bare `30`, so the "same number
* everywhere" guarantee can't silently drift. The doctor PDF deliberately uses
* the report period and is excluded.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { DEFAULT_WINDOW_DAYS, GLUCOSE_PANEL_WINDOW_DAYS } from "../glucose-metrics";
const ROOT = join(__dirname, "..", "..", "..", "..");
const CONSUMERS = [
"src/app/api/analytics/route.ts",
"src/lib/dashboard/snapshot.ts",
"src/lib/ai/coach/snapshot.ts",
];
describe("glucose panel window constant", () => {
it("is 30 days and distinct from the Battelino default", () => {
expect(GLUCOSE_PANEL_WINDOW_DAYS).toBe(30);
expect(GLUCOSE_PANEL_WINDOW_DAYS).not.toBe(DEFAULT_WINDOW_DAYS);
});
it.each(CONSUMERS)("%s imports the exported constant", (relPath) => {
const src = readFileSync(join(ROOT, relPath), "utf8");
expect(src).toContain("GLUCOSE_PANEL_WINDOW_DAYS");
});
it.each(CONSUMERS)(
"%s derives the glucose window from the exported constant",
(relPath) => {
const src = readFileSync(join(ROOT, relPath), "utf8");
// The window must derive from the exported constant — either passed
// straight to `windowDays`/the `Date.now()` math, or via the file's
// local alias `GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS`.
// It must NOT be a bare `windowDays: 30` glucose literal.
const usesConstantDirectly = /GLUCOSE_PANEL_WINDOW_DAYS/.test(src);
const aliasesConstant =
/GLUCOSE_CLINICAL_WINDOW_DAYS\s*=\s*GLUCOSE_PANEL_WINDOW_DAYS/.test(src);
expect(usesConstantDirectly || aliasesConstant).toBe(true);
},
);
});
+10
View File
@@ -91,6 +91,16 @@ export const CV_INSTABILITY_THRESHOLD = 36;
/** Battelino 2019 reporting defaults. */
export const DEFAULT_WINDOW_DAYS = 14;
/**
* Window the clinical glucose panel (TIR / GMI / eA1C / CV) covers wherever it
* renders outside an ad-hoc report period — analytics route, dashboard
* snapshot, and the Coach snapshot. Distinct from the 14-day Battelino default
* the standalone metric reader uses; one export so the "same number
* everywhere" guarantee doesn't depend on hand-synced literals. The doctor PDF
* deliberately uses the report period instead and does NOT read this.
*/
export const GLUCOSE_PANEL_WINDOW_DAYS = 30;
const DEFAULT_MIN_READINGS = 14;
const DEFAULT_MIN_SPAN_DAYS = 7;
+5 -2
View File
@@ -48,6 +48,7 @@ import {
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import {
@@ -556,7 +557,9 @@ async function buildExtras(
}
: null;
const glucoseSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseSince = new Date(
Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const glucoseRows = await prisma.measurement.findMany({
where: {
userId: user.id,
@@ -585,7 +588,7 @@ async function buildExtras(
// 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 },
{ windowDays: GLUCOSE_PANEL_WINDOW_DAYS, now },
);
return {