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

+5 -1
View File
@@ -908,6 +908,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.",
},
"app/api/measurements/blood-pressure/time-of-day/route.ts": {
domain: "measurements",
why: "The record's blood-pressure readings averaged by part of the day. The series read and the pulse lookup both bind the resolved user id, the hours are read in the record's own zone, and the route holds no write arm.",
},
"app/api/measurement-reminders/route.ts": {
domain: "measurements",
why: "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
@@ -2288,7 +2292,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* manage literal: it was refused under a switch while the medication's PUT,
* which used to carry the end, admitted a manager. 272 -> 274.
*/
const FROZEN_ENTRY_COUNT = 274;
const FROZEN_ENTRY_COUNT = 275;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -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
@@ -8,11 +8,14 @@
* chart folds rows into days either way, so the daily answer draws the same
* points while its size depends only on the number of days.
*
* `partOfDay` narrows each day to the readings of one part of it (#1331).
*
* Pure so the request the chart makes can be tested against the real route.
*/
export function chartSeriesParams(
type: string,
window: { from: string; to: string },
partOfDay?: string,
): URLSearchParams {
const params = new URLSearchParams();
params.set("type", type);
@@ -23,5 +26,6 @@ export function chartSeriesParams(
params.set("limit", "5000");
params.set("aggregate", "daily");
params.set("source", "rollup");
if (partOfDay) params.set("partOfDay", partOfDay);
return params;
}
+5 -5
View File
@@ -101,10 +101,10 @@ export function HealthChart({
preloadedCoverageDays,
showDataTable = false,
dayLinks = false,
partOfDay,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
// A mount without the prop used to pin Europe/Berlin, so those charts cut
// days in Berlin for every user whatever their profile said.
// Never a fixed Berlin: a mount without the prop cuts the profile's days.
const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE;
const { t, locale } = useTranslations();
const fmt = useFormatters();
@@ -128,9 +128,8 @@ export function HealthChart({
: DEFAULT_CHART_RANGE;
const [rangePoints, setRangePoints] = useState(initialRangePoints);
// The overlay toggles and the comparison persist per chart; an unbound
// chart (mini mode, ad-hoc use) gets the clean-line default and skips the
// layout read.
// Overlays and comparison persist per chart; an unbound chart (mini mode,
// ad-hoc use) gets the clean-line default and skips the layout read.
const overlayPrefs = useChartOverlayPrefs(chartKey);
const showMA = overlayPrefs.prefs.showTrendIndicator;
const showTrend = overlayPrefs.prefs.showTrendArrow;
@@ -184,6 +183,7 @@ export function HealthChart({
preloadedCoverageDays,
enabled: isAuthenticated,
onDataReady,
partOfDay,
});
const {
@@ -1,3 +1,4 @@
import type { BpPartOfDay } from "@/lib/analytics/bp-time-of-day";
import type { ComponentType } from "react";
import type {
@@ -138,6 +139,11 @@ export interface HealthChartProps {
* anything. Only while drawn in days, never in `mini` mode.
*/
dayLinks?: boolean;
/**
* Draw each day from the readings of one part of it only (#1331, the
* blood-pressure hours in `bp-time-of-day.ts`). Unset draws the whole day.
*/
partOfDay?: BpPartOfDay;
}
export interface ChartDataPoint {
@@ -48,6 +48,7 @@ async function readDailySeries({
valueOffset,
dayKeyFormatter,
tzFmt,
partOfDay,
}: {
types: string[];
fetchWindow: ReturnType<typeof openEndedFetchWindow>;
@@ -57,6 +58,7 @@ async function readDailySeries({
valueOffset: number;
dayKeyFormatter: Intl.DateTimeFormat;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
partOfDay?: string;
}): Promise<ChartDataPoint[]> {
const dailyAggregates = new Map<
string,
@@ -109,7 +111,7 @@ async function readDailySeries({
await fetchSleepNights();
return;
}
const typeParams = chartSeriesParams(type, fetchWindow);
const typeParams = chartSeriesParams(type, fetchWindow, partOfDay);
// The batched slice reads through the same rollup path as the
// per-type read, so the bucketed result is identical. A covered type
@@ -210,6 +212,8 @@ async function readDailySeries({
* On the dashboard the batched slice stands in for the per-type reads, but
* only for windows over 7 days that fit the batch's coverage and only when
* every non-sleep type is in it; sleep always reads its own per-night series.
* A series narrowed to a part of the day (`partOfDay`) always reads its own:
* the batched slice is whole days.
*/
export function useHealthChartSeries({
types,
@@ -226,6 +230,7 @@ export function useHealthChartSeries({
preloadedCoverageDays,
enabled,
onDataReady,
partOfDay,
}: {
types: string[];
valueMode: "raw" | "bmi";
@@ -241,6 +246,7 @@ export function useHealthChartSeries({
preloadedCoverageDays?: number;
enabled: boolean;
onDataReady?: () => void;
partOfDay?: string;
}) {
const todayKey = localDayKeyFor(new Date(), userTimezone);
const fetchWindow = useMemo(() => {
@@ -261,14 +267,20 @@ export function useHealthChartSeries({
// Without an explicit coverage (a legacy mount) the slice is bypassed
// rather than trusted blindly.
const usePreloaded = useMemo(() => {
if (!preloadedSeries) return false;
if (!preloadedSeries || partOfDay) return false;
if (fetchWindow.windowDays <= 7) return false;
const coverage = preloadedCoverageDays ?? 0;
if (fetchWindow.windowDays > coverage) return false;
return types.every(
(type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null,
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
}, [
preloadedSeries,
preloadedCoverageDays,
fetchWindow.windowDays,
types,
partOfDay,
]);
const chartQuery = useQuery({
// A range-tab change re-keys the query; without placeholder data every
@@ -291,6 +303,7 @@ export function useHealthChartSeries({
fetchWindow.to,
valueScale,
preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "",
partOfDay ?? "",
),
// A minute of freshness so moving between metric pages does not refire
// every chart; five minutes of cache for a quick back-and-forth.
@@ -306,6 +319,7 @@ export function useHealthChartSeries({
valueOffset,
dayKeyFormatter,
tzFmt,
partOfDay,
}),
// The BMI view: the cached raw WEIGHT series divided at read time, so the
// entry is shared with the weight chart. No height → no series. The range
@@ -0,0 +1,176 @@
/**
* The blood-pressure time-of-day card in a real browser (#1331): the server's
* numbers rendered as given, the narrowing control as a keyboard radiogroup,
* and the narrowed part in the address bar, so it survives a reload and a
* shared link while the whole day keeps a bare URL.
*/
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import { stubFetch } from "../../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../../tests/browser/render";
import {
currentSearch,
recordHistory,
setSearch,
} from "../../../../../tests/browser/url";
import { BpTimeOfDayCard } from "../bp-time-of-day-card";
import { useBpTimeOfDayState } from "../use-bp-time-of-day-state";
const SUMMARY = {
days: 90,
from: "2026-08-05T22:00:00.000Z",
to: "2026-11-03T22:59:59.999Z",
timeZone: "Europe/Berlin",
minReadings: 3,
readingCount: 15,
parts: [
{
part: "morning",
startHour: 4,
endHour: 12,
readingCount: 6,
systolic: 140,
diastolic: 90,
pulseReadingCount: 6,
pulse: 72,
},
{
part: "midday",
startHour: 12,
endHour: 18,
readingCount: 2,
systolic: null,
diastolic: null,
pulseReadingCount: 0,
pulse: null,
},
{
part: "evening",
startHour: 18,
endHour: 4,
readingCount: 7,
systolic: 131,
diastolic: 82,
pulseReadingCount: 6,
pulse: 66,
},
],
morningEveningDifference: { systolic: 9, diastolic: 8 },
};
/** The page's wiring: the state hook feeding the card, the chart's part shown. */
function Probe() {
const state = useBpTimeOfDayState();
return (
<>
<p data-testid="chart-part">{state.partOfDay ?? "all"}</p>
<BpTimeOfDayCard
days={state.days}
chartPart={state.partOfDay ?? "all"}
onChartPartChange={state.setChartPart}
/>
</>
);
}
function network() {
return stubFetch()
.on("GET", "/api/dashboard/widgets", {
data: { chartOverlayPrefs: { bp: { rangePoints: 90 } } },
})
.on("GET", "/api/measurements/blood-pressure/time-of-day", {
data: SUMMARY,
});
}
async function mount(search = "") {
setSearch(search);
const net = network();
const history = recordHistory();
const { screen } = await renderWithApp(<Probe />);
const radio = (name: string) => screen.getByRole("radio", { name });
return { screen, net, history, radio };
}
describe("BpTimeOfDayCard", () => {
it("renders the server's figures for the chart's range", async () => {
const { screen, net } = await mount();
await expect.element(screen.getByText("140/90")).toBeVisible();
await expect.element(screen.getByText("131/82")).toBeVisible();
await expect.element(screen.getByText("Too few readings")).toBeVisible();
await expect.element(screen.getByText("Pulse 72 bpm")).toBeVisible();
await expect
.element(screen.getByText("Morning minus evening: +9/+8 mmHg."))
.toBeVisible();
await expect
.element(
screen.getByText(
"Morning 04:00–11:59, midday 12:00–17:59, evening 18:00–03:59, in your time zone.",
),
)
.toBeVisible();
// The chart's persisted 90-day tab decides the window.
await expect
.poll(() =>
net
.calls("GET", "/api/measurements/blood-pressure/time-of-day")
.map((r) => r.path),
)
.toContain("/api/measurements/blood-pressure/time-of-day?days=90");
});
it("opens on the part the address names", async () => {
const { screen, radio } = await mount("?partOfDay=evening");
await expect
.element(radio("Evening"))
.toHaveAttribute("aria-checked", "true");
await expect
.element(screen.getByTestId("chart-part"))
.toHaveTextContent("evening");
});
it("treats an unknown part as the whole day", async () => {
const { radio } = await mount("?partOfDay=night");
await expect
.element(radio("All day"))
.toHaveAttribute("aria-checked", "true");
});
it("narrows the chart into the URL as a new history entry and back to a bare URL", async () => {
const { screen, radio, history } = await mount();
await expect
.element(radio("All day"))
.toHaveAttribute("aria-checked", "true");
await radio("Morning").click();
await expect.poll(currentSearch).toBe("?partOfDay=morning");
expect(history.pushes()).toContain("?partOfDay=morning");
await expect
.element(screen.getByTestId("chart-part"))
.toHaveTextContent("morning");
await radio("All day").click();
await expect.poll(currentSearch).toBe("");
await expect
.element(screen.getByTestId("chart-part"))
.toHaveTextContent("all");
});
it("moves through the parts with the arrow keys", async () => {
const { radio } = await mount("?partOfDay=morning");
await expect
.element(radio("Morning"))
.toHaveAttribute("aria-checked", "true");
(radio("Morning").element() as HTMLElement).focus();
await userEvent.keyboard("{ArrowRight}");
await expect.poll(currentSearch).toBe("?partOfDay=midday");
await userEvent.keyboard("{ArrowRight}");
await expect.poll(currentSearch).toBe("?partOfDay=evening");
});
it("names the control for assistive technology", async () => {
const { screen } = await mount();
await expect
.element(screen.getByRole("radiogroup", { name: "Show in the chart" }))
.toBeVisible();
});
});
@@ -0,0 +1,217 @@
"use client";
import { Clock } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import type {
BpPartOfDay,
BpPartSummary,
} from "@/lib/analytics/bp-time-of-day";
import { pluralKey } from "@/lib/i18n/plural";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useBpTimeOfDay } from "@/lib/queries/use-bp-time-of-day";
import type { BpChartPart } from "./use-bp-time-of-day-state";
import { cn } from "@/lib/utils";
const PART_KEYS: Record<BpPartOfDay, string> = {
morning: "insights.bpTimeOfDay.part.morning",
midday: "insights.bpTimeOfDay.part.midday",
evening: "insights.bpTimeOfDay.part.evening",
};
/** `HH:00` to `HH:59` of a part, from the hours the server sent. */
function hourRange(part: BpPartSummary): { start: string; end: string } {
const pad = (n: number) => String(n).padStart(2, "0");
return {
start: `${pad(part.startHour)}:00`,
end: `${pad((part.endHour + 23) % 24)}:59`,
};
}
/**
* Blood pressure by part of the day (#1331): the morning, midday and evening
* means side by side, the morning-minus-evening difference, and the control
* that narrows the chart above to one part. Every number is the server's
* (`/api/measurements/blood-pressure/time-of-day`); a part below the reading
* floor shows its count and says so rather than a mean one reading can swing.
* The card is its own text equivalent: there is no graphic to describe.
*/
export function BpTimeOfDayCard({
days,
chartPart,
onChartPartChange,
}: {
/** The chart's range, in days, so both read the same window. */
days: number;
chartPart: BpChartPart;
onChartPartChange: (next: BpChartPart) => void;
}) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const query = useBpTimeOfDay(days);
const data = query.data;
// A signed difference: `+` spelled out, the minus from the formatter.
const signed = (n: number) => (n > 0 ? `+${fmt.integer(n)}` : fmt.integer(n));
return (
<Card data-slot="bp-time-of-day" aria-busy={query.isFetching || undefined}>
<CardHeader>
<TileHeader
icon={Clock}
title={t("insights.bpTimeOfDay.title")}
titleAs="h2"
id="bp-time-of-day-title"
/>
<CardDescription>
{t("insights.bpTimeOfDay.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{query.isPending ? (
<div className="grid grid-cols-3 gap-x-3 gap-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("insights.bpTimeOfDay.error")}
onRetry={() => void query.refetch()}
/>
) : data && data.readingCount === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("insights.bpTimeOfDay.emptyTitle")}
description={t("insights.bpTimeOfDay.emptyDescription")}
/>
) : data ? (
<div
className={cn(
"space-y-3 transition-opacity",
query.isPlaceholderData && "opacity-60",
)}
>
<ul
aria-labelledby="bp-time-of-day-title"
className="grid grid-cols-3 gap-x-3 gap-y-2"
>
{data.parts.map((part) => (
<li
key={part.part}
data-slot={`bp-time-of-day-${part.part}`}
className="min-w-0 space-y-0.5"
>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t(PART_KEYS[part.part])}
</p>
{part.systolic !== null && part.diastolic !== null ? (
<p className="flex flex-wrap items-baseline gap-x-1 text-base font-semibold tabular-nums">
<span>{`${fmt.integer(part.systolic)}/${fmt.integer(part.diastolic)}`}</span>
<span className="text-muted-foreground text-xs font-normal">
mmHg
</span>
</p>
) : (
<p className="text-sm">
{t("insights.bpTimeOfDay.notEnough")}
</p>
)}
<p className="text-muted-foreground text-xs tabular-nums">
{t(
pluralKey(
"insights.bpTimeOfDay.readings",
part.readingCount,
locale,
),
{ count: fmt.integer(part.readingCount) },
)}
</p>
{part.pulse !== null ? (
<p className="text-muted-foreground text-xs tabular-nums">
{t("insights.bpTimeOfDay.pulse", {
value: fmt.integer(part.pulse),
})}
</p>
) : null}
</li>
))}
</ul>
{data.morningEveningDifference ? (
<p className="text-sm" data-slot="bp-time-of-day-difference">
{t("insights.bpTimeOfDay.difference", {
systolic: signed(data.morningEveningDifference.systolic),
diastolic: signed(data.morningEveningDifference.diastolic),
})}
</p>
) : (
<p className="text-sm" data-slot="bp-time-of-day-difference">
{t("insights.bpTimeOfDay.differencePending", {
min: fmt.integer(data.minReadings),
})}
</p>
)}
<p className="text-muted-foreground text-xs">
{t("insights.bpTimeOfDay.hours", {
morning: t(
"insights.bpTimeOfDay.hourRange",
hourRange(data.parts[0]),
),
midday: t(
"insights.bpTimeOfDay.hourRange",
hourRange(data.parts[1]),
),
evening: t(
"insights.bpTimeOfDay.hourRange",
hourRange(data.parts[2]),
),
})}
</p>
</div>
) : null}
<div className="space-y-1.5">
<p
className="text-muted-foreground text-xs"
id="bp-time-of-day-filter-label"
>
{t("insights.bpTimeOfDay.filterLabel")}
</p>
<Segmented<BpChartPart>
label={t("insights.bpTimeOfDay.filterLabel")}
slot="bp-time-of-day-filter"
stretch
size="sm"
// Full width on a phone, where four parts share 343 px; beside
// the text from `md` up rather than stretched across the card.
className="md:inline-flex md:w-auto"
value={chartPart}
onChange={onChartPartChange}
options={[
{ value: "all", label: t("insights.bpTimeOfDay.filterAll") },
{ value: "morning", label: t(PART_KEYS.morning) },
{ value: "midday", label: t(PART_KEYS.midday) },
{ value: "evening", label: t(PART_KEYS.evening) },
]}
/>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useCallback } from "react";
import {
rangeWindowDays,
DEFAULT_CHART_RANGE,
} from "@/components/charts/chart-range-tabs";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import {
BP_PARTS_OF_DAY,
type BpPartOfDay,
} from "@/lib/analytics/bp-time-of-day";
import { useUrlSearch, writeUrlState } from "@/lib/url-state";
/** The chart's part of the day, or the whole day. */
export type BpChartPart = BpPartOfDay | "all";
/** `?partOfDay=` as a part of the day; anything else is the whole day. */
export function parseBpChartPart(
params: URLSearchParams,
): BpPartOfDay | undefined {
const raw = params.get("partOfDay");
return BP_PARTS_OF_DAY.find((part) => part === raw);
}
const CHART_TITLE_KEYS: Record<BpPartOfDay, string> = {
morning: "insights.bpTimeOfDay.chartTitle.morning",
midday: "insights.bpTimeOfDay.chartTitle.midday",
evening: "insights.bpTimeOfDay.chartTitle.evening",
};
/**
* The blood-pressure page's time-of-day state (#1331).
*
* The part of the day the chart is narrowed to lives in the URL
* (`?partOfDay=`), so a narrowed view survives a reload, Back and a shared
* link; the whole day keeps a bare URL. A change is a discrete facet, so it
* pushes a history entry. `days` is the chart's own range tab (the persisted
* pick, the default before it loads), so the averages read the window the
* chart draws.
*/
export function useBpTimeOfDayState(): {
partOfDay: BpPartOfDay | undefined;
/** The chart title's message key while narrowed; undefined for the whole day. */
chartTitleKey: string | undefined;
setChartPart: (next: BpChartPart) => void;
days: number;
} {
const partOfDay = parseBpChartPart(useUrlSearch());
const setChartPart = useCallback((next: BpChartPart) => {
writeUrlState(
(params) => {
if (next === "all") params.delete("partOfDay");
else params.set("partOfDay", next);
},
{ mode: "push" },
);
}, []);
const range =
useChartOverlayPrefs("bp").prefs.rangePoints ?? DEFAULT_CHART_RANGE;
return {
partOfDay,
chartTitleKey: partOfDay ? CHART_TITLE_KEYS[partOfDay] : undefined,
setChartPart,
days: rangeWindowDays(range),
};
}
@@ -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 });
});
});
+164
View File
@@ -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,
};
}
+85 -3
View File
@@ -27,6 +27,11 @@ import { annotate } from "@/lib/logging/context";
import { isValidTimezone } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import type { MeasurementType } from "@/generated/prisma/client";
import {
BP_DAY_START_HOUR,
BP_PART_HOURS,
type BpPartOfDay,
} from "@/lib/analytics/bp-time-of-day";
import { Prisma } from "@/generated/prisma/client";
/** Wire row the chart-data client consumes — mirrors the route's shape. */
@@ -57,8 +62,10 @@ export async function readDailySeries(opts: {
priorityJson: unknown;
/** IANA zone the days are cut in; defaults to the user's own zone. */
timeZone?: string;
/** Only the readings of this part of the day (#1331). */
partOfDay?: BpPartOfDay;
}): Promise<DailySeriesRow[]> {
const { userId, type, from, to, priorityJson } = opts;
const { userId, type, from, to, priorityJson, partOfDay } = opts;
const cap = Math.min(opts.limit ?? BUCKET_CAP.daily, BUCKET_CAP.daily);
// v1.19.2 — whole-history step-up for very long ranges. When the
@@ -76,6 +83,11 @@ export async function readDailySeries(opts: {
const windowDaysFull = Math.ceil(
(to.getTime() - from.getTime()) / 86_400_000,
);
// A part of the day is folded live at every length: the rollups hold whole
// days and cannot say which hour a reading was taken in.
if (partOfDay) {
return readPartOfDaySeries({ ...opts, partOfDay, windowDaysFull, cap });
}
if (windowDaysFull > cap) {
// A rollup-table throw here (statement_timeout, deadlock, connection reset)
// must not 500 the whole read — treat it as a coverage miss and fall
@@ -146,6 +158,72 @@ export async function readDailySeries(opts: {
return rows;
}
/**
* The series of one part of the day: daily up to the day cap, weekly past it,
* with room for every week of the window (blood pressure is a few readings a
* day, so even ten years is one short fold).
*/
async function readPartOfDaySeries(opts: {
userId: string;
type: MeasurementType;
from: Date;
to: Date;
priorityJson: unknown;
timeZone?: string;
partOfDay: BpPartOfDay;
windowDaysFull: number;
cap: number;
}): Promise<DailySeriesRow[]> {
const weekly = opts.windowDaysFull > opts.cap;
const rows = await readLiveBuckets({
userId: opts.userId,
type: opts.type,
from: opts.from,
to: opts.to,
cap: weekly ? Math.ceil(opts.windowDaysFull / 7) + 2 : opts.cap,
priorityJson: opts.priorityJson,
grain: weekly ? "weekly" : "daily",
timeZone: opts.timeZone ?? (await resolveUserTimezone(opts.userId)),
partOfDay: opts.partOfDay,
});
annotate({
action: { name: "measurement.list" },
meta: {
total: rows.length,
type: opts.type,
aggregate: weekly ? "weekly" : "daily",
part_of_day: opts.partOfDay,
},
});
return rows;
}
/**
* The local wall clock a fold cuts its hours and days on, and the filter for
* a part of the day. Without a part, the wall clock in the zone. With one,
* only the readings whose local hour is inside the part, and the wall clock
* moved back by the day's start hour, so a day runs from 04:00 and an
* evening reading after midnight joins the evening before. Hours come from
* the closed `BP_PART_HOURS` table and are bound as parameters.
*/
function localClockSql(
timeZone: string,
partOfDay: BpPartOfDay | undefined,
): { local: Prisma.Sql; filter: Prisma.Sql } {
const wall = Prisma.sql`((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone})`;
if (!partOfDay) return { local: wall, filter: Prisma.empty };
const { startHour, endHour } = BP_PART_HOURS[partOfDay];
const hour = Prisma.sql`EXTRACT(HOUR FROM ${wall})`;
const filter =
startHour < endHour
? Prisma.sql`AND ${hour} >= ${startHour} AND ${hour} < ${endHour}`
: Prisma.sql`AND (${hour} >= ${startHour} OR ${hour} < ${endHour})`;
return {
local: Prisma.sql`(${wall} - make_interval(hours => ${BP_DAY_START_HOUR}::int))`,
filter,
};
}
/** The shortest bucket of a live grain, in days (a month is at least 28). */
const GRAIN_MIN_DAYS: Record<Exclude<AggregateGrain, "raw">, number> = {
daily: 1,
@@ -264,6 +342,8 @@ export async function readLiveBuckets(opts: {
priorityJson: unknown;
grain: Exclude<AggregateGrain, "raw">;
timeZone: string;
/** Only the readings of this part of the day (`localClockSql`). */
partOfDay?: BpPartOfDay;
}): Promise<DailySeriesRow[]> {
const { userId, type, from, to, cap, priorityJson, grain } = opts;
// A zone Postgres does not know would 500 the read; the resolver already
@@ -295,6 +375,7 @@ export async function readLiveBuckets(opts: {
const typeFilter = type
? Prisma.sql`AND m."type" = ${type}::measurement_type`
: Prisma.empty;
const clock = localClockSql(timeZone, opts.partOfDay);
// measured_at is a UTC wall-clock timestamp: pin it to UTC, re-read it in
// the user's zone and truncate there.
//
@@ -334,8 +415,8 @@ export async function readLiveBuckets(opts: {
SELECT
m."type",
m."source",
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS day,
date_trunc('hour', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}) AS hr,
date_trunc('day', ${clock.local}) AS day,
date_trunc('hour', ${clock.local}) AS hr,
SUM(m."value")::double precision AS total,
COUNT(*) AS n,
AVG(m."value") AS hour_mean,
@@ -347,6 +428,7 @@ export async function readLiveBuckets(opts: {
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${typeFilter}
${clock.filter}
GROUP BY m."type", m."source", 3, 4
),
per_source AS (
+15 -7
View File
@@ -34,6 +34,7 @@ import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped";
import { sleepListResponse } from "@/lib/measurements/list/sleep-list";
import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver";
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import type { BpPartOfDay } from "@/lib/analytics/bp-time-of-day";
/**
* The columns a measurement row answers with (`MeasurementResource`), plus
@@ -84,7 +85,7 @@ export async function measurementListResponse(
query: ListQuery,
): Promise<Response> {
const { type, from, to, limit, offset, sortDir, groupBy, dayKey } = query;
const { aggregate, source, sourceEq } = query;
const { aggregate, source, sourceEq, partOfDay } = query;
// Sleep is read row-per-STAGE (≈5 rows/night × source-count), not a spot
// sample and not a cumulative day-sum. A plain list query for
@@ -159,12 +160,14 @@ export async function measurementListResponse(
const series = await rollupSeriesResponse(
user.id,
type as MeasurementType,
{ from, to, limit },
{ from, to, limit, partOfDay },
);
if (series) return series;
// An empty result means the window genuinely holds no rows. Fall
// through to the shared live aggregate branch below to keep the
// response shape identical for the truly-empty case.
// response shape identical for the truly-empty case. A narrowed read
// never falls through: the live branch knows no part of the day, and
// would answer an empty morning with the whole day.
}
if (aggregate && aggregate !== "raw" && from && to) {
@@ -222,13 +225,16 @@ async function drillDownResponse(
});
}
/** The chart's daily series, or null when the window holds no rows. */
/**
* The chart's daily series, or null when the window holds no rows. A series
* narrowed to a part of the day answers even when empty.
*/
async function rollupSeriesResponse(
userId: string,
type: MeasurementType,
window: { from: Date; to: Date; limit: number },
window: { from: Date; to: Date; limit: number; partOfDay?: BpPartOfDay },
): Promise<Response | null> {
const { from, to, limit } = window;
const { from, to, limit, partOfDay } = window;
// Shared with the batched series endpoint, so both read the same rows.
const cap = Math.min(limit, BUCKET_CAP.daily);
const priorityJson = await loadUserSourcePriority(userId);
@@ -239,8 +245,9 @@ async function rollupSeriesResponse(
to,
limit,
priorityJson,
partOfDay,
});
if (measurements.length === 0) return null;
if (measurements.length === 0 && !partOfDay) return null;
return apiSuccess({
measurements,
meta: {
@@ -248,6 +255,7 @@ async function rollupSeriesResponse(
limit: cap,
offset: 0,
aggregate: "daily",
...(partOfDay && { partOfDay }),
},
});
}
@@ -0,0 +1,106 @@
/**
* The readings behind the blood-pressure time-of-day summary (#1331).
*
* The pairs are the blood-pressure series' own (`readBloodPressureSeries`):
* the ladder's one source per day, each systolic with the diastolic of the
* same source within five minutes. A systolic without its diastolic is half a
* reading and stays out. The pulse is the reading's own: the closest PULSE row
* of the same source within the same five minutes, so a cuff's pulse is taken
* and a watch's stream of heart-rate rows from another source is not.
*/
import { prisma } from "@/lib/db";
import {
openEndedFetchWindow,
localDayKeyFor,
} from "@/lib/charts/fetch-window";
import {
computeBpTimeOfDay,
type BpTimeOfDaySummary,
type BpTimedReading,
} from "@/lib/analytics/bp-time-of-day";
import { VALUE_RANGES } from "@/lib/measurements/value-ranges";
import { readBloodPressureSeries } from "./read-blood-pressure";
export interface BpTimeOfDayRead extends BpTimeOfDaySummary {
days: number;
from: string;
to: string;
}
/**
* The summary over the last `days` local days, ending with today in
* `timeZone`: the same window the chart draws for that range tab.
*/
export async function readBpTimeOfDay(opts: {
userId: string;
days: number;
timeZone: string;
now?: Date;
}): Promise<BpTimeOfDayRead> {
const { userId, days, timeZone } = opts;
const now = opts.now ?? new Date();
const window = openEndedFetchWindow(
days,
localDayKeyFor(now, timeZone),
timeZone,
);
const from = new Date(window.from);
const to = new Date(window.to);
const { points } = await readBloodPressureSeries(userId, from);
const paired = points.filter(
(p) => p.secondary !== null && new Date(p.at).getTime() <= to.getTime(),
);
const pulseById = await readPulseForReadings(
userId,
paired.map((p) => p.id),
);
const readings: BpTimedReading[] = paired.map((p) => ({
at: new Date(p.at),
systolic: p.value,
diastolic: p.secondary as number,
pulse: pulseById.get(p.id) ?? null,
}));
return {
days,
from: window.from,
to: window.to,
...computeBpTimeOfDay(readings, timeZone),
};
}
const PULSE_WINDOW_MINUTES = 5;
/** The pulse taken with each systolic row, keyed by the systolic row's id. */
async function readPulseForReadings(
userId: string,
systolicIds: string[],
): Promise<Map<string, number>> {
if (systolicIds.length === 0) return new Map();
// `measured_at` is a naive UTC timestamp on both sides, so the interval
// arithmetic is zone-free. One indexed lookup per reading; BP is a few
// readings a day even on a long history.
const rows = await prisma.$queryRaw<Array<{ id: string; pulse: number }>>`
SELECT s."id", p."value"::double precision AS pulse
FROM measurements s
CROSS JOIN LATERAL (
SELECT q."value"
FROM measurements q
WHERE q."user_id" = s."user_id"
AND q."type" = 'PULSE'::measurement_type
AND q."source" = s."source"
AND q."deleted_at" IS NULL
AND q."value" >= ${VALUE_RANGES.PULSE.min}
AND q."value" <= ${VALUE_RANGES.PULSE.max}
AND q."measured_at" BETWEEN s."measured_at" - make_interval(mins => ${PULSE_WINDOW_MINUTES}::int)
AND s."measured_at" + make_interval(mins => ${PULSE_WINDOW_MINUTES}::int)
ORDER BY abs(extract(epoch FROM (q."measured_at" - s."measured_at"))), q."id"
LIMIT 1
) p
WHERE s."user_id" = ${userId}
AND s."id" = ANY(${systolicIds}::text[])
`;
return new Map(rows.map((r) => [r.id, Number(r.pulse)]));
}
+111
View File
@@ -14,6 +14,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { bpTimeOfDayQuerySchema } from "@/lib/validations/bp-time-of-day";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { sleepStageEntrySchema } from "@/lib/validations/sleep-stage-entry";
import {
@@ -464,6 +465,10 @@ const listMeasurementsMeta = z
.string()
.optional()
.describe("Set on the aggregate modes — the requested grain."),
partOfDay: z
.enum(["morning", "midday", "evening"])
.optional()
.describe("Echoed when the series was narrowed with `partOfDay`."),
droppedDuplicates: z
.number()
.int()
@@ -829,7 +834,113 @@ const seriesBatchResponse = z
"Batched daily series — one rollup-backed series per requested type.",
});
const bpPartSummary = z
.object({
part: z
.enum(["morning", "midday", "evening"])
.describe("The part of the day."),
startHour: z
.number()
.int()
.describe("First local hour of the part (inclusive)."),
endHour: z
.number()
.int()
.describe(
"Local hour the part ends at (exclusive). Smaller than `startHour` when the part runs past midnight (evening: 18 to 4).",
),
readingCount: z
.number()
.int()
.describe("Paired systolic/diastolic readings in this part."),
systolic: z
.number()
.int()
.nullable()
.describe(
"Mean systolic in whole mmHg; null while `readingCount` is below `minReadings`.",
),
diastolic: z
.number()
.int()
.nullable()
.describe("Mean diastolic in whole mmHg; null on the same floor."),
pulseReadingCount: z
.number()
.int()
.describe(
"Readings in this part that carried a pulse (same source, within five minutes).",
),
pulse: z
.number()
.int()
.nullable()
.describe(
"Mean pulse in whole bpm; null while `pulseReadingCount` is below `minReadings`.",
),
})
.meta({ id: "BloodPressurePartOfDay" });
const bpTimeOfDayResponse = z
.object({
days: z.number().int().describe("The window asked for, in local days."),
from: isoDateTime().describe("Window start (ISO-8601)."),
to: isoDateTime().describe(
"Window end: the last instant of today in the record's time zone.",
),
timeZone: z
.string()
.describe("IANA zone the hours were read in (the record's own)."),
minReadings: z
.number()
.int()
.describe("Readings a part needs before its mean is given."),
readingCount: z
.number()
.int()
.describe("Paired readings in the window, across the three parts."),
parts: z
.array(bpPartSummary)
.describe("Always three entries: morning, midday, evening, in order."),
morningEveningDifference: z
.object({
systolic: z.number().int(),
diastolic: z.number().int(),
})
.nullable()
.describe(
"Morning mean minus evening mean, from the two whole-mmHg means given above (so it equals their difference exactly). Null unless both parts reach `minReadings`.",
),
})
.meta({ id: "BloodPressureTimeOfDay" });
export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/measurements/blood-pressure/time-of-day": {
get: {
tags: ["Measurements"],
summary: "Blood pressure by part of the day",
description:
"Morning (04:00 to 11:59), midday (12:00 to 17:59) and evening (18:00 to 03:59) means of the paired blood-pressure readings in the window, read in the record's own time zone, with a count per part and the morning-minus-evening difference. Home monitoring guidance reads morning and evening readings as separate sets; the windows are wide so a waking reading and a late bedtime reading land in the right one. A pair is a systolic with the diastolic of the same source within five minutes, from the source the person's ladder picks for the day (the `bloodPressure` series' rule). Every figure is resolved here; a client renders it as given. Auth via cookie or Bearer.",
requestParams: {
query: bpTimeOfDayQuerySchema,
},
responses: {
...recordRefusal(),
"200": {
description: "Resolved summary.",
content: {
"application/json": {
schema: dataEnvelope(
bpTimeOfDayResponse,
"GetBloodPressureTimeOfDayResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/series-batch": {
get: {
tags: ["Measurements"],
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import type { BpTimeOfDaySummary } from "@/lib/analytics/bp-time-of-day";
import { queryKeys } from "@/lib/query-keys";
/** `GET /api/measurements/blood-pressure/time-of-day` as the server resolved it. */
export interface BpTimeOfDayDto extends BpTimeOfDaySummary {
days: number;
from: string;
to: string;
}
/**
* Blood pressure by part of the day over the last `days` local days (#1331).
* Every figure comes from the server; the card renders it as given.
*/
export function useBpTimeOfDay(days: number) {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.bpTimeOfDay(days),
queryFn: () =>
apiGet<BpTimeOfDayDto>(
`/api/measurements/blood-pressure/time-of-day?days=${days}`,
),
// A range change re-keys the read; the previous range stays painted
// (dimmed) until the new one lands, like the chart above it.
placeholderData: keepPreviousData,
staleTime: 60_000,
enabled: isAuthenticated,
});
}
+26 -23
View File
@@ -100,29 +100,32 @@ export const measurementKeys = {
// appended when set, so every pre-v1.18.6 caller packs a
// byte-identical tuple and the existing cache layout is unchanged.
readMode: string = "",
) =>
readMode
? ([
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
readMode,
] as const)
: ([
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
] as const),
// #1331 — the part of the day the series is narrowed to. Appended only
// when set (behind the read mode, which then always rides), so the
// whole-day key of every chart stays as it was.
partOfDay: string = "",
) => {
const base = [
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
] as const;
if (partOfDay) return [...base, readMode, partOfDay] as const;
return readMode ? ([...base, readMode] as const) : base;
},
/**
* #1331 — blood pressure by part of the day
* (`GET /api/measurements/blood-pressure/time-of-day?days=`). Under the
* `["measurements"]` prefix, so a new reading refreshes it with the list.
*/
bpTimeOfDay: (days: number) =>
["measurements", "bp-time-of-day", days] as const,
/**
* v1.8.5 — bounded recent-timestamp read powering the
@@ -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,
);
});
});
+19
View File
@@ -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.",
),
});
+36
View File
@@ -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({