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,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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user