fix(insights): compare a glucose day in progress with the same hours

Today's glucose mean was held against a band built from whole days. By
mid-morning the day holds a fasting reading and nothing after, so an
ordinary morning read as "below your range". A glucose day still in
progress is now compared with the earlier days cut at the same local clock
time, read in SQL so a dense stream is averaged whole rather than through
its newest fifty rows. With fewer than seven earlier days that have
readings in those hours, today gets no verdict. The day-aggregate reader
gains the local-clock cut; an integration case pins it against the
in-memory fold in a half-hour zone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 12:40:55 +02:00
1 parent 7d42cf7122
commit a6d841d3ae
5 files changed
+292 -14

No files matched your search

@@ -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();
});
});
+115 -13
View File
@@ -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<MeasurementType> = 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,
),
);
}
@@ -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<ReadDayAggregatesOptions, "userId" | "type">,
@@ -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)
+16 -1
View File
@@ -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<PrismaClient, "$queryRaw">;
}
/** `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<DayAggregateRow[]> {
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;
@@ -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]);
});
});