mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user