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