fix(dashboard): paint the blood-glucose tile for untagged readings

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
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 23:03:21 +02:00
1 parent 9a2b1f0955
commit 5ab7a2ca81
8 files changed
+162 -51

No files matched your search

+3 -3
View File
@@ -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"
+10 -9
View File
@@ -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<string, ReturnType<typeof summarize>> = {};
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 /
+11 -22
View File
@@ -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<string, string> = {
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: (
<TrendCard
key={`glucose-${ctx}`}
label={t(glucoseLabelKey[ctx])}
key={`glucose-${bucket}`}
label={t(labelKey)}
latest={
s.latest != null
? convertGlucose(s.latest, displayGlucoseUnit)
@@ -0,0 +1,79 @@
/**
* #943 — the Blood Glucose tile must not depend on a meal-time tag.
*
* A reporter's meter syncs through Apple Health and never writes HealthKit's
* blood-glucose meal-time metadata, so all 358 of their readings carry an
* empty `glucose_context`. The tile strip fanned out over the four named
* contexts only, found none of them populated, and omitted the tile — with
* the glucose module on, the layout toggle on, and a reading from today.
*
* These cases pin the untagged bucket: it counts toward eligibility and
* carries the latest value and the trend, while a tagged account still gets
* its per-context breakdown.
*/
import { describe, expect, it } from "vitest";
import { resolveGlucoseTiles } from "../dashboard-gates";
import type { DataSummary } from "@/lib/analytics/trends";
function summary(overrides: Partial<DataSummary> = {}): 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/);
}
});
});
@@ -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<GlucoseContextBucket, string> = {
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<string, DataSummary> | 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;
}
+10 -15
View File
@@ -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<string, DataSummary> = {};
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
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()