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

@@ -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),
};
}