diff --git a/messages/de.json b/messages/de.json index eef3089fe..9a3d0cb6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glukose (postprandial)", "typeGlucoseRandom": "Glukose (beliebig)", "typeGlucoseBedtime": "Glukose (vor dem Schlafen)", + "typeGlucoseUnspecified": "Glukose (ohne Angabe)", "glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)", "glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "im Ziel", @@ -3438,6 +3439,7 @@ "postprandial": "Nach dem Essen", "random": "Beliebig", "bedtime": "Vor dem Schlafen", + "unspecified": "Ohne Angabe", "readingsShort": "{count} Messungen", "readingsShortOne": "1 Messung" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", + "glucoseUnspecified": "Glukose — ohne Angabe", "status": { "underweight": "Untergewicht", "normal": "Normal", diff --git a/messages/en.json b/messages/en.json index e75e342a8..ae324a483 100644 --- a/messages/en.json +++ b/messages/en.json @@ -244,6 +244,7 @@ "typeGlucosePostprandial": "Glucose (postprandial)", "typeGlucoseRandom": "Glucose (random)", "typeGlucoseBedtime": "Glucose (bedtime)", + "typeGlucoseUnspecified": "Glucose (untagged)", "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "in target", @@ -3438,6 +3439,7 @@ "postprandial": "After meals", "random": "Random", "bedtime": "Bedtime", + "unspecified": "Untagged", "readingsShort": "{count} readings", "readingsShortOne": "1 reading" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", + "glucoseUnspecified": "Glucose — untagged", "status": { "underweight": "Underweight", "normal": "Normal", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..3fb6c1d8c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glucosa (posprandial)", "typeGlucoseRandom": "Glucosa (aleatoria)", "typeGlucoseBedtime": "Glucosa (antes de dormir)", + "typeGlucoseUnspecified": "Glucosa (sin indicar)", "glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)", "glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "en objetivo", @@ -3438,6 +3439,7 @@ "postprandial": "Después de comer", "random": "Aleatorio", "bedtime": "Antes de dormir", + "unspecified": "Sin indicar", "readingsShort": "{count} mediciones", "readingsShortOne": "1 medición" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glucemia — posprandial", "glucoseRandom": "Glucemia — cualquiera", "glucoseBedtime": "Glucemia — antes de dormir", + "glucoseUnspecified": "Glucemia — sin indicar", "status": { "underweight": "Peso insuficiente", "normal": "Normal", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..08a2b848f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glycémie (postprandiale)", "typeGlucoseRandom": "Glycémie (indifférente)", "typeGlucoseBedtime": "Glycémie (avant le coucher)", + "typeGlucoseUnspecified": "Glycémie (non précisée)", "glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)", "glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "dans la cible", @@ -3438,6 +3439,7 @@ "postprandial": "Après les repas", "random": "Aléatoire", "bedtime": "Au coucher", + "unspecified": "Non précisée", "readingsShort": "{count} mesures", "readingsShortOne": "1 mesure" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glycémie — postprandiale", "glucoseRandom": "Glycémie — indifférente", "glucoseBedtime": "Glycémie — avant le coucher", + "glucoseUnspecified": "Glycémie — non précisée", "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", diff --git a/messages/it.json b/messages/it.json index e17e31091..bb61e24ba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glicemia (postprandiale)", "typeGlucoseRandom": "Glicemia (qualsiasi)", "typeGlucoseBedtime": "Glicemia (prima di coricarsi)", + "typeGlucoseUnspecified": "Glicemia (non indicata)", "glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)", "glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "in obiettivo", @@ -3438,6 +3439,7 @@ "postprandial": "Dopo i pasti", "random": "Casuale", "bedtime": "Prima di dormire", + "unspecified": "Non indicata", "readingsShort": "{count} misurazioni", "readingsShortOne": "1 misurazione" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glicemia — postprandiale", "glucoseRandom": "Glicemia — qualsiasi", "glucoseBedtime": "Glicemia — prima di coricarsi", + "glucoseUnspecified": "Glicemia — non indicata", "status": { "underweight": "Sottopeso", "normal": "Normal", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..37af1bca3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -244,6 +244,7 @@ "typeGlucosePostprandial": "혈당 (식후)", "typeGlucoseRandom": "혈당 (임의)", "typeGlucoseBedtime": "혈당 (취침 전)", + "typeGlucoseUnspecified": "혈당 (미지정)", "glucoseClassificationTitle": "혈당 — 참고 범위 (ADA 2024 / DDG)", "glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "목표 범위 내", @@ -3438,6 +3439,7 @@ "postprandial": "식후", "random": "임의", "bedtime": "취침 전", + "unspecified": "미지정", "readingsShort": "측정값 {count}개", "readingsShortOne": "측정값 1개" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "혈당 — 식후", "glucoseRandom": "혈당 — 무작위", "glucoseBedtime": "혈당 — 취침 전", + "glucoseUnspecified": "혈당 — 미지정", "status": { "underweight": "저체중", "normal": "정상", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..da7723c8b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -208,6 +208,7 @@ "typeGlucosePostprandial": "Glukoza (po posiłku)", "typeGlucoseRandom": "Glukoza (dowolna)", "typeGlucoseBedtime": "Glukoza (przed snem)", + "typeGlucoseUnspecified": "Glukoza (bez oznaczenia)", "glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)", "glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}", "glucoseInTarget": "w celu", @@ -3438,6 +3439,7 @@ "postprandial": "Po posiłku", "random": "Losowy", "bedtime": "Przed snem", + "unspecified": "Bez oznaczenia", "readingsShort": "{count} pomiarów", "readingsShortOne": "1 pomiar" } @@ -7593,6 +7595,7 @@ "glucosePostprandial": "Glikemia — po posiłku", "glucoseRandom": "Glikemia — dowolna", "glucoseBedtime": "Glikemia — przed snem", + "glucoseUnspecified": "Glikemia — bez oznaczenia", "status": { "underweight": "Niedowaga", "normal": "Normal", diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index db296dccd..a684ba896 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index 14d2dee5b..4b4e69a61 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -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( + rows: readonly T[], + contextOf: (row: T) => string | null | undefined, +): Array<[GlucoseContextBucket, T[]]> { + const byBucket = new Map(); + 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"; } }