From 9a2b1f0955cfc72cbaef06222a7912d8543b6bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:13 +0200 Subject: [PATCH 1/5] feat(glucose): name the bucket for readings with no meal-time context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `glucose_context` is nullable, but every surface that fanned out over the four enum members treated the named list as exhaustive. A meter synced through Apple Health writes no HealthKit meal-time metadata at all, so an account can hold nothing but rows the list cannot match. Add one shared notion of the untagged bucket — the name the Coach's glucose block already used — with a resolver that files NULL, blank and unrecognised values under it, and a grouper that walks the named contexts first and the untagged bucket last. The untagged bucket borrows the RANDOM threshold: an untagged reading is a spot reading, and a fifth threshold would give the user a second editor row judging the same readings by a different number. Labels in all seven locales. Refs #943 --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/pl.json | 3 + src/lib/__tests__/glucose.test.ts | 49 +++++++++++++++++ src/lib/glucose.ts | 91 +++++++++++++++++++++++++++++-- 9 files changed, 157 insertions(+), 4 deletions(-) diff --git a/messages/de.json b/messages/de.json index eef3089fe..9a3d0cb6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index e75e342a8..ae324a483 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..3fb6c1d8c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..08a2b848f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index e17e31091..bb61e24ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..37af1bca3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "정상", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..da7723c8b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index db296dccd..a684ba896 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index 14d2dee5b..4b4e69a61 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -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( + rows: readonly T[], + contextOf: (row: T) => string | null | undefined, +): Array<[GlucoseContextBucket, T[]]> { + const byBucket = new Map(); + 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"; } } From 5ab7a2ca81fc1d522cb1309e79e1156035c99377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:21 +0200 Subject: [PATCH 2/5] fix(dashboard): paint the blood-glucose tile for untagged readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tile eligibility filtered the four named contexts against the per-context summaries and asked whether any of them held readings. An account whose source records no meal time matched none of them, so the tile was omitted with the module on, the layout toggle on, and a reading from today. Nothing warned; the strip was simply short one card. Move the gate out of the page into `resolveGlucoseTiles`, which walks the shared bucket list — untagged included — and hand the caller the label key with the summary. Both summary producers group through the shared resolver, so `UNSPECIFIED` reaches the wire with its own count, latest value and trend, and a tagged account still gets its per-context breakdown. Refs #943 --- docs/api/openapi.yaml | 6 +- src/app/api/analytics/route.ts | 19 ++--- src/app/page-client.tsx | 33 +++----- .../__tests__/dashboard-gates-glucose.test.ts | 79 +++++++++++++++++++ src/components/dashboard/dashboard-gates.ts | 47 +++++++++++ src/lib/dashboard/snapshot.ts | 25 +++--- src/lib/openapi/routes/analytics.ts | 2 +- src/lib/openapi/routes/insights/schemas.ts | 2 +- 8 files changed, 162 insertions(+), 51 deletions(-) create mode 100644 src/components/dashboard/__tests__/dashboard-gates-glucose.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 72915fbc3..b165aa8c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -49994,9 +49994,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" diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 351181531..02056525c 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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> = {}; - 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 / diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 182c42526..4ea9f0e11 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -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 = { - 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: ( = {}): 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/); + } + }); +}); diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index e583da023..80577cffa 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -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 = { + 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 | 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; +} diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 9e403895d..da3f1f631 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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 = {}; - 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 diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index 0dd258dfb..f51360fee 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -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() diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 6e76f57ee..2fa522b70 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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() From 971c922a143af3d264a6077789a89c7f27c1ca73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:32 +0200 Subject: [PATCH 3/5] fix(targets): give untagged glucose readings a target card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildGlucoseTargets` had the same gap as the dashboard tile: it iterated the four named contexts, so an account whose readings carry no meal time got no glucose target at all. Route the loop through the shared grouper. The untagged card is judged against the RANDOM band and is not editable — one threshold, one editor row — so the cog renders disabled the way it does for a derived card. The insights reference panel lists the new target type, otherwise such an account sees no band on the blood-glucose sub-page. Refs #943 --- .../insights/metric-target-summary.tsx | 4 ++ src/components/targets/target-edit-sheet.tsx | 4 ++ src/lib/targets/__tests__/builders.test.ts | 62 +++++++++++++++++++ src/lib/targets/glucose-builder.ts | 28 ++++----- src/lib/targets/glucose-targets.ts | 17 ++++- 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 81fdc0277..cfdaa0be4 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -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 { diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index c69acb2ce..d3a788d42 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -69,6 +69,10 @@ const TARGET_TYPE_TO_METRIC: Record = { // 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 { diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index efb34f1ff..8362cda40 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -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([ diff --git a/src/lib/targets/glucose-builder.ts b/src/lib/targets/glucose-builder.ts index 01fab7c6f..cea599c92 100644 --- a/src/lib/targets/glucose-builder.ts +++ b/src/lib/targets/glucose-builder.ts @@ -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 = { +const LABEL_BY_CONTEXT: Record = { 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 = diff --git a/src/lib/targets/glucose-targets.ts b/src/lib/targets/glucose-targets.ts index 4653c1e7b..a0ffec64b 100644 --- a/src/lib/targets/glucose-targets.ts +++ b/src/lib/targets/glucose-targets.ts @@ -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. */ From 126383b35280c2e1f4c4ee6949897e682a4aa241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:32 +0200 Subject: [PATCH 4/5] fix(doctor-report): keep untagged glucose readings in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both report paths dropped them: the raw path iterated the named contexts, and the dense path skipped any row without a context outright, so a report built for a meter that records no meal time carried an empty glucose panel while the readings sat in the database. The FHIR export dropped them for the third time, at the LOINC lookup — its own comment already said an unspecified reading codes as 2339-0, but the table had no entry to hit. The insights context breakdown names the bucket, and the Coach's glucose block now resolves through the shared helper rather than its own copy. Refs #943 --- .../glucose/glucose-advanced-disclosure.tsx | 2 ++ .../ai/coach/snapshot-blocks/glucose-block.ts | 17 +++++++--- .../measurements-charts-section.ts | 10 +++--- src/lib/doctor-report/collect.ts | 33 +++++++++++-------- src/lib/doctor-report/measurement-series.ts | 8 +++-- src/lib/fhir/loinc-map.ts | 4 +++ 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 1e3ab6165..7fbe90613 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record = { "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 { diff --git a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts index 7f0e855e1..cd8ac806f 100644 --- a/src/lib/ai/coach/snapshot-blocks/glucose-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/glucose-block.ts @@ -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): 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); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index c13cfddca..fef72fd68 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -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 = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", + ...GLUCOSE_CONTEXT_BUCKETS, ]; /** A single charted reading. */ diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 42ce9e3dc..e932224c8 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -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] = { diff --git a/src/lib/doctor-report/measurement-series.ts b/src/lib/doctor-report/measurement-series.ts index 5fdb12474..f6d6e1d07 100644 --- a/src/lib/doctor-report/measurement-series.ts +++ b/src/lib/doctor-report/measurement-series.ts @@ -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, diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 814ca0558..4920d04fa 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record = }, 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) ─────────────────────────── From ad62ab6f2794b2bac3afc6c94ce9f67096854905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:38 +0200 Subject: [PATCH 5/5] test: guard tile eligibility against a missing null arm The defect was invisible from the outside because every behavioural test in the suite seeded a tagged reading. Two checks close that: A structural guard that reads the dashboard surfaces and fails when a tile derives presence from a list of enum buckets with no null / unknown arm and no written reason, following one hop through a named constant. It carries its own positive and negative controls, so a refactor that silently stops matching anything fails here rather than going quiet. An integration test that seeds twenty readings with a genuinely NULL context against a real Postgres and reads the tile back through the same resolver the page renders with: eligible, latest value intact, and a tagged reading still filed under its own context. Refs #943 --- ...oard-tile-eligibility-bucket-guard.test.ts | 197 ++++++++++++++++++ .../dashboard-glucose-untagged.test.ts | 165 +++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts create mode 100644 tests/integration/dashboard-glucose-untagged.test.ts diff --git a/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts new file mode 100644 index 000000000..2fc4e3a07 --- /dev/null +++ b/src/__tests__/dashboard-tile-eligibility-bucket-guard.test.ts @@ -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([]); + }); +}); diff --git a/tests/integration/dashboard-glucose-untagged.test.ts b/tests/integration/dashboard-glucose-untagged.test.ts new file mode 100644 index 000000000..810696cac --- /dev/null +++ b/tests/integration/dashboard-glucose-untagged.test.ts @@ -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 | 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 { + const { GET } = await import("@/app/api/analytics/route"); + const response = await ( + GET as unknown as (req: Request) => Promise + )(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", + ]); + }); +});