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