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] 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. */