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:
Marc-André Bombeck committed 2026-11-04 00:12:49 +01:00
1 parent 41e75b5bde
commit b93241aa87
32 files changed
+2184 -43

No files matched your search

@@ -0,0 +1,47 @@
/**
* GET /api/measurements/blood-pressure/time-of-day?days=
*
* Blood pressure by part of the day (#1331): morning, midday and evening
* means of the paired readings in the window, each with its count, and the
* morning-minus-evening difference. Every figure is resolved here, in the
* record's own time zone, so the web and the iOS app show the same numbers
* without bucketing anything themselves. The rule is in
* `src/lib/analytics/bp-time-of-day.ts`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readBpTimeOfDay } from "@/lib/measurements/series/read-bp-time-of-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { bpTimeOfDayQuerySchema } from "@/lib/validations/bp-time-of-day";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
const parsed = bpTimeOfDayQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "measurements.bp-time-of-day.validation-failed" },
});
return returnAllZodIssues(parsed.error, 422);
}
const summary = await readBpTimeOfDay({
userId: user.id,
days: parsed.data.days,
timeZone: await resolveUserTimezone(user.id),
});
annotate({
action: { name: "measurements.bp-time-of-day.read" },
meta: {
days: summary.days,
reading_count: summary.readingCount,
has_difference: summary.morningEveningDifference !== null,
},
});
return apiSuccess(summary);
});
+13 -1
View File
@@ -20,6 +20,8 @@ import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { useBpTargetBand } from "@/lib/queries/use-bp-target-band";
import { BpTimeOfDayCard } from "@/components/insights/blood-pressure/bp-time-of-day-card";
import { useBpTimeOfDayState } from "@/components/insights/blood-pressure/use-bp-time-of-day-state";
/**
* v1.4.25 — `/insights/blood-pressure`.
@@ -64,6 +66,9 @@ export default function InsightsBlutdruckPage() {
const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats();
const bpTargets = useBpTargetBand();
// #1331 — the chart narrowed to one part of the day (URL state).
const timeOfDay = useBpTimeOfDayState();
if (isEmpty) {
return (
<SubPageShell
@@ -171,7 +176,7 @@ export default function InsightsBlutdruckPage() {
<HealthChartDynamic
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
title={t(timeOfDay.chartTitleKey ?? "charts.bloodPressure")}
titleIcon={HeartPulse}
colors={["var(--chart-3)", "var(--chart-4)"]}
unit="mmHg"
@@ -182,6 +187,13 @@ export default function InsightsBlutdruckPage() {
onVisibleStats={onVisibleStats}
showDataTable
dayLinks
partOfDay={timeOfDay.partOfDay}
/>
<BpTimeOfDayCard
days={timeOfDay.days}
chartPart={timeOfDay.partOfDay ?? "all"}
onChartPartChange={timeOfDay.setChartPart}
/>
{/* v1.12.4 — target card sits between the chart and the assessment on