mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
commit
713fa3e926
10 files changed
+269
-21
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user