Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0

This commit is contained in:
Marc-André Bombeck committed 2026-11-04 00:13:45 +01:00
commit 22f83aed6e
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),
};
}