diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 8c0fc800f..60036caaf 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -234,3 +234,95 @@ describe("computeCoincidentDeviation", () => { expect(restModeMock.resolveRestMode).not.toHaveBeenCalled(); }); }); + +describe("glucose — a day in progress is compared with the same hours", () => { + /** Fourteen whole days: fasting 85 at 07:00, then 160 and 150 after meals. */ + function glucoseDays(times: string[], values: number[]) { + return Array.from({ length: 14 }, (_, i) => + times.map((time, j) => ({ + value: values[j], + measuredAt: new Date( + `2026-05-${String(18 + i).padStart(2, "0")}T${time}:00Z`, + ), + })), + ).flat(); + } + const rhrFlat = Array.from({ length: 10 }, (_, i) => ({ + value: 58, + measuredAt: new Date( + `2026-05-${String(20 + i).padStart(2, "0")}T06:00:00Z`, + ), + })); + + it("does not call a fasting morning 'below your range' against whole days", async () => { + // 08:00 today: only the fasting reading is in. Against whole-day means + // (~132) it read as below the range; against the same hours (85) it is + // an ordinary morning. + 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: 86, 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: NOW, + tz: "UTC", + }); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + const glucose = result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE"); + expect(glucose).toBeDefined(); + expect(glucose!.daysAgo).toBe(0); + expect(glucose!.value).toBe(86); + expect(glucose!.direction).toBe("in"); + expect(glucose!.center).toBe(85); + }); + + 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") { + return [ + ...glucoseDays(["07:00", "13:00", "19:00"], [85, 160, 150]), + { value: 62, 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: NOW, + 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("below"); + }); + + it("says nothing about today when earlier days have no readings at this hour", async () => { + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "BLOOD_GLUCOSE") { + return [ + ...glucoseDays(["19:00"], [150]), + { value: 86, measuredAt: new Date("2026-06-02T07:00:00Z") }, + ]; + } + if (args.where.type === "RESTING_HEART_RATE") return rhrFlat; + if (args.where.type === "HEART_RATE_VARIABILITY") { + return rhrFlat.map((r) => ({ ...r, value: 60 })); + } + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { + now: NOW, + tz: "UTC", + }); + if (result.status !== "ok") throw new Error("expected ok"); + expect( + result.value.vitals.find((v) => v.type === "BLOOD_GLUCOSE"), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 83f75301e..0bf494ccd 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -36,11 +36,20 @@ import { deriveCoverage, nowProvenanceTimestamp, } from "./coverage"; -import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { + buildBaselineBand, + computeVitalsBaseline, + type BaselineProfile, +} from "./baseline"; +import { readDayAggregates } from "@/lib/measurements/day-aggregates"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; -import { isPlausibleMetricValue } from "@/lib/measurements/value-domain"; +import { + isPlausibleMetricValue, + plausibleMetricRange, +} from "@/lib/measurements/value-domain"; import { dayKeyAgeInDays, isCurrentForTodayClaim, @@ -61,6 +70,75 @@ export const COINCIDENT_FIRE_THRESHOLD = 2; /** Need ≥ this many banded vitals before the flag can even coincide. */ export const COINCIDENT_MIN_BANDS = 2; +/** + * Vitals whose day mean depends on how much of the day has passed. + * + * Glucose climbs after every meal, so a day's mean at ten in the morning is a + * 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. + */ +const SAME_HOURS_TYPES: ReadonlySet = new Set([ + "BLOOD_GLUCOSE", +]); + +/** Earlier days with readings in the same hours before today is compared. */ +export const SAME_HOURS_MIN_DAYS = 7; + +/** + * Today's standing for a same-hours type, from per-day aggregates already cut + * at the current local clock time. Pure. Null when today has no reading in + * those hours or fewer than {@link SAME_HOURS_MIN_DAYS} earlier days do: then + * there is nothing like-for-like to compare today with, and no verdict. + */ +export function sameHoursStanding( + days: readonly { day: string; n: number; sum: number }[], + todayKey: string, + type: MeasurementType, +): { value: number; low: number; high: number; center: number } | null { + const today = days.find((d) => d.day === todayKey && d.n > 0); + if (!today) return null; + const earlier = days + .filter((d) => d.day < todayKey && d.n > 0) + .map((d) => d.sum / d.n); + if (earlier.length < SAME_HOURS_MIN_DAYS) return null; + const band = buildBaselineBand(earlier, type); + if (!band) return null; + return { + value: today.sum / today.n, + low: band.low, + high: band.high, + center: band.center, + }; +} + +/** The current local clock time in `tz`, `HH:MM:SS`. */ +function localClockNow(now: Date, tz: string): string { + const c = wallClockInTz(now, 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. */ +async function readSameHoursDays( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, + tz: string, +) { + return readDayAggregates({ + userId, + type, + since: new Date(now.getTime() - windowDays * MS_PER_DAY), + until: now, + timeZone: tz, + valueRange: plausibleMetricRange(type), + upToLocalTime: localClockNow(now, tz), + }); +} + /** One vital's standing against its personal band today. */ export interface VitalDeviation { type: MeasurementType; @@ -242,28 +320,52 @@ export async function computeCoincidentDeviation( now, coverage, }); - if (baseline.status !== "ok") return { type, baseline, latest: null }; + if (baseline.status !== "ok") { + return { type, baseline, latest: null, sameHours: null }; + } const latest = await readLatestDayMean(userId, type, windowDays, now, tz); - return { type, baseline, latest }; + // A day still in progress is compared with the same hours of earlier + // days, not with their whole-day band (see `SAME_HOURS_TYPES`). + const sameHours = + latest && latest.day === todayKey && SAME_HOURS_TYPES.has(type) + ? sameHoursStanding( + await readSameHoursDays(userId, type, windowDays, now, tz), + todayKey, + type, + ) + : undefined; + return { type, baseline, latest, sameHours }; }), ); - for (const { type, baseline, latest } of perType) { + for (const { type, baseline, latest, sameHours } of perType) { if (baseline.status !== "ok") continue; if (baseline.provenance.source === "DAY") anyDaySource = true; if (!latest) continue; + // A same-hours type with no like-for-like basis for today yet says + // nothing about today rather than hold a partial day against whole ones. + if (sameHours === null) continue; if (latest.day > latestDay) latestDay = latest.day; if (baseline.coverage.historyDays > maxHistoryDays) { maxHistoryDays = baseline.coverage.historyDays; } vitals.push( - classifyDeviation( - type, - latest.value, - baseline.value.low, - baseline.value.high, - baseline.value.center, - dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY, - ), + sameHours + ? classifyDeviation( + type, + sameHours.value, + sameHours.low, + sameHours.high, + sameHours.center, + 0, + ) + : classifyDeviation( + type, + latest.value, + baseline.value.low, + baseline.value.high, + baseline.value.center, + dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY, + ), ); } diff --git a/src/lib/measurements/__tests__/fake-day-aggregates.ts b/src/lib/measurements/__tests__/fake-day-aggregates.ts index 9e2503dd0..d3dca4fff 100644 --- a/src/lib/measurements/__tests__/fake-day-aggregates.ts +++ b/src/lib/measurements/__tests__/fake-day-aggregates.ts @@ -19,9 +19,21 @@ import type { SourceDayAggregateRow, } from "@/lib/measurements/day-aggregates"; import { userDayKey } from "@/lib/tz/format"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dayValue } from "@/lib/measurements/day-mean"; import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; +/** `HH:MM:SS` of an instant on the wall clock of `timeZone`. */ +function localClock(at: Date, timeZone: string): string { + const c = wallClockInTz(at, timeZone); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(c.hour)}:${pad(c.minute)}:${pad(c.second)}`; +} + +function normaliseClock(clock: string): string { + return clock.length === 5 ? `${clock}:00` : clock; +} + export function foldDayAggregates( rows: ReadonlyArray<{ measuredAt: Date; value: number }>, opts: Omit, @@ -32,6 +44,13 @@ export function foldDayAggregates( const t = r.measuredAt.getTime(); if (t < opts.since.getTime()) continue; if (opts.until && t > opts.until.getTime()) continue; + if ( + opts.upToLocalTime && + localClock(r.measuredAt, opts.timeZone) > + normaliseClock(opts.upToLocalTime) + ) { + continue; + } if ( opts.valueRange && (r.value < opts.valueRange.min || r.value > opts.valueRange.max) diff --git a/src/lib/measurements/day-aggregates.ts b/src/lib/measurements/day-aggregates.ts index d29b71cfa..b211b1b86 100644 --- a/src/lib/measurements/day-aggregates.ts +++ b/src/lib/measurements/day-aggregates.ts @@ -63,6 +63,13 @@ export interface ReadDayAggregatesOptions { since: Date; /** Inclusive upper bound on `measuredAt`; omit for no upper bound. */ until?: Date; + /** + * Keep only readings taken at or before this local clock time + * (`HH:MM:SS`, in `timeZone`) on their own day. Comparing a day still in + * progress against earlier days needs the same hours on both sides: by + * ten in the morning, today's mean has seen breakfast and nothing after. + */ + upToLocalTime?: string; /** IANA zone the day boundary is cut in. */ timeZone: string; /** Rows outside `[min, max]` are dropped before the fold. */ @@ -73,13 +80,21 @@ export interface ReadDayAggregatesOptions { db?: Pick; } +/** `HH:MM` or `HH:MM:SS`, 24-hour. */ +const LOCAL_CLOCK = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/; + export async function readDayAggregates( opts: ReadDayAggregatesOptions, ): Promise { const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC"; - const until = opts.until + const untilInstant = opts.until ? Prisma.sql`AND m."measured_at" <= ${opts.until}` : Prisma.empty; + const clock = + opts.upToLocalTime && LOCAL_CLOCK.test(opts.upToLocalTime) + ? Prisma.sql`AND ((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone})::time <= ${opts.upToLocalTime}::time` + : Prisma.empty; + const until = Prisma.sql`${untilInstant} ${clock}`; const range = opts.valueRange ? Prisma.sql`AND m."value" >= ${opts.valueRange.min} AND m."value" <= ${opts.valueRange.max}` : Prisma.empty; diff --git a/tests/integration/dense-read-bounded.test.ts b/tests/integration/dense-read-bounded.test.ts index 8fefb1505..a77ec520c 100644 --- a/tests/integration/dense-read-bounded.test.ts +++ b/tests/integration/dense-read-bounded.test.ts @@ -39,6 +39,7 @@ vi.mock("@/lib/db-compat", () => ({ const prisma = getPrismaClient(); const DENSE_USER = "dense-pulse-owner"; const PARITY_USER = "dense-parity-owner"; +const CLOCK_USER = "dense-clock-owner"; const DAYS = 91; const ROWS = DAYS * 24 * 60; const NOW = new Date("2026-09-20T18:00:00.000Z"); @@ -232,4 +233,53 @@ describe("pulse status over a dense stream (#1023)", () => { // Sanity: the impossible value was dropped and the edge row split off. expect(sql.reduce((n, r) => n + r.n, 0)).toBe(5); }); + + it("the local-clock cut agrees with the in-memory fold on real Postgres", async () => { + // A half-hour zone, so a cut on the UTC clock would land 30 minutes off. + const tz = "Asia/Kolkata"; + await prisma.user.create({ + data: { id: CLOCK_USER, username: CLOCK_USER, timezone: tz }, + }); + const days = [ + new Date("2026-09-08T12:00:00Z"), + new Date("2026-09-09T12:00:00Z"), + new Date("2026-09-10T12:00:00Z"), + ]; + const rows = days.flatMap((d, i) => [ + { at: localHmAsUtc(d, tz, 7, 0), value: 85 + i }, + { at: localHmAsUtc(d, tz, 9, 59), value: 120 + i }, + { at: localHmAsUtc(d, tz, 10, 1), value: 160 + i }, // after the cut + { at: localHmAsUtc(d, tz, 19, 0), value: 150 + i }, // after the cut + ]); + await prisma.measurement.createMany({ + data: rows.map((r, i) => ({ + id: `clock-${i}`, + userId: CLOCK_USER, + type: "BLOOD_GLUCOSE" as const, + value: r.value, + unit: "mg/dL", + source: "MANUAL" as const, + measuredAt: r.at, + })), + }); + const opts = { + since: new Date(NOW.getTime() - 400 * 86_400_000), + until: NOW, + timeZone: tz, + upToLocalTime: "10:00:00", + }; + const sql = await readDayAggregates({ + userId: CLOCK_USER, + type: "BLOOD_GLUCOSE", + ...opts, + }); + const memory = foldDayAggregates( + rows.map((r) => ({ measuredAt: r.at, value: r.value })), + opts, + ); + expect(sql).toEqual(memory); + // Two readings per day survive the cut, the two after it do not. + expect(sql.map((r) => r.n)).toEqual([2, 2, 2]); + expect(sql.map((r) => r.max)).toEqual([120, 121, 122]); + }); });