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(