mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge the untagged glucose bucket across every consumer (#943)
This commit is contained in:
commit
3bd0664edf
30 files changed
+828
-97
No files matched your search
@@ -49997,9 +49997,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Structural guard — a dashboard tile may not be gated on a closed enum
|
||||
* whose column is nullable.
|
||||
*
|
||||
* The Blood Glucose tile decided whether to paint by filtering the four
|
||||
* `GlucoseContext` members against a per-context summary record and asking
|
||||
* whether any of them held readings. Every named context is a legal value of
|
||||
* the column, so the list read as exhaustive — but `glucose_context` is
|
||||
* nullable, and a meter synced through Apple Health writes no meal-time
|
||||
* metadata at all. An account with one hundred percent untagged readings
|
||||
* therefore matched no bucket, and the tile was omitted with the module on,
|
||||
* the layout toggle on, and a reading from today. Nothing warned: every
|
||||
* behavioural test in the suite seeded a tagged reading, so the missing arm
|
||||
* was invisible from the outside (#943).
|
||||
*
|
||||
* What the guard asserts: wherever a dashboard tile surface derives presence
|
||||
* from a list of enum buckets, the list must carry a null / unknown arm — or
|
||||
* the call site must write down why it cannot. It matches on the list
|
||||
* EXPRESSION, following one hop through a named constant, because the fix for
|
||||
* this class is always the same: the untagged bucket joins the list the
|
||||
* eligibility loop already walks.
|
||||
*
|
||||
* Its limits, written down so a later reader does not over-trust it: it sees
|
||||
* presence tests spelled with `count`, and a bucket list that is either an
|
||||
* inline literal or a single named constant. An eligibility gate that counted
|
||||
* rows some other way, or assembled its bucket list at runtime, would slip
|
||||
* past. The `detect()` self-controls below exist so a refactor that silently
|
||||
* stops matching anything fails here rather than going quiet.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* A bucket list that carries one of these tokens has an arm for rows the
|
||||
* enum cannot name. Matched against the list expression's source text, so a
|
||||
* shared constant (`GLUCOSE_CONTEXT_BUCKETS = [...NAMED, UNSPECIFIED]`)
|
||||
* satisfies it through its own name.
|
||||
*/
|
||||
const UNTAGGED_ARM = /UNSPECIFIED|UNKNOWN|UNTAGGED|OTHER|NONE/;
|
||||
|
||||
/** The written-reason escape hatch, with a non-empty reason after it. */
|
||||
const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/;
|
||||
|
||||
/** Dashboard surfaces that decide whether a tile paints. */
|
||||
const SURFACES = [
|
||||
"src/app/page-client.tsx",
|
||||
...listSourceFiles("src/components/dashboard"),
|
||||
...listSourceFiles("src/lib/dashboard"),
|
||||
];
|
||||
|
||||
function listSourceFiles(dir: string): string[] {
|
||||
const abs = join(process.cwd(), dir);
|
||||
return readdirSync(abs)
|
||||
.filter((name) => /\.tsx?$/.test(name))
|
||||
.map((name) => join(dir, name))
|
||||
.filter((rel) => statSync(join(process.cwd(), rel)).isFile());
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
/** The bucket-list expression as written at the eligibility site. */
|
||||
list: string;
|
||||
/** 1-based line of the eligibility site. */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteration over a bucket list: `for (const x of LIST)` or
|
||||
* `LIST.filter(…)` / `.some(…)`. The list is either an inline array literal
|
||||
* or an identifier.
|
||||
*/
|
||||
const ITERATION =
|
||||
/(?:for\s*\(\s*(?:const|let)\s+[^)]*?\bof\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\))|(?:(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\.\s*(?:filter|some|flatMap)\s*\()/g;
|
||||
|
||||
/** A presence test — "does this bucket hold any readings". */
|
||||
const PRESENCE = /\bcount\b/;
|
||||
|
||||
/** How far past the iteration head a presence test still belongs to it. */
|
||||
const BODY_WINDOW = 500;
|
||||
|
||||
/**
|
||||
* Every eligibility site in `source` whose bucket list has no untagged arm
|
||||
* and no written reason. `resolve` supplies the initializer text of a named
|
||||
* constant so the guard can follow one hop out of the file.
|
||||
*/
|
||||
export function detect(
|
||||
source: string,
|
||||
resolve: (name: string) => string | null,
|
||||
): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
ITERATION.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = ITERATION.exec(source)) !== null) {
|
||||
const list = match[1] ?? match[2];
|
||||
if (!list) continue;
|
||||
const body = source.slice(match.index, match.index + BODY_WINDOW);
|
||||
if (!PRESENCE.test(body)) continue;
|
||||
// Only enum-shaped lists are in scope: an inline literal of UPPER_SNAKE
|
||||
// members, or an UPPER_SNAKE constant name.
|
||||
const inline = list.startsWith("[");
|
||||
const members = inline ? list.match(/"[A-Z][A-Z0-9_]*"/g) : null;
|
||||
if (inline && (members?.length ?? 0) < 2) continue;
|
||||
if (!inline && !/^[A-Z][A-Z0-9_]*$/.test(list)) continue;
|
||||
const expression = inline ? list : (resolve(list) ?? list);
|
||||
if (UNTAGGED_ARM.test(expression)) continue;
|
||||
const before = source.slice(Math.max(0, match.index - 400), match.index);
|
||||
if (WRITTEN_REASON.test(before)) continue;
|
||||
findings.push({
|
||||
list,
|
||||
line: source.slice(0, match.index).split("\n").length,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Initializer text of `export const NAME = …` / `const NAME = …` in `src/`. */
|
||||
function resolveConstant(name: string): string | null {
|
||||
for (const file of CONSTANT_SOURCES) {
|
||||
const source = readFileSync(join(process.cwd(), file), "utf8");
|
||||
const declaration = new RegExp(
|
||||
`(?:export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(\\[[\\s\\S]*?\\])`,
|
||||
).exec(source);
|
||||
if (declaration) return declaration[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Files a dashboard surface may import a bucket list from. */
|
||||
const CONSTANT_SOURCES = ["src/lib/glucose.ts", ...SURFACES];
|
||||
|
||||
describe("detect() — the matcher itself", () => {
|
||||
it("flags the shape that shipped: four enum members, no null arm", () => {
|
||||
const shipped = `
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"];
|
||||
const present = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].filter(
|
||||
(ctx) => (byContext[ctx]?.count ?? 0) > 0,
|
||||
);
|
||||
`;
|
||||
const findings = detect(shipped, () => null);
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].list).toContain("FASTING");
|
||||
});
|
||||
|
||||
it("accepts a list that carries an untagged arm", () => {
|
||||
const fixed = `
|
||||
for (const bucket of GLUCOSE_CONTEXT_BUCKETS) {
|
||||
const summary = byContext?.[bucket];
|
||||
if (!summary || summary.count <= 0) continue;
|
||||
}
|
||||
`;
|
||||
const resolve = () => '["FASTING", "BEDTIME", GLUCOSE_CONTEXT_UNSPECIFIED]';
|
||||
expect(detect(fixed, resolve)).toEqual([]);
|
||||
// …and rejects the same site when the constant has no arm, so the pass
|
||||
// above is the arm's doing and not the resolver's.
|
||||
expect(detect(fixed, () => '["FASTING", "BEDTIME"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a written reason", () => {
|
||||
const exempt = `
|
||||
// tile-eligibility-exempt: sleep stages are never null on this path.
|
||||
for (const stage of ["DEEP", "REM"]) {
|
||||
if (byStage[stage].count > 0) return true;
|
||||
}
|
||||
`;
|
||||
expect(detect(exempt, () => null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores lists that are not presence tests", () => {
|
||||
const unrelated = `
|
||||
const labels = ["WEIGHT", "PULSE"].map((type) => LABELS[type]);
|
||||
`;
|
||||
expect(detect(unrelated, () => null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard tile eligibility", () => {
|
||||
it("scans the surfaces it claims to scan", () => {
|
||||
// An empty match set must fail rather than read as clean: the guard is
|
||||
// worthless the day its matchers stop finding the real call sites.
|
||||
expect(SURFACES.length).toBeGreaterThan(5);
|
||||
const sited = SURFACES.filter((file) =>
|
||||
PRESENCE.test(readFileSync(join(process.cwd(), file), "utf8")),
|
||||
);
|
||||
expect(sited.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("computes no tile's eligibility from an enum without a null arm", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of SURFACES) {
|
||||
const source = readFileSync(join(process.cwd(), file), "utf8");
|
||||
for (const finding of detect(source, resolveConstant)) {
|
||||
offenders.push(`${file}:${finding.line} — ${finding.list}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record<string, string> = {
|
||||
"insights.bloodGlucose.clinical.advanced.byContext.postprandial",
|
||||
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
|
||||
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
|
||||
// #943 — readings whose source recorded no meal-time context.
|
||||
UNSPECIFIED: "insights.bloodGlucose.clinical.advanced.byContext.unspecified",
|
||||
};
|
||||
|
||||
export interface GlucoseAdvancedDisclosureProps {
|
||||
|
||||
@@ -119,6 +119,10 @@ const GLUCOSE_TARGET_TYPES = [
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
// #943 — readings whose source recorded no meal-time context. The route
|
||||
// emits this card whenever such rows exist, so the panel must list it or
|
||||
// an account with only untagged readings gets no reference band at all.
|
||||
"BLOOD_GLUCOSE_UNSPECIFIED",
|
||||
];
|
||||
|
||||
interface MetricTargetSummaryProps {
|
||||
|
||||
@@ -69,6 +69,10 @@ const TARGET_TYPE_TO_METRIC: Record<string, ThresholdMetric | null> = {
|
||||
// BLOOD_PRESSURE handled specially with both sys + dia.
|
||||
// BMI / MOOD_SCORE / MOOD_STABILITY / MEDICATION_COMPLIANCE /
|
||||
// BLOOD_PRESSURE_IN_TARGET — derived, no editable threshold.
|
||||
// BLOOD_GLUCOSE_UNSPECIFIED (#943) — deliberately absent. The untagged
|
||||
// bucket is judged against the RANDOM band; giving it its own editor row
|
||||
// would offer two controls that write one threshold, so the cog renders
|
||||
// disabled the same way it does for a derived card.
|
||||
};
|
||||
|
||||
interface CurrentRange {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,11 @@
|
||||
* read plus the shared accumulators, so the emitted shape and ordering
|
||||
* are unchanged.
|
||||
*/
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
convertGlucose,
|
||||
glucoseContextBucket,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
@@ -80,15 +84,18 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
|
||||
meta: { cluster: "glucose", source: "glucose" },
|
||||
});
|
||||
} else {
|
||||
// Group by context (NULL → "unspecified"), then per-day mean.
|
||||
// Group by context (NULL → "unspecified"), then per-day mean. The bucket
|
||||
// resolver is the shared one (#943) — this block named the untagged
|
||||
// bucket first, and the rest of the tree now resolves through the same
|
||||
// helper rather than repeating the NULL arm at each surface.
|
||||
const byContext = new Map<
|
||||
string,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
for (const r of glucoseRows) {
|
||||
const ctxKey = r.glucoseContext
|
||||
? String(r.glucoseContext).toLowerCase()
|
||||
: "unspecified";
|
||||
const ctxKey = glucoseContextBucket(
|
||||
typeof r.glucoseContext === "string" ? r.glucoseContext : null,
|
||||
).toLowerCase();
|
||||
const list = byContext.get(ctxKey) ?? [];
|
||||
list.push({ measuredAt: r.measuredAt, value: r.value });
|
||||
byContext.set(ctxKey, list);
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import type { jsPDF } from "jspdf";
|
||||
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -20,6 +21,8 @@ const GLUCOSE_LABEL_KEYS = {
|
||||
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
|
||||
RANDOM: "doctorReport.typeGlucoseRandom",
|
||||
BEDTIME: "doctorReport.typeGlucoseBedtime",
|
||||
// #943 — readings the source never tagged with a meal time.
|
||||
UNSPECIFIED: "doctorReport.typeGlucoseUnspecified",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_GLUCOSE_RANGES = {
|
||||
@@ -27,13 +30,12 @@ const DEFAULT_GLUCOSE_RANGES = {
|
||||
POSTPRANDIAL: { min: 70, max: 140 },
|
||||
RANDOM: { min: 70, max: 140 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
// Same band as RANDOM: an untagged reading is a spot reading.
|
||||
UNSPECIFIED: { min: 70, max: 140 },
|
||||
} as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
...GLUCOSE_CONTEXT_BUCKETS,
|
||||
];
|
||||
|
||||
/** A single charted reading. */
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
|
||||
import {
|
||||
GLUCOSE_CONTEXT_BUCKETS,
|
||||
groupByGlucoseContext,
|
||||
resolveGlucoseUnit,
|
||||
thresholdMetricForContext,
|
||||
} from "@/lib/glucose";
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -70,13 +72,6 @@ import {
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
|
||||
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
|
||||
|
||||
/** No medication row is read unless at least one medication leaf is admitted. */
|
||||
@@ -413,9 +408,19 @@ export async function collectDoctorReportData(
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
if (glucosePanelOn) {
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
// #943 — the untagged bucket is a bucket. A report built from a meter
|
||||
// that records no meal-time context used to carry an empty glucose panel.
|
||||
const groupedGlucose = groupByGlucoseContext(
|
||||
glucoseRows,
|
||||
(m) => m.glucoseContext,
|
||||
);
|
||||
const glucoseBuckets = denseGlucose
|
||||
? GLUCOSE_CONTEXT_BUCKETS
|
||||
: groupedGlucose.map(([bucket]) => bucket);
|
||||
for (const ctx of glucoseBuckets) {
|
||||
if (!denseGlucose) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
const rows =
|
||||
groupedGlucose.find(([bucket]) => bucket === ctx)?.[1] ?? [];
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* Split out of the aggregator with the selection rework.
|
||||
*/
|
||||
import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
|
||||
import { glucoseContextBucket } from "@/lib/glucose";
|
||||
import type { DoctorReportStats } from "@/lib/doctor-report-types";
|
||||
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DenseMeasurementBucket } from "./dense-buckets";
|
||||
@@ -117,8 +118,11 @@ export function summariseDenseBuckets(
|
||||
}
|
||||
|
||||
for (const row of canonical) {
|
||||
if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
|
||||
accumulate(glucoseStatsAcc, row.glucoseContext, {
|
||||
if (row.type !== "BLOOD_GLUCOSE") continue;
|
||||
// #943 — a reading with no meal-time context is filed under the shared
|
||||
// untagged bucket. Skipping it here dropped the whole panel for an
|
||||
// account whose meter never records one.
|
||||
accumulate(glucoseStatsAcc, glucoseContextBucket(row.glucoseContext), {
|
||||
count: row.count,
|
||||
sum: row.sumValue,
|
||||
min: row.minValue,
|
||||
|
||||
@@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
|
||||
},
|
||||
RANDOM: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
|
||||
BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
|
||||
// #943 — the untagged bucket. Already named in the table above; without
|
||||
// the entry the exported bundle silently dropped every reading whose
|
||||
// source recorded no meal time.
|
||||
UNSPECIFIED: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
|
||||
};
|
||||
|
||||
/* ── Clinical glucose-panel LOINCs (v1.18.0) ───────────────────────────
|
||||
|
||||
+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";
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -135,6 +135,68 @@ describe("target section builders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a card for readings with no meal-time context (#943)", () => {
|
||||
// A meter synced through Apple Health writes no HealthKit meal-time
|
||||
// metadata, so every row arrives with a null context. Iterating the four
|
||||
// named contexts alone returned no glucose target at all.
|
||||
const targets = buildGlucoseTargets({
|
||||
rows: [
|
||||
{ value: 112, measuredAt: NOW, glucoseContext: null },
|
||||
{ value: 104, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
profile: {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
glucoseUnit: "mg/dL",
|
||||
hasDiabetes: false,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
timezone: TZ,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(targets.map((target) => target.type)).toEqual([
|
||||
"BLOOD_GLUCOSE_UNSPECIFIED",
|
||||
]);
|
||||
expect(targets[0]).toMatchObject({
|
||||
label: "targets.glucoseUnspecified",
|
||||
current: 112,
|
||||
average30: 108,
|
||||
unit: "mg/dL",
|
||||
});
|
||||
// The untagged bucket is judged against the RANDOM band, so it still
|
||||
// carries a range and a classification rather than a blank card.
|
||||
expect(targets[0].range).not.toBeNull();
|
||||
expect(targets[0].classification).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => {
|
||||
const targets = buildGlucoseTargets({
|
||||
rows: [
|
||||
{ value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const },
|
||||
{ value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const },
|
||||
{ value: 111, measuredAt: NOW, glucoseContext: null },
|
||||
],
|
||||
profile: {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
glucoseUnit: "mg/dL",
|
||||
hasDiabetes: false,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
timezone: TZ,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(targets.map((target) => target.type)).toEqual([
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_UNSPECIFIED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps summary streak ties stable by public target order", () => {
|
||||
expect(
|
||||
buildTargetPageSummary([
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
groupByGlucoseContext,
|
||||
resolveGlucoseUnit,
|
||||
type GlucoseContextBucket,
|
||||
} from "@/lib/glucose";
|
||||
import { resolveGlucoseTarget } from "./glucose-targets";
|
||||
import { makeRangeClassifier, rollupConsistency } from "./consistency";
|
||||
import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types";
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
|
||||
const LABEL_BY_CONTEXT: Record<GlucoseContext, string> = {
|
||||
const LABEL_BY_CONTEXT: Record<GlucoseContextBucket, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
UNSPECIFIED: "targets.glucoseUnspecified",
|
||||
};
|
||||
|
||||
interface GlucoseTargetsInput {
|
||||
@@ -35,10 +32,13 @@ export function buildGlucoseTargets({
|
||||
const unit = resolveGlucoseUnit(profile.glucoseUnit);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const context of GLUCOSE_CONTEXTS) {
|
||||
const contextRows = rows.filter((row) => row.glucoseContext === context);
|
||||
if (contextRows.length === 0) continue;
|
||||
|
||||
// #943 — the untagged bucket rides the same loop. Iterating the four named
|
||||
// contexts alone left an account whose source writes no meal-time tag with
|
||||
// no glucose target at all, the same gap the dashboard tile had.
|
||||
for (const [context, contextRows] of groupByGlucoseContext(
|
||||
rows,
|
||||
(row) => row.glucoseContext,
|
||||
)) {
|
||||
const latest = contextRows[0].value;
|
||||
const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo);
|
||||
const average30 =
|
||||
|
||||
@@ -40,10 +40,17 @@ import {
|
||||
type UserProfileForRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import type { TrafficRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext } from "@/lib/glucose";
|
||||
import {
|
||||
thresholdMetricForContext,
|
||||
type GlucoseContextBucket,
|
||||
} from "@/lib/glucose";
|
||||
|
||||
export type GlucoseContextKey =
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
|
||||
/**
|
||||
* The buckets a glucose target can be resolved for — the four named contexts
|
||||
* plus the untagged bucket (#943), which is judged against the RANDOM band
|
||||
* because an untagged reading is a spot reading.
|
||||
*/
|
||||
export type GlucoseContextKey = GlucoseContextBucket;
|
||||
|
||||
/**
|
||||
* ADA glycemic GOAL bands for people living with diabetes (mg/dL).
|
||||
@@ -59,6 +66,9 @@ export type GlucoseContextKey =
|
||||
* higher floor guards against nocturnal hypoglycemia. Kept identical to the
|
||||
* non-diabetic bedtime band on purpose — the diabetic delta lives in the
|
||||
* fasting/post-prandial goals, where ADA is explicit.
|
||||
* UNSPECIFIED: a reading whose source recorded no meal-time context. Same band
|
||||
* as RANDOM, and for the same reason — an untagged reading is a spot
|
||||
* reading, and the post-prandial goal is the defensible comparator.
|
||||
*/
|
||||
const DIABETIC_GOAL_BANDS: Record<
|
||||
GlucoseContextKey,
|
||||
@@ -68,6 +78,7 @@ const DIABETIC_GOAL_BANDS: Record<
|
||||
POSTPRANDIAL: { min: 80, max: 180 },
|
||||
RANDOM: { min: 80, max: 180 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
UNSPECIFIED: { min: 80, max: 180 },
|
||||
};
|
||||
|
||||
/** Source label surfaced on the resolved range for the targets surface. */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* #943 — untagged glucose readings light the dashboard tile.
|
||||
*
|
||||
* A reporter's meter syncs through Apple Health and never writes HealthKit's
|
||||
* blood-glucose meal-time metadata, so all of their readings carry a NULL
|
||||
* `glucose_context`. The tile strip filtered the four named contexts against
|
||||
* the per-context summaries, matched none, and omitted the tile — module on,
|
||||
* layout toggle on, a reading from today, and no warning anywhere.
|
||||
*
|
||||
* A real Postgres is what makes this convincing: the summaries are built from
|
||||
* rows the database actually holds, with the column genuinely NULL, and the
|
||||
* tile gate is the same pure resolver the page renders through.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { resolveGlucoseTiles } from "@/components/dashboard/dashboard-gates";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
interface AnalyticsEnvelope {
|
||||
data: {
|
||||
glucoseByContext: Record<string, DataSummary> | null;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const READINGS = 20;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedUntaggedGlucose() {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "glucose-untagged",
|
||||
email: "glucose-untagged@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
// 20 readings, one per day, newest first at 104 mg/dL. Every one of them
|
||||
// arrives exactly as the reporter's sync writes them: no meal-time context.
|
||||
const now = Date.now();
|
||||
await prisma.measurement.createMany({
|
||||
data: Array.from({ length: READINGS }, (_, i) => ({
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE" as const,
|
||||
value: 104 + i,
|
||||
unit: "mg/dL",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
measuredAt: new Date(now - i * DAY_MS),
|
||||
glucoseContext: null,
|
||||
})),
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async function readAnalytics(): Promise<AnalyticsEnvelope> {
|
||||
const { GET } = await import("@/app/api/analytics/route");
|
||||
const response = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/analytics"));
|
||||
expect(response.status).toBe(200);
|
||||
return (await response.json()) as AnalyticsEnvelope;
|
||||
}
|
||||
|
||||
describe("dashboard glucose tile with untagged readings (#943)", () => {
|
||||
it("stores the readings with a NULL context", async () => {
|
||||
const user = await seedUntaggedGlucose();
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
|
||||
select: { glucoseContext: true },
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(READINGS);
|
||||
expect(rows.every((row) => row.glucoseContext === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the tile as eligible and carries the latest value", async () => {
|
||||
await seedUntaggedGlucose();
|
||||
|
||||
const envelope = await readAnalytics();
|
||||
const byContext = envelope.data.glucoseByContext;
|
||||
expect(byContext).not.toBeNull();
|
||||
|
||||
// The summaries carry the untagged bucket, and nothing else.
|
||||
expect(Object.keys(byContext ?? {})).toEqual(["UNSPECIFIED"]);
|
||||
const summary = byContext!.UNSPECIFIED;
|
||||
expect(summary.count).toBe(READINGS);
|
||||
expect(summary.latest).toBe(104);
|
||||
expect(summary.avg30).not.toBeNull();
|
||||
|
||||
// …and the tile gate the dashboard renders through says "paint it".
|
||||
const tiles = resolveGlucoseTiles(byContext ?? undefined);
|
||||
expect(tiles).toHaveLength(1);
|
||||
expect(tiles[0]).toMatchObject({
|
||||
bucket: "UNSPECIFIED",
|
||||
labelKey: "targets.glucoseUnspecified",
|
||||
});
|
||||
expect(tiles[0].summary.latest).toBe(104);
|
||||
});
|
||||
|
||||
it("keeps a tagged reading in its own bucket beside the untagged ones", async () => {
|
||||
const user = await seedUntaggedGlucose();
|
||||
await getPrismaClient().measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 92,
|
||||
unit: "mg/dL",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date(),
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await readAnalytics();
|
||||
const byContext = envelope.data.glucoseByContext ?? {};
|
||||
|
||||
expect(Object.keys(byContext).sort()).toEqual(["FASTING", "UNSPECIFIED"]);
|
||||
expect(byContext.FASTING.count).toBe(1);
|
||||
expect(byContext.UNSPECIFIED.count).toBe(READINGS);
|
||||
expect(resolveGlucoseTiles(byContext).map((tile) => tile.bucket)).toEqual([
|
||||
"FASTING",
|
||||
"UNSPECIFIED",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user