mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so the blood pressure page now shows the morning, midday and evening means side by side over the chart's range, each with its count and pulse, and the morning-minus-evening difference. A control under them narrows the chart to one part of the day; the choice lives in `?partOfDay=` so it survives a reload and a shared link, and the whole day keeps a bare URL. The split is resolved on the server in the record's own time zone and published as one response, `GET /api/measurements/blood-pressure/time-of-day`, so every client renders the same numbers. Morning is 04:00 to 11:59, midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the sets by waking and bedtime rather than by the clock, so the windows are wide, and a reading after midnight is a late evening one. Pairs follow the blood-pressure series' rule (same source within five minutes); the pulse is the reading's own. A part shows a mean from three readings on. `GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse series read: each local day folds only that part's readings, a day runs from 04:00, and long ranges fold weekly instead of reading rollups that cannot tell the hour. Refs #1331
This commit is contained in:
1 parent
41e75b5bde
commit
b93241aa87
32 files changed
+2184
-43
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BP_PART_MIN_READINGS,
|
||||
bpPartOfDayForHour,
|
||||
computeBpTimeOfDay,
|
||||
type BpTimedReading,
|
||||
} from "../bp-time-of-day";
|
||||
|
||||
const reading = (
|
||||
iso: string,
|
||||
systolic = 130,
|
||||
diastolic = 80,
|
||||
pulse: number | null = null,
|
||||
): BpTimedReading => ({ at: new Date(iso), systolic, diastolic, pulse });
|
||||
|
||||
/** The part one reading lands in, in `tz`. */
|
||||
function partOf(iso: string, tz: string) {
|
||||
const summary = computeBpTimeOfDay([reading(iso)], tz);
|
||||
return summary.parts.find((p) => p.readingCount === 1)?.part;
|
||||
}
|
||||
|
||||
describe("bpPartOfDayForHour", () => {
|
||||
it("tiles the day at 04, 12 and 18 with evening wrapping midnight", () => {
|
||||
const parts = Array.from({ length: 24 }, (_, h) => bpPartOfDayForHour(h));
|
||||
expect(parts).toEqual([
|
||||
...Array(4).fill("evening"), // 00..03
|
||||
...Array(8).fill("morning"), // 04..11
|
||||
...Array(6).fill("midday"), // 12..17
|
||||
...Array(6).fill("evening"), // 18..23
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBpTimeOfDay — boundaries", () => {
|
||||
it.each([
|
||||
["2026-06-01T03:59:59.999Z", "evening"],
|
||||
["2026-06-01T04:00:00.000Z", "morning"],
|
||||
["2026-06-01T11:59:59.999Z", "morning"],
|
||||
["2026-06-01T12:00:00.000Z", "midday"],
|
||||
["2026-06-01T17:59:59.999Z", "midday"],
|
||||
["2026-06-01T18:00:00.000Z", "evening"],
|
||||
["2026-06-01T00:00:00.000Z", "evening"],
|
||||
])("%s is %s in UTC", (iso, part) => {
|
||||
expect(partOf(iso, "UTC")).toBe(part);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBpTimeOfDay — time zones", () => {
|
||||
it("reads the hour in the zone it is given", () => {
|
||||
const iso = "2026-06-01T09:00:00.000Z";
|
||||
expect(partOf(iso, "UTC")).toBe("morning");
|
||||
expect(partOf(iso, "America/New_York")).toBe("morning"); // 05:00
|
||||
expect(partOf(iso, "Asia/Tokyo")).toBe("evening"); // 18:00
|
||||
expect(partOf(iso, "Asia/Kolkata")).toBe("midday"); // 14:30
|
||||
});
|
||||
|
||||
it("follows the autumn change: 02:00 UTC on 25 Oct is 03:00 in Berlin", () => {
|
||||
// A fixed summer offset (+2) would call this 04:00 and a morning reading.
|
||||
expect(partOf("2026-10-25T02:00:00.000Z", "Europe/Berlin")).toBe("evening");
|
||||
expect(partOf("2026-10-25T03:00:00.000Z", "Europe/Berlin")).toBe("morning");
|
||||
// Both 02:30s of the repeated hour are the same night's evening.
|
||||
expect(partOf("2026-10-25T00:30:00.000Z", "Europe/Berlin")).toBe("evening");
|
||||
expect(partOf("2026-10-25T01:30:00.000Z", "Europe/Berlin")).toBe("evening");
|
||||
});
|
||||
|
||||
it("follows the spring change: 02:00 UTC on 29 Mar is 04:00 in Berlin", () => {
|
||||
// A fixed winter offset (+1) would call this 03:00 and an evening reading.
|
||||
expect(partOf("2026-03-29T02:00:00.000Z", "Europe/Berlin")).toBe("morning");
|
||||
expect(partOf("2026-03-29T01:59:00.000Z", "Europe/Berlin")).toBe("evening");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBpTimeOfDay — the reading floor and the difference", () => {
|
||||
const morning = (n: number, sys: number, dia: number, pulse?: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
reading(`2026-06-0${i + 1}T07:00:00.000Z`, sys, dia, pulse ?? null),
|
||||
);
|
||||
const evening = (n: number, sys: number, dia: number) =>
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
reading(`2026-06-0${i + 1}T21:00:00.000Z`, sys, dia),
|
||||
);
|
||||
|
||||
it("gives no mean below the floor, only the count", () => {
|
||||
const summary = computeBpTimeOfDay(
|
||||
morning(BP_PART_MIN_READINGS - 1, 140, 90),
|
||||
"UTC",
|
||||
);
|
||||
const part = summary.parts[0];
|
||||
expect(part).toMatchObject({
|
||||
part: "morning",
|
||||
readingCount: BP_PART_MIN_READINGS - 1,
|
||||
systolic: null,
|
||||
diastolic: null,
|
||||
});
|
||||
expect(summary.morningEveningDifference).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the mean at the floor", () => {
|
||||
const summary = computeBpTimeOfDay(
|
||||
morning(BP_PART_MIN_READINGS, 140, 90),
|
||||
"UTC",
|
||||
);
|
||||
expect(summary.parts[0]).toMatchObject({ systolic: 140, diastolic: 90 });
|
||||
});
|
||||
|
||||
it("returns all three parts in order, empty ones included", () => {
|
||||
const summary = computeBpTimeOfDay([], "UTC");
|
||||
expect(summary.parts.map((p) => p.part)).toEqual([
|
||||
"morning",
|
||||
"midday",
|
||||
"evening",
|
||||
]);
|
||||
expect(summary.readingCount).toBe(0);
|
||||
expect(summary.parts.every((p) => p.readingCount === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the difference from the two shown whole-mmHg means", () => {
|
||||
// Morning means 135.4 / 85.6 → 135 / 86; evening 128.6 / 80.4 → 129 / 80.
|
||||
const m = [
|
||||
reading("2026-06-01T07:00:00.000Z", 135, 85),
|
||||
reading("2026-06-02T07:00:00.000Z", 135, 86),
|
||||
reading("2026-06-03T07:00:00.000Z", 135, 86),
|
||||
reading("2026-06-04T07:00:00.000Z", 136, 85),
|
||||
reading("2026-06-05T07:00:00.000Z", 136, 86),
|
||||
];
|
||||
const e = [
|
||||
reading("2026-06-01T21:00:00.000Z", 128, 80),
|
||||
reading("2026-06-02T21:00:00.000Z", 129, 80),
|
||||
reading("2026-06-03T21:00:00.000Z", 129, 81),
|
||||
reading("2026-06-04T21:00:00.000Z", 128, 80),
|
||||
reading("2026-06-05T21:00:00.000Z", 129, 81),
|
||||
];
|
||||
const summary = computeBpTimeOfDay([...m, ...e], "UTC");
|
||||
const [mo, , ev] = summary.parts;
|
||||
expect([mo.systolic, mo.diastolic, ev.systolic, ev.diastolic]).toEqual([
|
||||
135, 86, 129, 80,
|
||||
]);
|
||||
expect(summary.morningEveningDifference).toEqual({
|
||||
systolic: 6,
|
||||
diastolic: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("is negative when the evening is higher", () => {
|
||||
const summary = computeBpTimeOfDay(
|
||||
[...morning(3, 120, 75), ...evening(3, 131, 79)],
|
||||
"UTC",
|
||||
);
|
||||
expect(summary.morningEveningDifference).toEqual({
|
||||
systolic: -11,
|
||||
diastolic: -4,
|
||||
});
|
||||
});
|
||||
|
||||
it("averages the pulse only over readings that carried one, on the same floor", () => {
|
||||
const rows = [
|
||||
...morning(2, 130, 80, 70),
|
||||
reading("2026-06-05T07:00:00.000Z", 130, 80, null),
|
||||
];
|
||||
const below = computeBpTimeOfDay(rows, "UTC").parts[0];
|
||||
expect(below).toMatchObject({ pulseReadingCount: 2, pulse: null });
|
||||
const at = computeBpTimeOfDay(
|
||||
[...rows, reading("2026-06-06T07:00:00.000Z", 130, 80, 73)],
|
||||
"UTC",
|
||||
).parts[0];
|
||||
expect(at).toMatchObject({ pulseReadingCount: 3, pulse: 71 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Blood pressure by part of the day (#1331).
|
||||
*
|
||||
* Home blood-pressure monitoring is read as a morning set and an evening set:
|
||||
* the ESH position paper on home monitoring (Parati et al., J Hypertens 2021;
|
||||
* 39:1742, Box 15) asks for "duplicate morning and evening measurements",
|
||||
* the morning one "before drug intake if treated", over 3 to 7 days, and the
|
||||
* AHA statement on blood-pressure measurement (Muntner et al., Hypertension
|
||||
* 2019;73:e35) likewise asks for a morning set before medication and an
|
||||
* evening set. Neither names clock hours, and national guidelines disagree on
|
||||
* the evening (before the evening meal, after it, or before bed): the parts
|
||||
* are defined by what the person does, not by the clock.
|
||||
*
|
||||
* The boundaries below are therefore wide windows that catch those readings
|
||||
* for ordinary schedules, cut at the hour in the person's own zone:
|
||||
*
|
||||
* morning 04:00 to 11:59 waking and before the first dose, early risers
|
||||
* and late sleepers alike;
|
||||
* midday 12:00 to 17:59 readings that are neither, kept apart so they do
|
||||
* not blur either set;
|
||||
* evening 18:00 to 03:59 before supper through to a late bedtime: a reading
|
||||
* at 01:00 is the evening reading of a night owl,
|
||||
* never a morning one.
|
||||
*
|
||||
* So the day of a reading, for this view, starts at 04:00 local
|
||||
* ({@link BP_DAY_START_HOUR}); an evening reading taken after midnight belongs
|
||||
* to the evening before. The SQL fold behind the narrowed chart uses the same
|
||||
* hours and the same day start, so the averages and the narrowed chart cut
|
||||
* the readings at one place.
|
||||
*
|
||||
* The numbers are the person's own data side by side. Whether a difference
|
||||
* matters is for them and their clinician; nothing here grades it.
|
||||
*/
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
|
||||
export const BP_PARTS_OF_DAY = ["morning", "midday", "evening"] as const;
|
||||
export type BpPartOfDay = (typeof BP_PARTS_OF_DAY)[number];
|
||||
|
||||
/** `[startHour, endHour)` in local wall-clock hours; evening wraps midnight. */
|
||||
export const BP_PART_HOURS: Readonly<
|
||||
Record<BpPartOfDay, { startHour: number; endHour: number }>
|
||||
> = {
|
||||
morning: { startHour: 4, endHour: 12 },
|
||||
midday: { startHour: 12, endHour: 18 },
|
||||
evening: { startHour: 18, endHour: 4 },
|
||||
};
|
||||
|
||||
/** The local hour a blood-pressure day starts at (the morning's first hour). */
|
||||
export const BP_DAY_START_HOUR = BP_PART_HOURS.morning.startHour;
|
||||
|
||||
/**
|
||||
* Readings a part needs before its mean is shown. Three is the smallest
|
||||
* schedule the ESH paper accepts (three days); fewer is shown as a count
|
||||
* without a mean rather than as a number that one reading can swing.
|
||||
*/
|
||||
export const BP_PART_MIN_READINGS = 3;
|
||||
|
||||
/** The part a local wall-clock hour (0..23) falls in. */
|
||||
export function bpPartOfDayForHour(hour: number): BpPartOfDay {
|
||||
for (const part of BP_PARTS_OF_DAY) {
|
||||
const { startHour, endHour } = BP_PART_HOURS[part];
|
||||
const inside =
|
||||
startHour < endHour
|
||||
? hour >= startHour && hour < endHour
|
||||
: hour >= startHour || hour < endHour;
|
||||
if (inside) return part;
|
||||
}
|
||||
// Unreachable for 0..23: the three windows tile the day.
|
||||
throw new Error(`hour out of range: ${hour}`);
|
||||
}
|
||||
|
||||
/** One paired reading: systolic and diastolic, with the pulse taken with it. */
|
||||
export interface BpTimedReading {
|
||||
at: Date;
|
||||
systolic: number;
|
||||
diastolic: number;
|
||||
pulse: number | null;
|
||||
}
|
||||
|
||||
export interface BpPartSummary {
|
||||
part: BpPartOfDay;
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
/** Paired readings in this part over the window. */
|
||||
readingCount: number;
|
||||
/** Whole-mmHg means; `null` below {@link BP_PART_MIN_READINGS}. */
|
||||
systolic: number | null;
|
||||
diastolic: number | null;
|
||||
/** Readings in this part that carried a pulse. */
|
||||
pulseReadingCount: number;
|
||||
/** Whole-bpm mean; `null` below the same floor of pulse readings. */
|
||||
pulse: number | null;
|
||||
}
|
||||
|
||||
export interface BpTimeOfDaySummary {
|
||||
timeZone: string;
|
||||
minReadings: number;
|
||||
/** Every paired reading in the window, across the three parts. */
|
||||
readingCount: number;
|
||||
parts: BpPartSummary[];
|
||||
/**
|
||||
* Morning minus evening, from the two shown means (so the figure on screen
|
||||
* is exactly the difference of the two figures beside it). `null` unless
|
||||
* both parts clear the floor.
|
||||
*/
|
||||
morningEveningDifference: { systolic: number; diastolic: number } | null;
|
||||
}
|
||||
|
||||
function mean(values: number[]): number {
|
||||
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/** Bucket paired readings by the local hour they were taken at, in `timeZone`. */
|
||||
export function computeBpTimeOfDay(
|
||||
readings: readonly BpTimedReading[],
|
||||
timeZone: string,
|
||||
): BpTimeOfDaySummary {
|
||||
const byPart = new Map<BpPartOfDay, BpTimedReading[]>(
|
||||
BP_PARTS_OF_DAY.map((part) => [part, []]),
|
||||
);
|
||||
for (const reading of readings) {
|
||||
const { hour } = wallClockInTz(reading.at, timeZone);
|
||||
byPart.get(bpPartOfDayForHour(hour))!.push(reading);
|
||||
}
|
||||
|
||||
const parts = BP_PARTS_OF_DAY.map<BpPartSummary>((part) => {
|
||||
const rows = byPart.get(part)!;
|
||||
const enough = rows.length >= BP_PART_MIN_READINGS;
|
||||
const pulses = rows
|
||||
.map((r) => r.pulse)
|
||||
.filter((p): p is number => p !== null);
|
||||
return {
|
||||
part,
|
||||
...BP_PART_HOURS[part],
|
||||
readingCount: rows.length,
|
||||
systolic: enough ? Math.round(mean(rows.map((r) => r.systolic))) : null,
|
||||
diastolic: enough ? Math.round(mean(rows.map((r) => r.diastolic))) : null,
|
||||
pulseReadingCount: pulses.length,
|
||||
pulse:
|
||||
pulses.length >= BP_PART_MIN_READINGS ? Math.round(mean(pulses)) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const morning = parts.find((p) => p.part === "morning")!;
|
||||
const evening = parts.find((p) => p.part === "evening")!;
|
||||
const morningEveningDifference =
|
||||
morning.systolic !== null &&
|
||||
morning.diastolic !== null &&
|
||||
evening.systolic !== null &&
|
||||
evening.diastolic !== null
|
||||
? {
|
||||
systolic: morning.systolic - evening.systolic,
|
||||
diastolic: morning.diastolic - evening.diastolic,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
timeZone,
|
||||
minReadings: BP_PART_MIN_READINGS,
|
||||
readingCount: readings.length,
|
||||
parts,
|
||||
morningEveningDifference,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user