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

+202
View File
@@ -4340,6 +4340,41 @@ paths:
"403": *a9
"422": *a4
"429": *a5
/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.
parameters:
- in: query
name: days
schema:
default: 30
description: "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."
type: integer
minimum: 1
maximum: 3650
description: "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."
responses:
"200":
description: Resolved summary.
content:
application/json:
schema:
$ref: "#/components/schemas/GetBloodPressureTimeOfDayResponse"
"401": *a1
"403": *a15
"422": *a4
"429": *a5
/api/measurements/series-batch:
get:
tags:
@@ -4661,6 +4696,22 @@ paths:
schema:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
- in: query
name: partOfDay
schema:
description: "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."
type: string
enum:
- morning
- midday
- evening
description: "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."
responses:
"200":
description: Measurement page.
@@ -49594,6 +49645,150 @@ components:
additionalProperties: false
description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the
token that sent it. The reading itself did not change and is not re-sent in `measurements`.
GetBloodPressureTimeOfDayResponse:
type: object
properties:
data:
$ref: "#/components/schemas/BloodPressureTimeOfDay"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
BloodPressureTimeOfDay:
type: object
properties:
days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: The window asked for, in local days.
from:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: Window start (ISO-8601).
to:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: "Window end: the last instant of today in the record's time zone."
timeZone:
type: string
description: IANA zone the hours were read in (the record's own).
minReadings:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Readings a part needs before its mean is given.
readingCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Paired readings in the window, across the three parts.
parts:
type: array
items:
$ref: "#/components/schemas/BloodPressurePartOfDay"
description: "Always three entries: morning, midday, evening, in order."
morningEveningDifference:
anyOf:
- type: object
properties:
systolic:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
diastolic:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- systolic
- diastolic
additionalProperties: false
- type: "null"
description: 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`.
required:
- days
- from
- to
- timeZone
- minReadings
- readingCount
- parts
- morningEveningDifference
additionalProperties: false
BloodPressurePartOfDay:
type: object
properties:
part:
type: string
enum:
- morning
- midday
- evening
description: The part of the day.
startHour:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: First local hour of the part (inclusive).
endHour:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "Local hour the part ends at (exclusive). Smaller than `startHour` when the part runs past midnight
(evening: 18 to 4)."
readingCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Paired systolic/diastolic readings in this part.
systolic:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: Mean systolic in whole mmHg; null while `readingCount` is below `minReadings`.
diastolic:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: Mean diastolic in whole mmHg; null on the same floor.
pulseReadingCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Readings in this part that carried a pulse (same source, within five minutes).
pulse:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: Mean pulse in whole bpm; null while `pulseReadingCount` is below `minReadings`.
required:
- part
- startHour
- endHour
- readingCount
- systolic
- diastolic
- pulseReadingCount
- pulse
additionalProperties: false
GetMeasurementsSeriesBatchResponse:
type: object
properties:
@@ -50156,6 +50351,13 @@ components:
aggregate:
description: Set on the aggregate modes — the requested grain.
type: string
partOfDay:
description: Echoed when the series was narrowed with `partOfDay`.
type: string
enum:
- morning
- midday
- evening
droppedDuplicates:
description: Rows discarded by the cross-source canonical picker on the day-collapse mode.
type: integer
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "In diesem Zeitraum wurde noch kein Score erfasst.",
"usualRange": "Schattiert: dein üblicher Bereich, {lo} bis {hi}.",
"seam": "Die Linie bricht dort ab, wo sich geändert hat, was in den Score einfließt."
},
"bpTimeOfDay": {
"title": "Nach Tageszeit",
"description": "Dein durchschnittlicher Blutdruck je Tageszeit im Zeitraum des Diagramms.",
"part": {
"morning": "Morgens",
"midday": "Mittags",
"evening": "Abends"
},
"filterAll": "Ganzer Tag",
"filterLabel": "Im Diagramm zeigen",
"readingsOne": "{count} Messung",
"readingsFew": "{count} Messungen",
"readingsOther": "{count} Messungen",
"notEnough": "Zu wenige Messungen",
"pulse": "Puls {value} bpm",
"difference": "Morgens minus abends: {systolic}/{diastolic} mmHg.",
"differencePending": "Morgen und Abend werden verglichen, sobald beide mindestens {min} Messungen haben.",
"hours": "Morgens {morning}, mittags {midday}, abends {evening}, in deiner Zeitzone.",
"hourRange": "{start} bis {end} Uhr",
"emptyTitle": "Keine vollständigen Messungen in diesem Zeitraum",
"emptyDescription": "Hier erscheinen Messungen mit systolischem und diastolischem Wert.",
"error": "Die Werte nach Tageszeit konnten nicht geladen werden.",
"chartTitle": {
"morning": "Blutdruck morgens",
"midday": "Blutdruck mittags",
"evening": "Blutdruck abends"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "No score was recorded in this range yet.",
"usualRange": "Shaded: your usual range, {lo} to {hi}.",
"seam": "The line breaks where what counts toward the score changed."
},
"bpTimeOfDay": {
"title": "By time of day",
"description": "Your average blood pressure in each part of the day, over the chart's range.",
"part": {
"morning": "Morning",
"midday": "Midday",
"evening": "Evening"
},
"filterAll": "All day",
"filterLabel": "Show in the chart",
"readingsOne": "{count} reading",
"readingsFew": "{count} readings",
"readingsOther": "{count} readings",
"notEnough": "Too few readings",
"pulse": "Pulse {value} bpm",
"difference": "Morning minus evening: {systolic}/{diastolic} mmHg.",
"differencePending": "Morning and evening are compared once each has at least {min} readings.",
"hours": "Morning {morning}, midday {midday}, evening {evening}, in your time zone.",
"hourRange": "{start}–{end}",
"emptyTitle": "No complete readings in this range",
"emptyDescription": "Readings with both a systolic and a diastolic value show up here.",
"error": "The time-of-day averages could not be loaded.",
"chartTitle": {
"morning": "Blood pressure in the morning",
"midday": "Blood pressure at midday",
"evening": "Blood pressure in the evening"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "Todavía no hay ninguna puntuación registrada en este periodo.",
"usualRange": "Sombreado: tu rango habitual, de {lo} a {hi}.",
"seam": "La línea se interrumpe donde cambió lo que cuenta para la puntuación."
},
"bpTimeOfDay": {
"title": "Por momento del día",
"description": "Tu tensión arterial media en cada momento del día, en el periodo del gráfico.",
"part": {
"morning": "Mañana",
"midday": "Mediodía",
"evening": "Noche"
},
"filterAll": "Todo el día",
"filterLabel": "Mostrar en el gráfico",
"readingsOne": "{count} medición",
"readingsFew": "{count} mediciones",
"readingsOther": "{count} mediciones",
"notEnough": "Muy pocas mediciones",
"pulse": "Pulso {value} lpm",
"difference": "Mañana menos noche: {systolic}/{diastolic} mmHg.",
"differencePending": "La mañana y la noche se comparan cuando cada una tiene al menos {min} mediciones.",
"hours": "Mañana {morning}, mediodía {midday}, noche {evening}, en tu zona horaria.",
"hourRange": "{start}–{end}",
"emptyTitle": "No hay mediciones completas en este periodo",
"emptyDescription": "Aquí aparecen las mediciones con valor sistólico y diastólico.",
"error": "No se pudieron cargar los promedios por momento del día.",
"chartTitle": {
"morning": "Tensión arterial por la mañana",
"midday": "Tensión arterial a mediodía",
"evening": "Tensión arterial por la noche"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "Aucun score n’a encore été enregistré sur cette période.",
"usualRange": "En grisé : votre plage habituelle, de {lo} à {hi}.",
"seam": "La ligne s’interrompt là où ce qui compte pour le score a changé."
},
"bpTimeOfDay": {
"title": "Selon le moment de la journée",
"description": "Votre tension artérielle moyenne à chaque moment de la journée, sur la période du graphique.",
"part": {
"morning": "Matin",
"midday": "Midi",
"evening": "Soir"
},
"filterAll": "Toute la journée",
"filterLabel": "Afficher dans le graphique",
"readingsOne": "{count} mesure",
"readingsFew": "{count} mesures",
"readingsOther": "{count} mesures",
"notEnough": "Trop peu de mesures",
"pulse": "Pouls {value} bpm",
"difference": "Matin moins soir : {systolic}/{diastolic} mmHg.",
"differencePending": "Le matin et le soir sont comparés dès que chacun compte au moins {min} mesures.",
"hours": "Matin {morning}, midi {midday}, soir {evening}, dans votre fuseau horaire.",
"hourRange": "{start}–{end}",
"emptyTitle": "Aucune mesure complète sur cette période",
"emptyDescription": "Les mesures avec une valeur systolique et diastolique apparaissent ici.",
"error": "Impossible de charger les moyennes selon le moment de la journée.",
"chartTitle": {
"morning": "Tension artérielle le matin",
"midday": "Tension artérielle à midi",
"evening": "Tension artérielle le soir"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "In questo periodo non è ancora stato registrato alcun punteggio.",
"usualRange": "In ombra: il tuo intervallo abituale, da {lo} a {hi}.",
"seam": "La linea si interrompe dove è cambiato ciò che conta per il punteggio."
},
"bpTimeOfDay": {
"title": "Per momento della giornata",
"description": "La tua pressione arteriosa media in ogni momento della giornata, nel periodo del grafico.",
"part": {
"morning": "Mattina",
"midday": "Mezzogiorno",
"evening": "Sera"
},
"filterAll": "Tutto il giorno",
"filterLabel": "Mostra nel grafico",
"readingsOne": "{count} misurazione",
"readingsFew": "{count} misurazioni",
"readingsOther": "{count} misurazioni",
"notEnough": "Troppe poche misurazioni",
"pulse": "Pulsazioni {value} bpm",
"difference": "Mattina meno sera: {systolic}/{diastolic} mmHg.",
"differencePending": "Mattina e sera vengono confrontate quando ciascuna ha almeno {min} misurazioni.",
"hours": "Mattina {morning}, mezzogiorno {midday}, sera {evening}, nel tuo fuso orario.",
"hourRange": "{start}–{end}",
"emptyTitle": "Nessuna misurazione completa in questo periodo",
"emptyDescription": "Qui compaiono le misurazioni con valore sistolico e diastolico.",
"error": "Impossibile caricare le medie per momento della giornata.",
"chartTitle": {
"morning": "Pressione arteriosa al mattino",
"midday": "Pressione arteriosa a mezzogiorno",
"evening": "Pressione arteriosa alla sera"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.",
"usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.",
"seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다."
},
"bpTimeOfDay": {
"title": "시간대별",
"description": "차트 기간 동안 하루 중 시간대별 평균 혈압입니다.",
"part": {
"morning": "아침",
"midday": "낮",
"evening": "저녁"
},
"filterAll": "하루 전체",
"filterLabel": "차트에 표시",
"readingsOne": "측정 {count}회",
"readingsFew": "측정 {count}회",
"readingsOther": "측정 {count}회",
"notEnough": "측정 횟수 부족",
"pulse": "맥박 {value} bpm",
"difference": "아침 빼기 저녁: {systolic}/{diastolic} mmHg.",
"differencePending": "아침과 저녁에 각각 측정이 {min}회 이상 있으면 비교합니다.",
"hours": "아침 {morning}, 낮 {midday}, 저녁 {evening}, 내 시간대 기준.",
"hourRange": "{start}–{end}",
"emptyTitle": "이 기간에 완전한 측정이 없습니다",
"emptyDescription": "수축기와 이완기 값이 모두 있는 측정이 여기에 표시됩니다.",
"error": "시간대별 평균을 불러오지 못했습니다.",
"chartTitle": {
"morning": "아침 혈압",
"midday": "낮 혈압",
"evening": "저녁 혈압"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "In deze periode is nog geen score vastgelegd.",
"usualRange": "Gearceerd: je gebruikelijke bereik, {lo} tot {hi}.",
"seam": "De lijn wordt onderbroken op het moment dat veranderde wat meetelt voor de score."
},
"bpTimeOfDay": {
"title": "Per moment van de dag",
"description": "Je gemiddelde bloeddruk per moment van de dag, over de periode van de grafiek.",
"part": {
"morning": "Ochtend",
"midday": "Middag",
"evening": "Avond"
},
"filterAll": "Hele dag",
"filterLabel": "Tonen in de grafiek",
"readingsOne": "{count} meting",
"readingsFew": "{count} metingen",
"readingsOther": "{count} metingen",
"notEnough": "Te weinig metingen",
"pulse": "Hartslag {value} bpm",
"difference": "Ochtend min avond: {systolic}/{diastolic} mmHg.",
"differencePending": "Ochtend en avond worden vergeleken zodra elk minstens {min} metingen heeft.",
"hours": "Ochtend {morning}, middag {midday}, avond {evening}, in je eigen tijdzone.",
"hourRange": "{start}–{end}",
"emptyTitle": "Geen volledige metingen in deze periode",
"emptyDescription": "Metingen met zowel een systolische als een diastolische waarde verschijnen hier.",
"error": "De gemiddelden per moment van de dag konden niet worden geladen.",
"chartTitle": {
"morning": "Bloeddruk 's ochtends",
"midday": "Bloeddruk 's middags",
"evening": "Bloeddruk 's avonds"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "W tym okresie nie zapisano jeszcze żadnego wyniku.",
"usualRange": "Zacieniowane: twój zwykły zakres, od {lo} do {hi}.",
"seam": "Linia przerywa się tam, gdzie zmieniło się to, co liczy się do wyniku."
},
"bpTimeOfDay": {
"title": "Według pory dnia",
"description": "Twoje średnie ciśnienie w każdej porze dnia, w zakresie wykresu.",
"part": {
"morning": "Rano",
"midday": "W południe",
"evening": "Wieczorem"
},
"filterAll": "Cały dzień",
"filterLabel": "Pokaż na wykresie",
"readingsOne": "{count} pomiar",
"readingsFew": "{count} pomiary",
"readingsOther": "{count} pomiarów",
"notEnough": "Za mało pomiarów",
"pulse": "Tętno {value} bpm",
"difference": "Rano minus wieczór: {systolic}/{diastolic} mmHg.",
"differencePending": "Rano i wieczór zostaną porównane, gdy każda z tych pór będzie mieć co najmniej {min} pomiary.",
"hours": "Rano {morning}, w południe {midday}, wieczorem {evening}, w Twojej strefie czasowej.",
"hourRange": "{start}–{end}",
"emptyTitle": "Brak pełnych pomiarów w tym zakresie",
"emptyDescription": "Tutaj pojawią się pomiary z wartością skurczową i rozkurczową.",
"error": "Nie udało się wczytać średnich według pory dnia.",
"chartTitle": {
"morning": "Ciśnienie tętnicze rano",
"midday": "Ciśnienie tętnicze w południe",
"evening": "Ciśnienie tętnicze wieczorem"
}
}
},
"thresholds": {
+28
View File
@@ -4972,6 +4972,34 @@
"emptyDescription": "Nenhuma pontuação foi registrada nesta faixa ainda.",
"usualRange": "Sombreado: sua faixa habitual, {lo} a {hi}.",
"seam": "A linha quebra onde o que conta para a pontuação mudou."
},
"bpTimeOfDay": {
"title": "Por período do dia",
"description": "Sua pressão arterial média em cada período do dia, no intervalo do gráfico.",
"part": {
"morning": "Manhã",
"midday": "Tarde",
"evening": "Noite"
},
"filterAll": "Dia todo",
"filterLabel": "Mostrar no gráfico",
"readingsOne": "{count} medição",
"readingsFew": "{count} medições",
"readingsOther": "{count} medições",
"notEnough": "Poucas medições",
"pulse": "Pulso {value} bpm",
"difference": "Manhã menos noite: {systolic}/{diastolic} mmHg.",
"differencePending": "Manhã e noite são comparadas quando cada uma tiver pelo menos {min} medições.",
"hours": "Manhã {morning}, tarde {midday}, noite {evening}, no seu fuso horário.",
"hourRange": "{start}–{end}",
"emptyTitle": "Nenhuma medição completa neste intervalo",
"emptyDescription": "Medições com valor sistólico e diastólico aparecem aqui.",
"error": "Não foi possível carregar as médias por período do dia.",
"chartTitle": {
"morning": "Pressão arterial pela manhã",
"midday": "Pressão arterial à tarde",
"evening": "Pressão arterial à noite"
}
}
},
"thresholds": {
+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({
+335
View File
@@ -0,0 +1,335 @@
/**
* Blood pressure by part of the day (#1331), through the real routes.
*
* Against Postgres:
* - the summary route pairs systolic and diastolic as the series does,
* buckets them by the hour in the record's own zone, holds back a mean
* below the reading floor, and takes the pulse of the reading's own
* source only;
* - a reading after midnight is an evening reading, and the narrowed chart
* series puts it on the evening before;
* - readings outside the window and another account's readings stay out;
* - `partOfDay` on the chart's series read narrows each day to the part,
* and is refused on any other read.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import type {
MeasurementSource,
MeasurementType,
Prisma,
} from "@/generated/prisma/client";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import {
openEndedFetchWindow,
localDayKeyFor,
} from "@/lib/charts/fetch-window";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
const prisma = getPrismaClient();
const TZ = "Europe/Berlin";
const OWNER = "bptod-owner";
const OTHER = "bptod-other";
const today = wallClockInTz(new Date(), TZ);
/** The local wall clock `hour:minute` on the day `daysBack` before today. */
function localAt(daysBack: number, hour: number, minute = 0): Date {
const d = new Date(
Date.UTC(today.year, today.month - 1, today.day - daysBack),
);
return zonedWallClockToUtc(
{
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour,
minute,
},
TZ,
);
}
let seq = 0;
function row(
userId: string,
type: MeasurementType,
value: number,
measuredAt: Date,
source: MeasurementSource = "MANUAL",
): Prisma.MeasurementCreateManyInput {
return {
id: `bptod-${++seq}`,
userId,
type,
unit: type === "PULSE" ? "bpm" : "mmHg",
source,
value,
measuredAt,
};
}
function bp(
userId: string,
at: Date,
sys: number,
dia: number,
pulse?: number,
): Prisma.MeasurementCreateManyInput[] {
const rows = [
row(userId, "BLOOD_PRESSURE_SYS", sys, at),
row(userId, "BLOOD_PRESSURE_DIA", dia, at),
];
if (pulse !== undefined) rows.push(row(userId, "PULSE", pulse, at));
return rows;
}
async function signIn(userId: string) {
const s = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
}
interface Summary {
days: number;
timeZone: string;
minReadings: number;
readingCount: number;
parts: Array<{
part: string;
startHour: number;
endHour: number;
readingCount: number;
systolic: number | null;
diastolic: number | null;
pulseReadingCount: number;
pulse: number | null;
}>;
morningEveningDifference: { systolic: number; diastolic: number } | null;
}
async function summary(query: string) {
const { GET } =
await import("@/app/api/measurements/blood-pressure/time-of-day/route");
return GET(
new NextRequest(
`http://localhost/api/measurements/blood-pressure/time-of-day?${query}`,
),
);
}
async function series(type: MeasurementType, days: number, partOfDay?: string) {
const window = openEndedFetchWindow(days, localDayKeyFor(new Date(), TZ), TZ);
const params = new URLSearchParams({
type,
aggregate: "daily",
source: "rollup",
from: window.from,
to: window.to,
limit: "5000",
});
if (partOfDay) params.set("partOfDay", partOfDay);
const { GET } = await import("@/app/api/measurements/route");
return GET(new NextRequest(`http://localhost/api/measurements?${params}`));
}
/** The local day key a series row stands for. */
const dayKeyOf = (iso: string) => localDayKeyFor(new Date(iso), TZ);
const dayKeyBack = (n: number) => localDayKeyFor(localAt(n, 12), TZ);
beforeAll(async () => {
await truncateAllTables(prisma);
for (const id of [OWNER, OTHER]) {
await prisma.user.create({ data: { id, username: id, timezone: TZ } });
invalidateUserTimezone(id);
}
const rows: Prisma.MeasurementCreateManyInput[] = [];
for (const d of [1, 2, 3, 4, 5, 6]) {
rows.push(...bp(OWNER, localAt(d, 7, 30), 140, 90, 72));
rows.push(...bp(OWNER, localAt(d, 21), 128, 80, 66));
// A watch's heart-rate row from another source beside the evening
// reading: never the reading's pulse.
rows.push(row(OWNER, "PULSE", 120, localAt(d, 21, 1), "APPLE_HEALTH"));
}
// Two midday readings: below the floor.
rows.push(...bp(OWNER, localAt(2, 13), 120, 70));
rows.push(...bp(OWNER, localAt(3, 13), 122, 72));
// A late bedtime reading at 01:15 after the evening of day 3 back.
rows.push(...bp(OWNER, localAt(2, 1, 15), 150, 95));
// It carries no pulse of its own; a watch's row of another source a minute
// later must not stand in for one.
rows.push(row(OWNER, "PULSE", 120, localAt(2, 1, 16), "APPLE_HEALTH"));
// Half a reading (no diastolic): not a pair.
rows.push(row(OWNER, "BLOOD_PRESSURE_SYS", 200, localAt(4, 8)));
// Outside a 7-day window.
rows.push(...bp(OWNER, localAt(10, 7, 30), 180, 110));
// Another account's morning.
rows.push(...bp(OTHER, localAt(1, 7, 30), 100, 60));
await prisma.measurement.createMany({ data: rows });
});
describe("GET /api/measurements/blood-pressure/time-of-day", () => {
it("resolves the three parts, the floor and the difference", async () => {
await signIn(OWNER);
const res = await summary("days=7");
expect(res.status).toBe(200);
const { data } = (await res.json()) as { data: Summary };
expect(data.timeZone).toBe(TZ);
expect(data.days).toBe(7);
expect(data.minReadings).toBe(3);
// 6 mornings + 2 middays + 6 evenings + the 01:15 one; the half reading,
// the old one and the other account's are out.
expect(data.readingCount).toBe(15);
const [morning, midday, evening] = data.parts;
expect(morning).toEqual({
part: "morning",
startHour: 4,
endHour: 12,
readingCount: 6,
systolic: 140,
diastolic: 90,
pulseReadingCount: 6,
pulse: 72,
});
expect(midday).toMatchObject({
part: "midday",
readingCount: 2,
systolic: null,
diastolic: null,
});
// (6 × 128 + 150) / 7 = 131.1, (6 × 80 + 95) / 7 = 82.1; the 01:15 reading
// carries no pulse and the watch rows of another source are not taken.
expect(evening).toEqual({
part: "evening",
startHour: 18,
endHour: 4,
readingCount: 7,
systolic: 131,
diastolic: 82,
pulseReadingCount: 6,
pulse: 66,
});
expect(data.morningEveningDifference).toEqual({
systolic: 9,
diastolic: 8,
});
});
it("sees only the signed-in record", async () => {
await signIn(OTHER);
const { data } = (await (await summary("days=7")).json()) as {
data: Summary;
};
expect(data.readingCount).toBe(1);
expect(data.parts[0].readingCount).toBe(1);
expect(data.parts[0].systolic).toBeNull();
expect(data.morningEveningDifference).toBeNull();
});
it("refuses a window it does not serve", async () => {
await signIn(OWNER);
expect((await summary("days=0")).status).toBe(422);
expect((await summary("days=abc")).status).toBe(422);
});
});
describe("GET /api/measurements?partOfDay=", () => {
it("narrows the evening series and puts an after-midnight reading on the evening before", async () => {
await signIn(OWNER);
const res = await series("BLOOD_PRESSURE_SYS", 7, "evening");
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
measurements: Array<{
value: number;
measuredAt: string;
count: number;
}>;
meta: { partOfDay?: string };
};
};
expect(body.data.meta.partOfDay).toBe("evening");
const byDay = new Map(
body.data.measurements.map((m) => [dayKeyOf(m.measuredAt), m]),
);
expect(byDay.size).toBe(6);
expect(byDay.get(dayKeyBack(3))).toMatchObject({ value: 139, count: 2 });
expect(byDay.get(dayKeyBack(2))).toMatchObject({ value: 128, count: 1 });
});
it("narrows the morning series to the morning readings", async () => {
await signIn(OWNER);
const body = (await (
await series("BLOOD_PRESSURE_SYS", 7, "morning")
).json()) as {
data: { measurements: Array<{ value: number; measuredAt: string }> };
};
const values = new Map(
body.data.measurements.map((m) => [dayKeyOf(m.measuredAt), m.value]),
);
expect(values.size).toBe(6);
// The chart series is per type, like the whole-day chart: the half
// reading's systolic draws on its morning.
expect(values.get(dayKeyBack(4))).toBe(170);
expect(values.get(dayKeyBack(1))).toBe(140);
});
it("answers an empty part with an empty series, not the whole day", async () => {
await signIn(OTHER);
const body = (await (
await series("BLOOD_PRESSURE_SYS", 7, "evening")
).json()) as {
data: { measurements: unknown[] };
};
expect(body.data.measurements).toEqual([]);
});
it("leaves the whole-day series as it was", async () => {
await signIn(OWNER);
const body = (await (await series("BLOOD_PRESSURE_SYS", 7)).json()) as {
data: { measurements: Array<{ measuredAt: string; count: number }> };
};
const day2 = body.data.measurements.find(
(m) => dayKeyOf(m.measuredAt) === dayKeyBack(2),
);
// Morning, midday, evening and the 01:15 reading of its own calendar day.
expect(day2?.count).toBe(4);
});
it("narrows the long range on weekly buckets", async () => {
await signIn(OWNER);
const res = await series("BLOOD_PRESSURE_SYS", 3650, "morning");
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { measurements: Array<{ value: number; count: number }> };
};
const total = body.data.measurements.reduce((n, m) => n + m.count, 0);
// Seven mornings in the window, the old one included, plus the half one.
expect(total).toBe(8);
});
it("refuses it on a series it does not narrow", async () => {
await signIn(OWNER);
expect((await series("WEIGHT", 7, "morning")).status).toBe(422);
});
});