From 2a3bd67b6826d6b247d9628960237dd2597c50ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 5 Oct 2026 13:10:42 +0200
Subject: [PATCH] fix(insights): cut glucose days at today's latest reading and
say so
The same-hours comparison cut the earlier days at the current clock time.
At two in the afternoon a day holding only its fasting reading was then
held against mornings plus lunch, and read as below its range again. The
earlier days are now cut at the local time of today's latest reading.
A range read this way is the usual range for this time of day, not the
whole-day band other surfaces show. The deviation carries a basis, the
health-status API publishes it as `basis` (`day` or `sameHours`), and the
Today lead and the health-status card name the range as the one for this
time of day, in all seven locales.
---
docs/api/openapi.yaml | 9 ++++
messages/de.json | 2 +
messages/en.json | 2 +
messages/es.json | 2 +
messages/fr.json | 2 +
messages/it.json | 2 +
messages/ko.json | 2 +
messages/pl.json | 2 +
.../insights/health-status-card.tsx | 15 ++++--
src/lib/daily/__tests__/load-digest.test.ts | 46 +++++++++++++++++++
src/lib/daily/load-digest.ts | 20 ++++++--
.../insights/__tests__/health-status.test.ts | 11 +++++
.../__tests__/coincident-deviation.test.ts | 25 ++++++++++
.../insights/derived/coincident-deviation.ts | 44 ++++++++++++++----
src/lib/insights/health-status.ts | 7 +++
src/lib/openapi/routes/insights-signals.ts | 5 ++
16 files changed, 177 insertions(+), 19 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index d4a229650..6033f1998 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -60433,6 +60433,14 @@ components:
enum:
- above
- below
+ basis:
+ type: string
+ enum:
+ - day
+ - sameHours
+ description: "What `low`/`high` describe: the usual whole day (`day`), or the usual range for this time of day
+ (`sameHours`), used for a day still in progress whose mean moves with the hour (glucose). Additive
+ since v1.40.3."
required:
- type
- value
@@ -60440,6 +60448,7 @@ components:
- low
- high
- direction
+ - basis
additionalProperties: false
shifts:
type: array
diff --git a/messages/de.json b/messages/de.json
index 94c4d89c1..c45a8287b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.",
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
+ "rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.",
"shiftContext": "Etwa {before} davor, {after} seitdem."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Seit {count} Wochen stabil",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} bis {high} {unit}",
+ "rangeAtThisTime": "{range} für diese Tageszeit",
"vital": {
"RESTING_HEART_RATE": "Ruhepuls",
"HEART_RATE_VARIABILITY": "Herzfrequenzvariabilität",
diff --git a/messages/en.json b/messages/en.json
index 92aff03a8..6582a631e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} has shifted up from your recent normal.",
"shiftDown": "{vital} has shifted down from your recent normal.",
"rangeContext": "Now {value}, usually {low}–{high}.",
+ "rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.",
"shiftContext": "Around {before} before, {after} since."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Steady for {count} weeks",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} to {high} {unit}",
+ "rangeAtThisTime": "{range} for this time of day",
"vital": {
"RESTING_HEART_RATE": "Resting heart rate",
"HEART_RATE_VARIABILITY": "Heart-rate variability",
diff --git a/messages/es.json b/messages/es.json
index 9f21ada5b..d4996861e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} ha subido respecto a tu normal reciente.",
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
+ "rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.",
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Estable desde hace {count} semanas",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} a {high} {unit}",
+ "rangeAtThisTime": "{range} para esta hora del día",
"vital": {
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
"HEART_RATE_VARIABILITY": "Variabilidad de la frecuencia cardíaca",
diff --git a/messages/fr.json b/messages/fr.json
index 3bc9a18f4..8b11b0e67 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} a augmenté par rapport à votre normale récente.",
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
+ "rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.",
"shiftContext": "Environ {before} avant, {after} depuis."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stable depuis {count} semaines",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} à {high} {unit}",
+ "rangeAtThisTime": "{range} pour ce moment de la journée",
"vital": {
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
"HEART_RATE_VARIABILITY": "Variabilité de la fréquence cardiaque",
diff --git a/messages/it.json b/messages/it.json
index ae8c7982b..03b89d1a8 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.",
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
"rangeContext": "Ora {value}, di solito {low}–{high}.",
+ "rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.",
"shiftContext": "Circa {before} prima, {after} da allora."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stabile da {count} settimane",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "da {low} a {high} {unit}",
+ "rangeAtThisTime": "{range} per quest'ora del giorno",
"vital": {
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
"HEART_RATE_VARIABILITY": "Variabilità della frequenza cardiaca",
diff --git a/messages/ko.json b/messages/ko.json
index 80d038191..9ba7aa1be 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} — 최근 평소 수준보다 올라갔어요.",
"shiftDown": "{vital} — 최근 평소 수준보다 내려갔어요.",
"rangeContext": "지금 {value}, 평소 {low}–{high}.",
+ "rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.",
"shiftContext": "이전엔 약 {before}, 이후엔 {after}."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "{count}주째 안정적",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low}~{high} {unit}",
+ "rangeAtThisTime": "이 시간대 기준 {range}",
"vital": {
"RESTING_HEART_RATE": "안정 시 심박수",
"HEART_RATE_VARIABILITY": "심박 변이도",
diff --git a/messages/pl.json b/messages/pl.json
index 809bc6c8e..3cca6e11c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4492,6 +4492,7 @@
"shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.",
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
+ "rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.",
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
},
"breathingScreening": {
@@ -11018,6 +11019,7 @@
"steadyWeeksOther": "Stabilnie od {count} tygodni",
"valueWithUnit": "{value} {unit}",
"rangeWithUnit": "{low} do {high} {unit}",
+ "rangeAtThisTime": "{range} o tej porze dnia",
"vital": {
"RESTING_HEART_RATE": "Tętno spoczynkowe",
"HEART_RATE_VARIABILITY": "Zmienność rytmu serca",
diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx
index 354843b80..87487f8f1 100644
--- a/src/components/insights/health-status-card.tsx
+++ b/src/components/insights/health-status-card.tsx
@@ -109,11 +109,16 @@ export function HealthStatusCard({
)}
- {t("insights.healthStatus.rangeContext", {
- value: metricValue(d.type, d.value),
- low: metricValue(d.type, d.low),
- high: metricValue(d.type, d.high),
- })}
+ {t(
+ d.basis === "sameHours"
+ ? "insights.healthStatus.rangeContextTimeOfDay"
+ : "insights.healthStatus.rangeContext",
+ {
+ value: metricValue(d.type, d.value),
+ low: metricValue(d.type, d.low),
+ high: metricValue(d.type, d.high),
+ },
+ )}
diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts
index 498cae53f..6544fd576 100644
--- a/src/lib/daily/__tests__/load-digest.test.ts
+++ b/src/lib/daily/__tests__/load-digest.test.ts
@@ -677,6 +677,52 @@ describe("loadDailyDigest — Today overview inputs", () => {
expect(digest.briefingLead).toBeNull();
});
+ it("says a glucose range is the usual one for this time of day", async () => {
+ // Today's glucose is held against the same hours of earlier days, so the
+ // range it names is not the whole-day band shown elsewhere.
+ const { getServerTranslator: real } = await vi.importActual<
+ typeof import("@/lib/i18n/server-translator")
+ >("@/lib/i18n/server-translator");
+ const { getServerTranslator } =
+ await import("@/lib/i18n/server-translator");
+ vi.mocked(getServerTranslator).mockReturnValueOnce(real("en"));
+ vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
+ ...SNAPSHOT,
+ body: {
+ ...SNAPSHOT.body,
+ briefingAi: aiUnavailable("user_disabled"),
+ },
+ } as never);
+ vi.mocked(aiCapabilityToServe).mockResolvedValue(
+ aiUnavailable("user_disabled"),
+ );
+ vi.mocked(computeCoincidentDeviation).mockResolvedValueOnce({
+ status: "ok",
+ value: {
+ fired: false,
+ contributing: [],
+ day: "2026-07-17",
+ illnessExplained: false,
+ vitals: [
+ {
+ type: "BLOOD_GLUCOSE",
+ value: 70,
+ center: 86,
+ low: 80,
+ high: 92,
+ outside: true,
+ direction: "below",
+ daysAgo: 0,
+ basis: "sameHours",
+ },
+ ],
+ },
+ } as never);
+ const digest = await loadDailyDigest(USER, NOW);
+ expect(digest.lead?.source).toBe("signal");
+ expect(digest.lead?.text).toMatch(/80 to 92 mg\/dL for this time of day/);
+ });
+
it("publishes how long the score has held", async () => {
vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({
...SNAPSHOT,
diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts
index 95c609b3a..db39186f9 100644
--- a/src/lib/daily/load-digest.ts
+++ b/src/lib/daily/load-digest.ts
@@ -259,6 +259,8 @@ interface ExtrasVital {
high: number;
direction: "above" | "below" | "in";
daysAgo: number;
+ /** Held against the same hours of earlier days, see `VitalDeviation`. */
+ basis?: "sameHours";
}
/**
@@ -286,6 +288,7 @@ async function gatherVitals(
high: v.high,
direction: v.direction,
daysAgo: Number.isFinite(v.daysAgo) ? v.daysAgo : Number.MAX_SAFE_INTEGER,
+ ...(v.basis ? { basis: v.basis } : {}),
}));
} catch {
return [];
@@ -370,11 +373,18 @@ function toTodayVital(
value: shown(vital.value),
unit,
}),
- rangeLabel: t("daily.today.rangeWithUnit", {
- low: shown(vital.low),
- high: shown(vital.high),
- unit,
- }),
+ rangeLabel: (() => {
+ const range = t("daily.today.rangeWithUnit", {
+ low: shown(vital.low),
+ high: shown(vital.high),
+ unit,
+ });
+ // A range read off the same hours of earlier days is the usual range
+ // for this time of day, not the whole-day band shown elsewhere.
+ return vital.basis === "sameHours"
+ ? t("daily.today.rangeAtThisTime", { range })
+ : range;
+ })(),
moduleKey: moduleForMeasurementType(vital.type),
};
}
diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts
index a932d476b..b5ad57d9c 100644
--- a/src/lib/insights/__tests__/health-status.test.ts
+++ b/src/lib/insights/__tests__/health-status.test.ts
@@ -61,4 +61,15 @@ describe("summariseHealthStatus", () => {
const s = summariseHealthStatus([vital(true, "in")], []);
expect(s.present).toBe(false);
});
+
+ it("says when a deviation is measured against the same hours", () => {
+ const s = summariseHealthStatus(
+ [
+ { ...vital(true, "below"), type: "BLOOD_GLUCOSE", basis: "sameHours" },
+ vital(true, "above"),
+ ],
+ [],
+ );
+ expect(s.deviations.map((d) => d.basis)).toEqual(["sameHours", "day"]);
+ });
});
diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
index 60036caaf..5e035a06d 100644
--- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
+++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts
@@ -282,6 +282,31 @@ describe("glucose — a day in progress is compared with the same hours", () =>
expect(glucose!.center).toBe(85);
});
+ it("cuts earlier days at today's latest reading, not at the clock", async () => {
+ // 14:00, and today holds only the 07:00 fasting value. Cut at 14:00 the
+ // earlier days include their 13:00 post-meal readings (mean ~122) and the
+ // morning read as low; cut at 07:00 they are fasting too.
+ findMany.mockImplementation(async (args: { where: { type: string } }) => {
+ if (args.where.type === "BLOOD_GLUCOSE") {
+ return [
+ ...glucoseDays(["07:00", "13:00", "19:00"], [85, 160, 150]),
+ { value: 84, measuredAt: new Date("2026-06-02T07:00:00Z") },
+ ];
+ }
+ if (args.where.type === "RESTING_HEART_RATE") return rhrFlat;
+ return [];
+ });
+ const result = await computeCoincidentDeviation("u1", PROFILE, {
+ now: new Date("2026-06-02T14:00:00Z"),
+ tz: "UTC",
+ });
+ if (result.status !== "ok") throw new Error("expected ok");
+ const glucose = result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE");
+ expect(glucose!.direction).toBe("in");
+ expect(glucose!.center).toBe(85);
+ expect(glucose!.basis).toBe("sameHours");
+ });
+
it("still flags a morning that is low against earlier mornings", async () => {
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "BLOOD_GLUCOSE") {
diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts
index 0bf494ccd..5588e3616 100644
--- a/src/lib/insights/derived/coincident-deviation.ts
+++ b/src/lib/insights/derived/coincident-deviation.ts
@@ -77,7 +77,8 @@ export const COINCIDENT_MIN_BANDS = 2;
* fasting mean, and a band built from whole days sits well above it: a
* perfectly ordinary morning read as "below your range". For these types a
* day still in progress is compared with the same hours of the earlier days
- * (each day cut at the current local clock time), never with whole days.
+ * (each day cut at the local time of today's latest reading), never with
+ * whole days.
*/
const SAME_HOURS_TYPES: ReadonlySet = new Set([
"BLOOD_GLUCOSE",
@@ -113,20 +114,26 @@ export function sameHoursStanding(
};
}
-/** The current local clock time in `tz`, `HH:MM:SS`. */
-function localClockNow(now: Date, tz: string): string {
- const c = wallClockInTz(now, tz);
+/** The local clock time of an instant in `tz`, `HH:MM:SS`. */
+function localClockOf(at: Date, tz: string): string {
+ const c = wallClockInTz(at, tz);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(c.hour)}:${pad(c.minute)}:${pad(c.second)}`;
}
-/** Per-day aggregates of the window, each day cut at the current clock. */
+/**
+ * Per-day aggregates of the window, each day cut at the local clock time of
+ * today's latest reading. Not at `now`: at two in the afternoon a day whose
+ * only reading is the 07:00 fasting value would otherwise be held against
+ * earlier days that include their lunch.
+ */
async function readSameHoursDays(
userId: string,
type: MeasurementType,
windowDays: number,
now: Date,
tz: string,
+ cutAt: Date,
) {
return readDayAggregates({
userId,
@@ -135,7 +142,7 @@ async function readSameHoursDays(
until: now,
timeZone: tz,
valueRange: plausibleMetricRange(type),
- upToLocalTime: localClockNow(now, tz),
+ upToLocalTime: localClockOf(cutAt, tz),
});
}
@@ -162,6 +169,14 @@ export interface VitalDeviation {
* `contributing` below already does.
*/
daysAgo: number;
+ /**
+ * `"sameHours"` when today's value was held against the same hours of the
+ * earlier days rather than their whole-day band (a day still in progress
+ * for a type whose day mean moves with the hour, see `SAME_HOURS_TYPES`).
+ * `low` / `high` are then the usual range for this time of day, and a
+ * surface naming them says so. Absent for the whole-day comparison.
+ */
+ basis?: "sameHours";
}
export interface CoincidentDeviationValue {
@@ -195,6 +210,7 @@ export function classifyDeviation(
high: number,
center: number,
daysAgo: number = 0,
+ basis?: "sameHours",
): VitalDeviation {
const above = value > high;
const below = value < low;
@@ -207,6 +223,7 @@ export function classifyDeviation(
outside: above || below,
direction: above ? "above" : below ? "below" : "in",
daysAgo,
+ ...(basis ? { basis } : {}),
};
}
@@ -241,7 +258,7 @@ async function readLatestDayMean(
windowDays: number,
now: Date,
tz: string,
-): Promise<{ value: number; day: string } | null> {
+): Promise<{ value: number; day: string; lastAt: Date } | null> {
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
@@ -268,7 +285,7 @@ export function latestDayMeanFromRows(
rows: readonly { value: number; measuredAt: Date }[],
type: MeasurementType,
tz: string,
-): { value: number; day: string } | null {
+): { value: number; day: string; lastAt: Date } | null {
const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
if (usable.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
@@ -282,6 +299,7 @@ export function latestDayMeanFromRows(
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
+ lastAt: new Date(Math.max(...sameDay.map((r) => r.measuredAt.getTime()))),
};
}
@@ -329,7 +347,14 @@ export async function computeCoincidentDeviation(
const sameHours =
latest && latest.day === todayKey && SAME_HOURS_TYPES.has(type)
? sameHoursStanding(
- await readSameHoursDays(userId, type, windowDays, now, tz),
+ await readSameHoursDays(
+ userId,
+ type,
+ windowDays,
+ now,
+ tz,
+ latest.lastAt,
+ ),
todayKey,
type,
)
@@ -357,6 +382,7 @@ export async function computeCoincidentDeviation(
sameHours.high,
sameHours.center,
0,
+ "sameHours",
)
: classifyDeviation(
type,
diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts
index a1dc31092..358059b6e 100644
--- a/src/lib/insights/health-status.ts
+++ b/src/lib/insights/health-status.ts
@@ -30,6 +30,12 @@ export interface HealthStatusDeviation {
high: number;
/** Which side of the band the value falls on. */
direction: "above" | "below";
+ /**
+ * What the band describes: the whole day (`day`), or the same hours of the
+ * earlier days (`sameHours`), for a day still in progress whose mean moves
+ * with the hour (glucose). A surface naming the range says which.
+ */
+ basis: "day" | "sameHours";
}
/** One dated, sustained level shift from the changepoint detector. */
@@ -75,6 +81,7 @@ export function summariseHealthStatus(
low: v.low,
high: v.high,
direction: v.direction === "above" ? "above" : "below",
+ basis: v.basis ?? "day",
}));
const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({
diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts
index 988f2f68f..b08885b3d 100644
--- a/src/lib/openapi/routes/insights-signals.ts
+++ b/src/lib/openapi/routes/insights-signals.ts
@@ -28,6 +28,11 @@ const healthStatusResponse = z
low: z.number(),
high: z.number(),
direction: z.enum(["above", "below"]),
+ basis: z
+ .enum(["day", "sameHours"])
+ .describe(
+ "What `low`/`high` describe: the usual whole day (`day`), or the usual range for this time of day (`sameHours`), used for a day still in progress whose mean moves with the hour (glucose). Additive since v1.40.3.",
+ ),
}),
),
shifts: z.array(