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()