mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
1d372df3a0
commit
9a2b1f0955
9 files changed
+157
-4
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "정상",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user