mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listMeasurementsSchema } from "../measurement";
|
||||
import { bpTimeOfDayQuerySchema } from "../bp-time-of-day";
|
||||
|
||||
const SERIES = {
|
||||
aggregate: "daily",
|
||||
source: "rollup",
|
||||
from: "2026-06-01T00:00:00.000Z",
|
||||
to: "2026-06-30T23:59:59.999Z",
|
||||
};
|
||||
|
||||
describe("listMeasurementsSchema partOfDay", () => {
|
||||
it.each(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"])(
|
||||
"accepts it on the %s series read",
|
||||
(type) => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
...SERIES,
|
||||
type,
|
||||
partOfDay: "morning",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["another type", { ...SERIES, type: "WEIGHT" }],
|
||||
["no type", { ...SERIES }],
|
||||
["the plain list", { type: "BLOOD_PRESSURE_SYS" }],
|
||||
["a live aggregate", { ...SERIES, type: "PULSE", source: undefined }],
|
||||
["no window", { type: "PULSE", aggregate: "daily", source: "rollup" }],
|
||||
])("refuses it on %s", (_label, query) => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
...query,
|
||||
partOfDay: "evening",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error?.issues[0]?.path).toEqual(["partOfDay"]);
|
||||
});
|
||||
|
||||
it("refuses a part it does not know", () => {
|
||||
const parsed = listMeasurementsSchema.safeParse({
|
||||
...SERIES,
|
||||
type: "PULSE",
|
||||
partOfDay: "night",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the query untouched without it", () => {
|
||||
expect(
|
||||
listMeasurementsSchema.safeParse({ ...SERIES, type: "WEIGHT" }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bpTimeOfDayQuerySchema", () => {
|
||||
it("defaults to 30 days and bounds the window", () => {
|
||||
expect(bpTimeOfDayQuerySchema.parse({})).toEqual({ days: 30 });
|
||||
expect(bpTimeOfDayQuerySchema.parse({ days: "3650" })).toEqual({
|
||||
days: 3650,
|
||||
});
|
||||
expect(bpTimeOfDayQuerySchema.safeParse({ days: "0" }).success).toBe(false);
|
||||
expect(bpTimeOfDayQuerySchema.safeParse({ days: "3651" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(bpTimeOfDayQuerySchema.safeParse({ days: "1.5" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Query of `GET /api/measurements/blood-pressure/time-of-day` (#1331).
|
||||
* Shared by the route and the OpenAPI registry, so the contract has one
|
||||
* definition.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const bpTimeOfDayQuerySchema = z.object({
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(3650)
|
||||
.optional()
|
||||
.default(30)
|
||||
.describe(
|
||||
"Window in local days, ending with today in the record's time zone (1..3650, default 30): the window the chart draws for the same range.",
|
||||
),
|
||||
});
|
||||
@@ -247,6 +247,7 @@ export { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
// the client's value-domain reads (the chart floor, the integer set) do not
|
||||
// pull Zod into every metric page.
|
||||
import { validateMeasurementRange } from "@/lib/measurements/value-ranges";
|
||||
import { BP_PARTS_OF_DAY } from "@/lib/analytics/bp-time-of-day";
|
||||
export {
|
||||
VALUE_RANGES,
|
||||
validateMeasurementRange,
|
||||
@@ -352,6 +353,13 @@ export const listPersonalRecordsSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional().default(100),
|
||||
});
|
||||
|
||||
/** The series `partOfDay` narrows: blood pressure and the pulse taken with it. */
|
||||
const PART_OF_DAY_TYPES: ReadonlySet<string> = new Set([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
]);
|
||||
|
||||
export const listMeasurementsSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum.optional(),
|
||||
@@ -440,6 +448,15 @@ export const listMeasurementsSchema = z
|
||||
"dayKey must be a real calendar date (YYYY-MM-DD)",
|
||||
)
|
||||
.optional(),
|
||||
// #1331 — narrow the chart's series to one part of the day (the
|
||||
// blood-pressure hours in `bp-time-of-day.ts`). A series read only: each
|
||||
// local day folds just the readings taken in that part.
|
||||
partOfDay: z
|
||||
.enum(BP_PARTS_OF_DAY)
|
||||
.optional()
|
||||
.describe(
|
||||
"Narrow the series to one part of the day in the record's zone: morning 04:00 to 11:59, midday 12:00 to 17:59, evening 18:00 to 03:59. A day runs from 04:00, so an evening reading after midnight counts on the evening before. Only for `BLOOD_PRESSURE_SYS`, `BLOOD_PRESSURE_DIA` or `PULSE` with `aggregate=daily`, `source=rollup`, `from` and `to`; anything else is refused with 422.",
|
||||
),
|
||||
})
|
||||
.refine(({ offset, dayKey }) => !(offset > 0 && dayKey != null), {
|
||||
message:
|
||||
@@ -499,6 +516,25 @@ export const listMeasurementsSchema = z
|
||||
"appleHealthMirror filters the plain row list only; combine it with type, from, to, sourceEq and paging",
|
||||
path: ["appleHealthMirror"],
|
||||
},
|
||||
)
|
||||
// The part-of-day hours are the blood-pressure reading windows, so the
|
||||
// filter is served for the blood-pressure series and the pulse taken with
|
||||
// it, on the chart's series read, and refused anywhere it would mean
|
||||
// something else.
|
||||
.refine(
|
||||
({ partOfDay, type, source, aggregate, from, to }) =>
|
||||
partOfDay === undefined ||
|
||||
(type !== undefined &&
|
||||
PART_OF_DAY_TYPES.has(type) &&
|
||||
source === "rollup" &&
|
||||
aggregate === "daily" &&
|
||||
from !== undefined &&
|
||||
to !== undefined),
|
||||
{
|
||||
message:
|
||||
"partOfDay narrows the blood-pressure or pulse series; combine it with type, from, to, aggregate=daily and source=rollup",
|
||||
path: ["partOfDay"],
|
||||
},
|
||||
);
|
||||
|
||||
export const createBatchMeasurementSchema = z.object({
|
||||
|
||||
Reference in new issue
Block a user