diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2eed38ee6..561aec088 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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" 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/__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/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/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/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/__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/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/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/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) ─────────────────────────── 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"; } } 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() 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. */ 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", + ]); + }); +});