feat(glucose): name the bucket for readings with no meal-time context

`glucose_context` is nullable, but every surface that fanned out over the
four enum members treated the named list as exhaustive. A meter synced
through Apple Health writes no HealthKit meal-time metadata at all, so an
account can hold nothing but rows the list cannot match.

Add one shared notion of the untagged bucket — the name the Coach's glucose
block already used — with a resolver that files NULL, blank and unrecognised
values under it, and a grouper that walks the named contexts first and the
untagged bucket last. The untagged bucket borrows the RANDOM threshold: an
untagged reading is a spot reading, and a fifth threshold would give the user
a second editor row judging the same readings by a different number.

Labels in all seven locales.

Refs #943
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 23:03:13 +02:00
1 parent 1d372df3a0
commit 9a2b1f0955
9 files changed
+157 -4

No files matched your search

+49
View File
@@ -6,6 +6,9 @@ import {
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
glucoseContextBucket,
groupByGlucoseContext,
GLUCOSE_CONTEXT_UNSPECIFIED,
} from "../glucose";
describe("glucose conversion", () => {
@@ -108,3 +111,49 @@ describe("glucose conversion", () => {
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
describe("glucose context buckets (#943)", () => {
it("files NULL, blank and unknown values under the untagged bucket", () => {
expect(glucoseContextBucket(null)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(undefined)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(" ")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("BEFORE_MEAL")).toBe(
GLUCOSE_CONTEXT_UNSPECIFIED,
);
});
it("keeps the named contexts named, case- and whitespace-tolerant", () => {
expect(glucoseContextBucket("FASTING")).toBe("FASTING");
expect(glucoseContextBucket(" bedtime ")).toBe("BEDTIME");
});
it("groups in canonical order with the untagged bucket last", () => {
const rows = [
{ ctx: null, v: 1 },
{ ctx: "BEDTIME", v: 2 },
{ ctx: "FASTING", v: 3 },
{ ctx: "", v: 4 },
];
const grouped = groupByGlucoseContext(rows, (row) => row.ctx);
expect(grouped.map(([bucket]) => bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
// Input order survives inside a bucket, so a caller reading "latest" off
// the head keeps reading the same row.
expect(grouped[2][1].map((row) => row.v)).toEqual([1, 4]);
});
it("returns nothing for no rows", () => {
expect(groupByGlucoseContext([], () => null)).toEqual([]);
});
it("judges the untagged bucket against the RANDOM threshold", () => {
expect(thresholdMetricForContext(GLUCOSE_CONTEXT_UNSPECIFIED)).toBe(
"BLOOD_GLUCOSE_RANDOM",
);
});
});
+87 -4
View File
@@ -49,11 +49,93 @@ export function resolveGlucoseUnit(
}
/**
* Threshold metric key corresponding to a glucose context. Keeps the
* The `GlucoseContext` enum members, in the order every surface iterates them.
*/
export const NAMED_GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
export type NamedGlucoseContext = (typeof NAMED_GLUCOSE_CONTEXTS)[number];
/**
* The bucket every reading lands in whose `glucoseContext` column is NULL,
* blank, or not one of the four enum members.
*
* It is not a `GlucoseContext` value and never reaches the database — the
* column stays NULL. It exists because a reading without a meal-time tag is
* still a reading, and a surface that iterates only the named contexts drops
* it silently. That is not hypothetical: a meter synced through Apple Health
* writes no HealthKit meal-time metadata at all, so such an account has one
* hundred percent of its readings here and saw an empty dashboard, empty
* targets, and an empty doctor-report panel while the data was current
* (#943). The Coach's glucose block already grouped them under the same
* name — this promotes that one bucket to the shared notion the rest of the
* tree resolves through.
*/
export const GLUCOSE_CONTEXT_UNSPECIFIED = "UNSPECIFIED";
export type GlucoseContextBucket =
NamedGlucoseContext | typeof GLUCOSE_CONTEXT_UNSPECIFIED;
/** Canonical iteration order: the named contexts first, untagged last. */
export const GLUCOSE_CONTEXT_BUCKETS = [
...NAMED_GLUCOSE_CONTEXTS,
GLUCOSE_CONTEXT_UNSPECIFIED,
] as const;
/**
* Resolve a stored `glucose_context` cell to the bucket it belongs to.
* NULL, an empty string, and any value outside the enum all resolve to
* {@link GLUCOSE_CONTEXT_UNSPECIFIED} — the fail-open arm, because dropping
* a reading is worse than filing it under "no meal-time tag".
*/
export function glucoseContextBucket(
raw: string | null | undefined,
): GlucoseContextBucket {
if (raw == null) return GLUCOSE_CONTEXT_UNSPECIFIED;
const key = raw.trim().toUpperCase();
return (NAMED_GLUCOSE_CONTEXTS as readonly string[]).includes(key)
? (key as NamedGlucoseContext)
: GLUCOSE_CONTEXT_UNSPECIFIED;
}
/**
* Group rows into their glucose buckets, in {@link GLUCOSE_CONTEXT_BUCKETS}
* order, skipping buckets with no rows. Row order inside a bucket is the
* caller's input order, so a caller that reads "latest" off the head or tail
* keeps doing so.
*/
export function groupByGlucoseContext<T>(
rows: readonly T[],
contextOf: (row: T) => string | null | undefined,
): Array<[GlucoseContextBucket, T[]]> {
const byBucket = new Map<GlucoseContextBucket, T[]>();
for (const row of rows) {
const bucket = glucoseContextBucket(contextOf(row));
const list = byBucket.get(bucket);
if (list) list.push(row);
else byBucket.set(bucket, [row]);
}
return GLUCOSE_CONTEXT_BUCKETS.flatMap((bucket) => {
const list = byBucket.get(bucket);
return list ? [[bucket, list] as [GlucoseContextBucket, T[]]] : [];
});
}
/**
* Threshold metric key corresponding to a glucose bucket. Keeps the
* effective-range resolver the single source of truth.
*
* The untagged bucket borrows the RANDOM band: a reading with no meal-time
* tag IS a spot reading, which is exactly what RANDOM means, and inventing a
* fifth threshold would give the user a second editor row that judges the
* same readings by a different number.
*/
export function thresholdMetricForContext(
context: "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
context: GlucoseContextBucket,
):
| "BLOOD_GLUCOSE_FASTING"
| "BLOOD_GLUCOSE_POSTPRANDIAL"
@@ -64,9 +146,10 @@ export function thresholdMetricForContext(
return "BLOOD_GLUCOSE_FASTING";
case "POSTPRANDIAL":
return "BLOOD_GLUCOSE_POSTPRANDIAL";
case "RANDOM":
return "BLOOD_GLUCOSE_RANDOM";
case "BEDTIME":
return "BLOOD_GLUCOSE_BEDTIME";
case "RANDOM":
case GLUCOSE_CONTEXT_UNSPECIFIED:
return "BLOOD_GLUCOSE_RANDOM";
}
}