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

@@ -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;
}