mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user