Merge the untagged glucose bucket across every consumer (#943)

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:06:58 +02:00
commit 3bd0664edf
30 files changed
+828 -97

No files matched your search

+3 -3
View File
@@ -49997,9 +49997,9 @@ components:
additionalProperties:
$ref: "#/components/schemas/MetricDataSummary"
- type: "null"
description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days,
canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is
off.
description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings
whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no
readings are omitted; the whole block is null when the glucose module is off.
glucoseClinical:
anyOf:
- $ref: "#/components/schemas/GlucoseClinicalMetrics"
+3
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glukose (postprandial)",
"typeGlucoseRandom": "Glukose (beliebig)",
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
"typeGlucoseUnspecified": "Glukose (ohne Angabe)",
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "im Ziel",
@@ -3438,6 +3439,7 @@
"postprandial": "Nach dem Essen",
"random": "Beliebig",
"bedtime": "Vor dem Schlafen",
"unspecified": "Ohne Angabe",
"readingsShort": "{count} Messungen",
"readingsShortOne": "1 Messung"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glukose — postprandial",
"glucoseRandom": "Glukose — beliebig",
"glucoseBedtime": "Glukose — vor dem Schlafen",
"glucoseUnspecified": "Glukose — ohne Angabe",
"status": {
"underweight": "Untergewicht",
"normal": "Normal",
+3
View File
@@ -244,6 +244,7 @@
"typeGlucosePostprandial": "Glucose (postprandial)",
"typeGlucoseRandom": "Glucose (random)",
"typeGlucoseBedtime": "Glucose (bedtime)",
"typeGlucoseUnspecified": "Glucose (untagged)",
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in target",
@@ -3438,6 +3439,7 @@
"postprandial": "After meals",
"random": "Random",
"bedtime": "Bedtime",
"unspecified": "Untagged",
"readingsShort": "{count} readings",
"readingsShortOne": "1 reading"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glucose — post-meal",
"glucoseRandom": "Glucose — random",
"glucoseBedtime": "Glucose — bedtime",
"glucoseUnspecified": "Glucose — untagged",
"status": {
"underweight": "Underweight",
"normal": "Normal",
+3
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glucosa (posprandial)",
"typeGlucoseRandom": "Glucosa (aleatoria)",
"typeGlucoseBedtime": "Glucosa (antes de dormir)",
"typeGlucoseUnspecified": "Glucosa (sin indicar)",
"glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)",
"glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "en objetivo",
@@ -3438,6 +3439,7 @@
"postprandial": "Después de comer",
"random": "Aleatorio",
"bedtime": "Antes de dormir",
"unspecified": "Sin indicar",
"readingsShort": "{count} mediciones",
"readingsShortOne": "1 medición"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glucemia — posprandial",
"glucoseRandom": "Glucemia — cualquiera",
"glucoseBedtime": "Glucemia — antes de dormir",
"glucoseUnspecified": "Glucemia — sin indicar",
"status": {
"underweight": "Peso insuficiente",
"normal": "Normal",
+3
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glycémie (postprandiale)",
"typeGlucoseRandom": "Glycémie (indifférente)",
"typeGlucoseBedtime": "Glycémie (avant le coucher)",
"typeGlucoseUnspecified": "Glycémie (non précisée)",
"glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)",
"glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "dans la cible",
@@ -3438,6 +3439,7 @@
"postprandial": "Après les repas",
"random": "Aléatoire",
"bedtime": "Au coucher",
"unspecified": "Non précisée",
"readingsShort": "{count} mesures",
"readingsShortOne": "1 mesure"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glycémie — postprandiale",
"glucoseRandom": "Glycémie — indifférente",
"glucoseBedtime": "Glycémie — avant le coucher",
"glucoseUnspecified": "Glycémie — non précisée",
"status": {
"underweight": "Insuffisance pondérale",
"normal": "Normal",
+3
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glicemia (postprandiale)",
"typeGlucoseRandom": "Glicemia (qualsiasi)",
"typeGlucoseBedtime": "Glicemia (prima di coricarsi)",
"typeGlucoseUnspecified": "Glicemia (non indicata)",
"glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)",
"glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in obiettivo",
@@ -3438,6 +3439,7 @@
"postprandial": "Dopo i pasti",
"random": "Casuale",
"bedtime": "Prima di dormire",
"unspecified": "Non indicata",
"readingsShort": "{count} misurazioni",
"readingsShortOne": "1 misurazione"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glicemia — postprandiale",
"glucoseRandom": "Glicemia — qualsiasi",
"glucoseBedtime": "Glicemia — prima di coricarsi",
"glucoseUnspecified": "Glicemia — non indicata",
"status": {
"underweight": "Sottopeso",
"normal": "Normal",
+3
View File
@@ -244,6 +244,7 @@
"typeGlucosePostprandial": "혈당 (식후)",
"typeGlucoseRandom": "혈당 (임의)",
"typeGlucoseBedtime": "혈당 (취침 전)",
"typeGlucoseUnspecified": "혈당 (미지정)",
"glucoseClassificationTitle": "혈당 — 참고 범위 (ADA 2024 / DDG)",
"glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "목표 범위 내",
@@ -3438,6 +3439,7 @@
"postprandial": "식후",
"random": "임의",
"bedtime": "취침 전",
"unspecified": "미지정",
"readingsShort": "측정값 {count}개",
"readingsShortOne": "측정값 1개"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "혈당 — 식후",
"glucoseRandom": "혈당 — 무작위",
"glucoseBedtime": "혈당 — 취침 전",
"glucoseUnspecified": "혈당 — 미지정",
"status": {
"underweight": "저체중",
"normal": "정상",
+3
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glukoza (po posiłku)",
"typeGlucoseRandom": "Glukoza (dowolna)",
"typeGlucoseBedtime": "Glukoza (przed snem)",
"typeGlucoseUnspecified": "Glukoza (bez oznaczenia)",
"glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)",
"glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "w celu",
@@ -3438,6 +3439,7 @@
"postprandial": "Po posiłku",
"random": "Losowy",
"bedtime": "Przed snem",
"unspecified": "Bez oznaczenia",
"readingsShort": "{count} pomiarów",
"readingsShortOne": "1 pomiar"
}
@@ -7593,6 +7595,7 @@
"glucosePostprandial": "Glikemia — po posiłku",
"glucoseRandom": "Glikemia — dowolna",
"glucoseBedtime": "Glikemia — przed snem",
"glucoseUnspecified": "Glikemia — bez oznaczenia",
"status": {
"underweight": "Niedowaga",
"normal": "Normal",
@@ -0,0 +1,197 @@
/**
* Structural guard — a dashboard tile may not be gated on a closed enum
* whose column is nullable.
*
* The Blood Glucose tile decided whether to paint by filtering the four
* `GlucoseContext` members against a per-context summary record and asking
* whether any of them held readings. Every named context is a legal value of
* the column, so the list read as exhaustive — but `glucose_context` is
* nullable, and a meter synced through Apple Health writes no meal-time
* metadata at all. An account with one hundred percent untagged readings
* therefore matched no bucket, and the tile was omitted with the module on,
* the layout toggle on, and a reading from today. Nothing warned: every
* behavioural test in the suite seeded a tagged reading, so the missing arm
* was invisible from the outside (#943).
*
* What the guard asserts: wherever a dashboard tile surface derives presence
* from a list of enum buckets, the list must carry a null / unknown arm — or
* the call site must write down why it cannot. It matches on the list
* EXPRESSION, following one hop through a named constant, because the fix for
* this class is always the same: the untagged bucket joins the list the
* eligibility loop already walks.
*
* Its limits, written down so a later reader does not over-trust it: it sees
* presence tests spelled with `count`, and a bucket list that is either an
* inline literal or a single named constant. An eligibility gate that counted
* rows some other way, or assembled its bucket list at runtime, would slip
* past. The `detect()` self-controls below exist so a refactor that silently
* stops matching anything fails here rather than going quiet.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* A bucket list that carries one of these tokens has an arm for rows the
* enum cannot name. Matched against the list expression's source text, so a
* shared constant (`GLUCOSE_CONTEXT_BUCKETS = [...NAMED, UNSPECIFIED]`)
* satisfies it through its own name.
*/
const UNTAGGED_ARM = /UNSPECIFIED|UNKNOWN|UNTAGGED|OTHER|NONE/;
/** The written-reason escape hatch, with a non-empty reason after it. */
const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/;
/** Dashboard surfaces that decide whether a tile paints. */
const SURFACES = [
"src/app/page-client.tsx",
...listSourceFiles("src/components/dashboard"),
...listSourceFiles("src/lib/dashboard"),
];
function listSourceFiles(dir: string): string[] {
const abs = join(process.cwd(), dir);
return readdirSync(abs)
.filter((name) => /\.tsx?$/.test(name))
.map((name) => join(dir, name))
.filter((rel) => statSync(join(process.cwd(), rel)).isFile());
}
interface Finding {
/** The bucket-list expression as written at the eligibility site. */
list: string;
/** 1-based line of the eligibility site. */
line: number;
}
/**
* Iteration over a bucket list: `for (const x of LIST)` or
* `LIST.filter(…)` / `.some(…)`. The list is either an inline array literal
* or an identifier.
*/
const ITERATION =
/(?:for\s*\(\s*(?:const|let)\s+[^)]*?\bof\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\))|(?:(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\.\s*(?:filter|some|flatMap)\s*\()/g;
/** A presence test — "does this bucket hold any readings". */
const PRESENCE = /\bcount\b/;
/** How far past the iteration head a presence test still belongs to it. */
const BODY_WINDOW = 500;
/**
* Every eligibility site in `source` whose bucket list has no untagged arm
* and no written reason. `resolve` supplies the initializer text of a named
* constant so the guard can follow one hop out of the file.
*/
export function detect(
source: string,
resolve: (name: string) => string | null,
): Finding[] {
const findings: Finding[] = [];
ITERATION.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ITERATION.exec(source)) !== null) {
const list = match[1] ?? match[2];
if (!list) continue;
const body = source.slice(match.index, match.index + BODY_WINDOW);
if (!PRESENCE.test(body)) continue;
// Only enum-shaped lists are in scope: an inline literal of UPPER_SNAKE
// members, or an UPPER_SNAKE constant name.
const inline = list.startsWith("[");
const members = inline ? list.match(/"[A-Z][A-Z0-9_]*"/g) : null;
if (inline && (members?.length ?? 0) < 2) continue;
if (!inline && !/^[A-Z][A-Z0-9_]*$/.test(list)) continue;
const expression = inline ? list : (resolve(list) ?? list);
if (UNTAGGED_ARM.test(expression)) continue;
const before = source.slice(Math.max(0, match.index - 400), match.index);
if (WRITTEN_REASON.test(before)) continue;
findings.push({
list,
line: source.slice(0, match.index).split("\n").length,
});
}
return findings;
}
/** Initializer text of `export const NAME = …` / `const NAME = …` in `src/`. */
function resolveConstant(name: string): string | null {
for (const file of CONSTANT_SOURCES) {
const source = readFileSync(join(process.cwd(), file), "utf8");
const declaration = new RegExp(
`(?:export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(\\[[\\s\\S]*?\\])`,
).exec(source);
if (declaration) return declaration[1];
}
return null;
}
/** Files a dashboard surface may import a bucket list from. */
const CONSTANT_SOURCES = ["src/lib/glucose.ts", ...SURFACES];
describe("detect() — the matcher itself", () => {
it("flags the shape that shipped: four enum members, no null arm", () => {
const shipped = `
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"];
const present = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].filter(
(ctx) => (byContext[ctx]?.count ?? 0) > 0,
);
`;
const findings = detect(shipped, () => null);
expect(findings).toHaveLength(1);
expect(findings[0].list).toContain("FASTING");
});
it("accepts a list that carries an untagged arm", () => {
const fixed = `
for (const bucket of GLUCOSE_CONTEXT_BUCKETS) {
const summary = byContext?.[bucket];
if (!summary || summary.count <= 0) continue;
}
`;
const resolve = () => '["FASTING", "BEDTIME", GLUCOSE_CONTEXT_UNSPECIFIED]';
expect(detect(fixed, resolve)).toEqual([]);
// …and rejects the same site when the constant has no arm, so the pass
// above is the arm's doing and not the resolver's.
expect(detect(fixed, () => '["FASTING", "BEDTIME"]')).toHaveLength(1);
});
it("accepts a written reason", () => {
const exempt = `
// tile-eligibility-exempt: sleep stages are never null on this path.
for (const stage of ["DEEP", "REM"]) {
if (byStage[stage].count > 0) return true;
}
`;
expect(detect(exempt, () => null)).toEqual([]);
});
it("ignores lists that are not presence tests", () => {
const unrelated = `
const labels = ["WEIGHT", "PULSE"].map((type) => LABELS[type]);
`;
expect(detect(unrelated, () => null)).toEqual([]);
});
});
describe("dashboard tile eligibility", () => {
it("scans the surfaces it claims to scan", () => {
// An empty match set must fail rather than read as clean: the guard is
// worthless the day its matchers stop finding the real call sites.
expect(SURFACES.length).toBeGreaterThan(5);
const sited = SURFACES.filter((file) =>
PRESENCE.test(readFileSync(join(process.cwd(), file), "utf8")),
);
expect(sited.length).toBeGreaterThan(0);
});
it("computes no tile's eligibility from an enum without a null arm", () => {
const offenders: string[] = [];
for (const file of SURFACES) {
const source = readFileSync(join(process.cwd(), file), "utf8");
for (const finding of detect(source, resolveConstant)) {
offenders.push(`${file}:${finding.line} — ${finding.list}`);
}
}
expect(offenders).toEqual([]);
});
});
+10 -9
View File
@@ -34,6 +34,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import {
decisionForEvidence,
PATTERN_FAMILIES,
@@ -495,16 +496,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
// #943 — grouped through the shared bucket resolver, so readings with no
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
if (glucoseRows.length > 0) {
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
for (const ctx of contexts) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
glucoseByContext[ctx] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
for (const [bucket, ctxRows] of groupByGlucoseContext(
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
+11 -22
View File
@@ -174,6 +174,7 @@ import {
resolveConfiguredTileCount,
resolveChartRowPlaceholderCount,
pickHrvSummary,
resolveGlucoseTiles,
} from "@/components/dashboard/dashboard-gates";
/**
@@ -725,23 +726,12 @@ export default function DashboardPageClient({
const glucoseWidgetVisible = isTileVisible("glucose");
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const glucoseByContext = data?.glucoseByContext ?? {};
const glucoseContexts = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
const glucoseSummariesPresent = glucoseContexts.filter(
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
);
const showGlucoseCards =
glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
const glucoseLabelKey: Record<string, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
};
// #943 — eligibility comes from `resolveGlucoseTiles`, which walks the
// untagged bucket alongside the four named contexts. The gate used to be
// an inline filter over the named contexts only, so an account whose
// source never writes a meal-time tag saw nothing.
const glucoseTiles = resolveGlucoseTiles(glucoseByContext);
const showGlucoseCards = glucoseWidgetVisible && glucoseTiles.length > 0;
// v1.18.6 — band / target math is computed SERVER-side in the snapshot
// DTO (`targetBands`) so the client stops recomputing it from the
// profile (audit finding #3). When snapshot mode is on, read the
@@ -1634,16 +1624,15 @@ export default function DashboardPageClient({
}
if (showGlucoseCards) {
const glucoseOrder = widgetOrder("glucose");
glucoseSummariesPresent.forEach((ctx, idx) => {
const s = glucoseByContext[ctx];
glucoseTiles.forEach(({ bucket, labelKey, summary: s }, idx) => {
trendCards.push({
id: `glucose-${ctx}`,
id: `glucose-${bucket}`,
// sub-order so all glucose cards stay in a block and order stable
order: glucoseOrder + idx / 1000,
node: (
<TrendCard
key={`glucose-${ctx}`}
label={t(glucoseLabelKey[ctx])}
key={`glucose-${bucket}`}
label={t(labelKey)}
latest={
s.latest != null
? convertGlucose(s.latest, displayGlucoseUnit)
@@ -0,0 +1,79 @@
/**
* #943 — the Blood Glucose tile must not depend on a meal-time tag.
*
* A reporter's meter syncs through Apple Health and never writes HealthKit's
* blood-glucose meal-time metadata, so all 358 of their readings carry an
* empty `glucose_context`. The tile strip fanned out over the four named
* contexts only, found none of them populated, and omitted the tile — with
* the glucose module on, the layout toggle on, and a reading from today.
*
* These cases pin the untagged bucket: it counts toward eligibility and
* carries the latest value and the trend, while a tagged account still gets
* its per-context breakdown.
*/
import { describe, expect, it } from "vitest";
import { resolveGlucoseTiles } from "../dashboard-gates";
import type { DataSummary } from "@/lib/analytics/trends";
function summary(overrides: Partial<DataSummary> = {}): DataSummary {
return {
count: 12,
latest: 118,
min: 92,
max: 149,
mean: 117,
median: 116,
avg7: 119,
avg30: 117,
slope7: null,
slope30: { slope: 0.4, direction: "up", confidence: 0.6 },
...overrides,
};
}
describe("resolveGlucoseTiles (#943)", () => {
it("is eligible when every reading is untagged", () => {
const tiles = resolveGlucoseTiles({
UNSPECIFIED: summary({ count: 358, latest: 104 }),
});
expect(tiles).toHaveLength(1);
expect(tiles[0].bucket).toBe("UNSPECIFIED");
expect(tiles[0].summary.latest).toBe(104);
expect(tiles[0].summary.slope30).not.toBeNull();
});
it("keeps the named breakdown and appends the untagged bucket last", () => {
const tiles = resolveGlucoseTiles({
FASTING: summary({ count: 5 }),
BEDTIME: summary({ count: 3 }),
UNSPECIFIED: summary({ count: 9 }),
});
expect(tiles.map((tile) => tile.bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
});
it("stays ineligible when nothing is logged", () => {
expect(resolveGlucoseTiles(undefined)).toEqual([]);
expect(resolveGlucoseTiles({})).toEqual([]);
expect(resolveGlucoseTiles({ UNSPECIFIED: summary({ count: 0 }) })).toEqual(
[],
);
});
it("names every bucket it emits", () => {
const tiles = resolveGlucoseTiles({
RANDOM: summary(),
UNSPECIFIED: summary(),
});
for (const tile of tiles) {
expect(tile.labelKey).toMatch(/^targets\.glucose/);
}
});
});
@@ -7,6 +7,10 @@
* `../page` test imports stay valid.
*/
import type { DataSummary } from "@/lib/analytics/trends";
import {
GLUCOSE_CONTEXT_BUCKETS,
type GlucoseContextBucket,
} from "@/lib/glucose";
import type { DashboardLayout } from "@/lib/dashboard-layout";
/**
@@ -207,3 +211,46 @@ export function pickHrvSummary(
// no HRV at all look like a ring user.
return pick(primary ?? null, "HEART_RATE_VARIABILITY");
}
/**
* The i18n key naming each glucose bucket on the tile strip. The untagged
* bucket is named, not blank: a strip of cards where one carries no label
* reads as a rendering fault, and the user needs to know the readings behind
* it are the ones their meter never tagged.
*/
export const GLUCOSE_TILE_LABEL_KEY: Record<GlucoseContextBucket, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
UNSPECIFIED: "targets.glucoseUnspecified",
};
/** One resolved glucose strip tile. */
export interface GlucoseTile {
bucket: GlucoseContextBucket;
labelKey: string;
summary: DataSummary;
}
/**
* Which glucose tiles the strip paints, in the canonical bucket order. An
* empty result means the tile is not eligible and the whole glucose block is
* omitted.
*
* #943 — the untagged bucket is in the loop, not outside it. Gating on the
* four named contexts alone hid the tile from every account whose source
* writes no meal-time tag, with no warning anywhere: the module was on, the
* layout toggle was on, the readings were current, and the strip was empty.
*/
export function resolveGlucoseTiles(
byContext: Record<string, DataSummary> | undefined,
): GlucoseTile[] {
const tiles: GlucoseTile[] = [];
for (const bucket of GLUCOSE_CONTEXT_BUCKETS) {
const summary = byContext?.[bucket];
if (!summary || summary.count <= 0) continue;
tiles.push({ bucket, labelKey: GLUCOSE_TILE_LABEL_KEY[bucket], summary });
}
return tiles;
}
@@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record<string, string> = {
"insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
// #943 — readings whose source recorded no meal-time context.
UNSPECIFIED: "insights.bloodGlucose.clinical.advanced.byContext.unspecified",
};
export interface GlucoseAdvancedDisclosureProps {
@@ -119,6 +119,10 @@ const GLUCOSE_TARGET_TYPES = [
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
// #943 — readings whose source recorded no meal-time context. The route
// emits this card whenever such rows exist, so the panel must list it or
// an account with only untagged readings gets no reference band at all.
"BLOOD_GLUCOSE_UNSPECIFIED",
];
interface MetricTargetSummaryProps {
@@ -69,6 +69,10 @@ const TARGET_TYPE_TO_METRIC: Record<string, ThresholdMetric | null> = {
// BLOOD_PRESSURE handled specially with both sys + dia.
// BMI / MOOD_SCORE / MOOD_STABILITY / MEDICATION_COMPLIANCE /
// BLOOD_PRESSURE_IN_TARGET — derived, no editable threshold.
// BLOOD_GLUCOSE_UNSPECIFIED (#943) — deliberately absent. The untagged
// bucket is judged against the RANDOM band; giving it its own editor row
// would offer two controls that write one threshold, so the cog renders
// disabled the same way it does for a derived card.
};
interface CurrentRange {
+49
View File
@@ -6,6 +6,9 @@ import {
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
glucoseContextBucket,
groupByGlucoseContext,
GLUCOSE_CONTEXT_UNSPECIFIED,
} from "../glucose";
describe("glucose conversion", () => {
@@ -108,3 +111,49 @@ describe("glucose conversion", () => {
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
describe("glucose context buckets (#943)", () => {
it("files NULL, blank and unknown values under the untagged bucket", () => {
expect(glucoseContextBucket(null)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(undefined)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(" ")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("BEFORE_MEAL")).toBe(
GLUCOSE_CONTEXT_UNSPECIFIED,
);
});
it("keeps the named contexts named, case- and whitespace-tolerant", () => {
expect(glucoseContextBucket("FASTING")).toBe("FASTING");
expect(glucoseContextBucket(" bedtime ")).toBe("BEDTIME");
});
it("groups in canonical order with the untagged bucket last", () => {
const rows = [
{ ctx: null, v: 1 },
{ ctx: "BEDTIME", v: 2 },
{ ctx: "FASTING", v: 3 },
{ ctx: "", v: 4 },
];
const grouped = groupByGlucoseContext(rows, (row) => row.ctx);
expect(grouped.map(([bucket]) => bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
// Input order survives inside a bucket, so a caller reading "latest" off
// the head keeps reading the same row.
expect(grouped[2][1].map((row) => row.v)).toEqual([1, 4]);
});
it("returns nothing for no rows", () => {
expect(groupByGlucoseContext([], () => null)).toEqual([]);
});
it("judges the untagged bucket against the RANDOM threshold", () => {
expect(thresholdMetricForContext(GLUCOSE_CONTEXT_UNSPECIFIED)).toBe(
"BLOOD_GLUCOSE_RANDOM",
);
});
});
@@ -12,7 +12,11 @@
* read plus the shared accumulators, so the emitted shape and ordering
* are unchanged.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import {
convertGlucose,
glucoseContextBucket,
type GlucoseUnit,
} from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -80,15 +84,18 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
meta: { cluster: "glucose", source: "glucose" },
});
} else {
// Group by context (NULL → "unspecified"), then per-day mean.
// Group by context (NULL → "unspecified"), then per-day mean. The bucket
// resolver is the shared one (#943) — this block named the untagged
// bucket first, and the rest of the tree now resolves through the same
// helper rather than repeating the NULL arm at each surface.
const byContext = new Map<
string,
Array<{ measuredAt: Date; value: number }>
>();
for (const r of glucoseRows) {
const ctxKey = r.glucoseContext
? String(r.glucoseContext).toLowerCase()
: "unspecified";
const ctxKey = glucoseContextBucket(
typeof r.glucoseContext === "string" ? r.glucoseContext : null,
).toLowerCase();
const list = byContext.get(ctxKey) ?? [];
list.push({ measuredAt: r.measuredAt, value: r.value });
byContext.set(ctxKey, list);
+10 -15
View File
@@ -49,6 +49,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -134,13 +135,6 @@ import {
unavailableWidgetIds,
} from "@/lib/dashboard/widget-modules";
const GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
/**
* v1.7.0 — HealthLog `MeasurementType` → iOS `MetricKind` raw value.
*
@@ -832,15 +826,16 @@ async function buildExtras(
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
// #943 — grouped through the shared bucket resolver, so readings with no
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
const glucoseByContext: Record<string, DataSummary> = {};
if (glucoseRows.length > 0) {
for (const ctx of GLUCOSE_CONTEXTS) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
glucoseByContext[ctx] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
for (const [bucket, ctxRows] of groupByGlucoseContext(
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
// v1.17.0 — clinical panel from the SAME 30-day glucose rows already read
@@ -1,4 +1,5 @@
import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -20,6 +21,8 @@ const GLUCOSE_LABEL_KEYS = {
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
RANDOM: "doctorReport.typeGlucoseRandom",
BEDTIME: "doctorReport.typeGlucoseBedtime",
// #943 — readings the source never tagged with a meal time.
UNSPECIFIED: "doctorReport.typeGlucoseUnspecified",
} as const;
const DEFAULT_GLUCOSE_RANGES = {
@@ -27,13 +30,12 @@ const DEFAULT_GLUCOSE_RANGES = {
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
// Same band as RANDOM: an untagged reading is a spot reading.
UNSPECIFIED: { min: 70, max: 140 },
} as const;
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
...GLUCOSE_CONTEXT_BUCKETS,
];
/** A single charted reading. */
+19 -14
View File
@@ -20,12 +20,14 @@ import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import {
GLUCOSE_CONTEXT_BUCKETS,
groupByGlucoseContext,
resolveGlucoseUnit,
thresholdMetricForContext,
} from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementType,
} from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -70,13 +72,6 @@ import {
loadLabResults,
} from "./clinical-records";
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/** No medication row is read unless at least one medication leaf is admitted. */
@@ -413,9 +408,19 @@ export async function collectDoctorReportData(
gender: userProfile?.gender ?? null,
};
if (glucosePanelOn) {
for (const ctx of GLUCOSE_CONTEXTS) {
// #943 — the untagged bucket is a bucket. A report built from a meter
// that records no meal-time context used to carry an empty glucose panel.
const groupedGlucose = groupByGlucoseContext(
glucoseRows,
(m) => m.glucoseContext,
);
const glucoseBuckets = denseGlucose
? GLUCOSE_CONTEXT_BUCKETS
: groupedGlucose.map(([bucket]) => bucket);
for (const ctx of glucoseBuckets) {
if (!denseGlucose) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
const rows =
groupedGlucose.find(([bucket]) => bucket === ctx)?.[1] ?? [];
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
+6 -2
View File
@@ -9,6 +9,7 @@
* Split out of the aggregator with the selection rework.
*/
import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
import { glucoseContextBucket } from "@/lib/glucose";
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DenseMeasurementBucket } from "./dense-buckets";
@@ -117,8 +118,11 @@ export function summariseDenseBuckets(
}
for (const row of canonical) {
if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
accumulate(glucoseStatsAcc, row.glucoseContext, {
if (row.type !== "BLOOD_GLUCOSE") continue;
// #943 — a reading with no meal-time context is filed under the shared
// untagged bucket. Skipping it here dropped the whole panel for an
// account whose meter never records one.
accumulate(glucoseStatsAcc, glucoseContextBucket(row.glucoseContext), {
count: row.count,
sum: row.sumValue,
min: row.minValue,
+4
View File
@@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
},
RANDOM: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
// #943 — the untagged bucket. Already named in the table above; without
// the entry the exported bundle silently dropped every reading whose
// source recorded no meal time.
UNSPECIFIED: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
};
/* ── Clinical glucose-panel LOINCs (v1.18.0) ───────────────────────────
+87 -4
View File
@@ -49,11 +49,93 @@ export function resolveGlucoseUnit(
}
/**
* Threshold metric key corresponding to a glucose context. Keeps the
* The `GlucoseContext` enum members, in the order every surface iterates them.
*/
export const NAMED_GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
export type NamedGlucoseContext = (typeof NAMED_GLUCOSE_CONTEXTS)[number];
/**
* The bucket every reading lands in whose `glucoseContext` column is NULL,
* blank, or not one of the four enum members.
*
* It is not a `GlucoseContext` value and never reaches the database — the
* column stays NULL. It exists because a reading without a meal-time tag is
* still a reading, and a surface that iterates only the named contexts drops
* it silently. That is not hypothetical: a meter synced through Apple Health
* writes no HealthKit meal-time metadata at all, so such an account has one
* hundred percent of its readings here and saw an empty dashboard, empty
* targets, and an empty doctor-report panel while the data was current
* (#943). The Coach's glucose block already grouped them under the same
* name — this promotes that one bucket to the shared notion the rest of the
* tree resolves through.
*/
export const GLUCOSE_CONTEXT_UNSPECIFIED = "UNSPECIFIED";
export type GlucoseContextBucket =
NamedGlucoseContext | typeof GLUCOSE_CONTEXT_UNSPECIFIED;
/** Canonical iteration order: the named contexts first, untagged last. */
export const GLUCOSE_CONTEXT_BUCKETS = [
...NAMED_GLUCOSE_CONTEXTS,
GLUCOSE_CONTEXT_UNSPECIFIED,
] as const;
/**
* Resolve a stored `glucose_context` cell to the bucket it belongs to.
* NULL, an empty string, and any value outside the enum all resolve to
* {@link GLUCOSE_CONTEXT_UNSPECIFIED} — the fail-open arm, because dropping
* a reading is worse than filing it under "no meal-time tag".
*/
export function glucoseContextBucket(
raw: string | null | undefined,
): GlucoseContextBucket {
if (raw == null) return GLUCOSE_CONTEXT_UNSPECIFIED;
const key = raw.trim().toUpperCase();
return (NAMED_GLUCOSE_CONTEXTS as readonly string[]).includes(key)
? (key as NamedGlucoseContext)
: GLUCOSE_CONTEXT_UNSPECIFIED;
}
/**
* Group rows into their glucose buckets, in {@link GLUCOSE_CONTEXT_BUCKETS}
* order, skipping buckets with no rows. Row order inside a bucket is the
* caller's input order, so a caller that reads "latest" off the head or tail
* keeps doing so.
*/
export function groupByGlucoseContext<T>(
rows: readonly T[],
contextOf: (row: T) => string | null | undefined,
): Array<[GlucoseContextBucket, T[]]> {
const byBucket = new Map<GlucoseContextBucket, T[]>();
for (const row of rows) {
const bucket = glucoseContextBucket(contextOf(row));
const list = byBucket.get(bucket);
if (list) list.push(row);
else byBucket.set(bucket, [row]);
}
return GLUCOSE_CONTEXT_BUCKETS.flatMap((bucket) => {
const list = byBucket.get(bucket);
return list ? [[bucket, list] as [GlucoseContextBucket, T[]]] : [];
});
}
/**
* Threshold metric key corresponding to a glucose bucket. Keeps the
* effective-range resolver the single source of truth.
*
* The untagged bucket borrows the RANDOM band: a reading with no meal-time
* tag IS a spot reading, which is exactly what RANDOM means, and inventing a
* fifth threshold would give the user a second editor row that judges the
* same readings by a different number.
*/
export function thresholdMetricForContext(
context: "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
context: GlucoseContextBucket,
):
| "BLOOD_GLUCOSE_FASTING"
| "BLOOD_GLUCOSE_POSTPRANDIAL"
@@ -64,9 +146,10 @@ export function thresholdMetricForContext(
return "BLOOD_GLUCOSE_FASTING";
case "POSTPRANDIAL":
return "BLOOD_GLUCOSE_POSTPRANDIAL";
case "RANDOM":
return "BLOOD_GLUCOSE_RANDOM";
case "BEDTIME":
return "BLOOD_GLUCOSE_BEDTIME";
case "RANDOM":
case GLUCOSE_CONTEXT_UNSPECIFIED:
return "BLOOD_GLUCOSE_RANDOM";
}
}
+1 -1
View File
@@ -507,7 +507,7 @@ const analyticsDefaultResponse = z
.record(z.string(), dataSummary)
.nullable()
.describe(
"Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days, canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is off.",
"Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no readings are omitted; the whole block is null when the glucose module is off.",
),
glucoseClinical: glucoseClinicalSchema
.nullable()
+1 -1
View File
@@ -2178,7 +2178,7 @@ export const analyticsDefaultSliceResponse = z
.record(z.string(), dataSummary)
.nullable()
.describe(
"Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`) over the trailing 30 days, canonical mg/dL. A context with no readings is omitted. NULL — not an empty object — when the glucose module is off.",
"Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`, plus `UNSPECIFIED` for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. A bucket with no readings is omitted. NULL — not an empty object — when the glucose module is off.",
),
glucoseClinical: glucoseClinicalSchema
.nullable()
@@ -135,6 +135,68 @@ describe("target section builders", () => {
});
});
it("emits a card for readings with no meal-time context (#943)", () => {
// A meter synced through Apple Health writes no HealthKit meal-time
// metadata, so every row arrives with a null context. Iterating the four
// named contexts alone returned no glucose target at all.
const targets = buildGlucoseTargets({
rows: [
{ value: 112, measuredAt: NOW, glucoseContext: null },
{ value: 104, measuredAt: NOW, glucoseContext: null },
],
profile: {
heightCm: 180,
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
glucoseUnit: "mg/dL",
hasDiabetes: false,
thresholdsJson: null,
},
timezone: TZ,
now: NOW,
});
expect(targets.map((target) => target.type)).toEqual([
"BLOOD_GLUCOSE_UNSPECIFIED",
]);
expect(targets[0]).toMatchObject({
label: "targets.glucoseUnspecified",
current: 112,
average30: 108,
unit: "mg/dL",
});
// The untagged bucket is judged against the RANDOM band, so it still
// carries a range and a classification rather than a blank card.
expect(targets[0].range).not.toBeNull();
expect(targets[0].classification).not.toBeNull();
});
it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => {
const targets = buildGlucoseTargets({
rows: [
{ value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const },
{ value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const },
{ value: 111, measuredAt: NOW, glucoseContext: null },
],
profile: {
heightCm: 180,
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
gender: "MALE",
glucoseUnit: "mg/dL",
hasDiabetes: false,
thresholdsJson: null,
},
timezone: TZ,
now: NOW,
});
expect(targets.map((target) => target.type)).toEqual([
"BLOOD_GLUCOSE_FASTING",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_UNSPECIFIED",
]);
});
it("keeps summary streak ties stable by public target order", () => {
expect(
buildTargetPageSummary([
+14 -14
View File
@@ -1,21 +1,18 @@
import type { GlucoseContext } from "@/generated/prisma/client";
import { resolveGlucoseUnit } from "@/lib/glucose";
import {
groupByGlucoseContext,
resolveGlucoseUnit,
type GlucoseContextBucket,
} from "@/lib/glucose";
import { resolveGlucoseTarget } from "./glucose-targets";
import { makeRangeClassifier, rollupConsistency } from "./consistency";
import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types";
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const LABEL_BY_CONTEXT: Record<GlucoseContext, string> = {
const LABEL_BY_CONTEXT: Record<GlucoseContextBucket, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
UNSPECIFIED: "targets.glucoseUnspecified",
};
interface GlucoseTargetsInput {
@@ -35,10 +32,13 @@ export function buildGlucoseTargets({
const unit = resolveGlucoseUnit(profile.glucoseUnit);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
for (const context of GLUCOSE_CONTEXTS) {
const contextRows = rows.filter((row) => row.glucoseContext === context);
if (contextRows.length === 0) continue;
// #943 — the untagged bucket rides the same loop. Iterating the four named
// contexts alone left an account whose source writes no meal-time tag with
// no glucose target at all, the same gap the dashboard tile had.
for (const [context, contextRows] of groupByGlucoseContext(
rows,
(row) => row.glucoseContext,
)) {
const latest = contextRows[0].value;
const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo);
const average30 =
+14 -3
View File
@@ -40,10 +40,17 @@ import {
type UserProfileForRange,
} from "@/lib/analytics/effective-range";
import type { TrafficRange } from "@/lib/analytics/value-bands";
import { thresholdMetricForContext } from "@/lib/glucose";
import {
thresholdMetricForContext,
type GlucoseContextBucket,
} from "@/lib/glucose";
export type GlucoseContextKey =
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
/**
* The buckets a glucose target can be resolved for — the four named contexts
* plus the untagged bucket (#943), which is judged against the RANDOM band
* because an untagged reading is a spot reading.
*/
export type GlucoseContextKey = GlucoseContextBucket;
/**
* ADA glycemic GOAL bands for people living with diabetes (mg/dL).
@@ -59,6 +66,9 @@ export type GlucoseContextKey =
* higher floor guards against nocturnal hypoglycemia. Kept identical to the
* non-diabetic bedtime band on purpose — the diabetic delta lives in the
* fasting/post-prandial goals, where ADA is explicit.
* UNSPECIFIED: a reading whose source recorded no meal-time context. Same band
* as RANDOM, and for the same reason — an untagged reading is a spot
* reading, and the post-prandial goal is the defensible comparator.
*/
const DIABETIC_GOAL_BANDS: Record<
GlucoseContextKey,
@@ -68,6 +78,7 @@ const DIABETIC_GOAL_BANDS: Record<
POSTPRANDIAL: { min: 80, max: 180 },
RANDOM: { min: 80, max: 180 },
BEDTIME: { min: 90, max: 150 },
UNSPECIFIED: { min: 80, max: 180 },
};
/** Source label surfaced on the resolved range for the targets surface. */
@@ -0,0 +1,165 @@
/**
* #943 — untagged glucose readings light the dashboard tile.
*
* A reporter's meter syncs through Apple Health and never writes HealthKit's
* blood-glucose meal-time metadata, so all of their readings carry a NULL
* `glucose_context`. The tile strip filtered the four named contexts against
* the per-context summaries, matched none, and omitted the tile — module on,
* layout toggle on, a reading from today, and no warning anywhere.
*
* A real Postgres is what makes this convincing: the summaries are built from
* rows the database actually holds, with the column genuinely NULL, and the
* tile gate is the same pure resolver the page renders through.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { resolveGlucoseTiles } from "@/components/dashboard/dashboard-gates";
import type { DataSummary } from "@/lib/analytics/trends";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
interface AnalyticsEnvelope {
data: {
glucoseByContext: Record<string, DataSummary> | null;
};
error: null;
}
const DAY_MS = 24 * 60 * 60 * 1000;
const READINGS = 20;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedUntaggedGlucose() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "glucose-untagged",
email: "glucose-untagged@example.test",
role: "USER",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
// 20 readings, one per day, newest first at 104 mg/dL. Every one of them
// arrives exactly as the reporter's sync writes them: no meal-time context.
const now = Date.now();
await prisma.measurement.createMany({
data: Array.from({ length: READINGS }, (_, i) => ({
userId: user.id,
type: "BLOOD_GLUCOSE" as const,
value: 104 + i,
unit: "mg/dL",
source: "APPLE_HEALTH" as const,
measuredAt: new Date(now - i * DAY_MS),
glucoseContext: null,
})),
});
return user;
}
async function readAnalytics(): Promise<AnalyticsEnvelope> {
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
return (await response.json()) as AnalyticsEnvelope;
}
describe("dashboard glucose tile with untagged readings (#943)", () => {
it("stores the readings with a NULL context", async () => {
const user = await seedUntaggedGlucose();
const rows = await getPrismaClient().measurement.findMany({
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
select: { glucoseContext: true },
});
expect(rows).toHaveLength(READINGS);
expect(rows.every((row) => row.glucoseContext === null)).toBe(true);
});
it("reports the tile as eligible and carries the latest value", async () => {
await seedUntaggedGlucose();
const envelope = await readAnalytics();
const byContext = envelope.data.glucoseByContext;
expect(byContext).not.toBeNull();
// The summaries carry the untagged bucket, and nothing else.
expect(Object.keys(byContext ?? {})).toEqual(["UNSPECIFIED"]);
const summary = byContext!.UNSPECIFIED;
expect(summary.count).toBe(READINGS);
expect(summary.latest).toBe(104);
expect(summary.avg30).not.toBeNull();
// …and the tile gate the dashboard renders through says "paint it".
const tiles = resolveGlucoseTiles(byContext ?? undefined);
expect(tiles).toHaveLength(1);
expect(tiles[0]).toMatchObject({
bucket: "UNSPECIFIED",
labelKey: "targets.glucoseUnspecified",
});
expect(tiles[0].summary.latest).toBe(104);
});
it("keeps a tagged reading in its own bucket beside the untagged ones", async () => {
const user = await seedUntaggedGlucose();
await getPrismaClient().measurement.create({
data: {
userId: user.id,
type: "BLOOD_GLUCOSE",
value: 92,
unit: "mg/dL",
source: "MANUAL",
measuredAt: new Date(),
glucoseContext: "FASTING",
},
});
const envelope = await readAnalytics();
const byContext = envelope.data.glucoseByContext ?? {};
expect(Object.keys(byContext).sort()).toEqual(["FASTING", "UNSPECIFIED"]);
expect(byContext.FASTING.count).toBe(1);
expect(byContext.UNSPECIFIED.count).toBe(READINGS);
expect(resolveGlucoseTiles(byContext).map((tile) => tile.bucket)).toEqual([
"FASTING",
"UNSPECIFIED",
]);
});
});