From 08d5acdcb5ec724bda6747e4e70cc3444de89514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 21:53:38 +0200 Subject: [PATCH] feat(measurements): log a resting heart rate by hand The entry form offered pulse but not resting heart rate, which has its own chart and series, so a value read off a watch or taken on waking could only land among spot pulses. The form now offers it next to pulse. The server already gates the type on the recovery module (its resting_hr domain), so the form, the dashboard summary, the resting-pulse page, its trend slot and its correlation channel now follow that module too. Refs #1079 --- ...easurement-form-resting-heart-rate.test.ts | 45 +++++++++++++++++++ .../measurements/measurement-form.tsx | 8 ++++ src/lib/modules/surface.ts | 8 ++++ 3 files changed, 61 insertions(+) create mode 100644 src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts diff --git a/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts b/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts new file mode 100644 index 000000000..5a2074a91 --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-resting-heart-rate.test.ts @@ -0,0 +1,45 @@ +/** + * The manual-entry form offers the resting heart rate (#1079). + * + * Resting heart rate has its own chart and series, separate from spot pulse. + * A value read off a watch or taken on waking had no row in the form, so it + * could only be logged as a pulse, which feeds a different chart. + */ +import { describe, it, expect } from "vitest"; + +import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement"; +import { + MEASUREMENT_FORM_TYPE_VALUES, + MEASUREMENT_TYPES, +} from "@/components/measurements/measurement-form"; +import en from "../../../../messages/en.json"; + +describe("manual entry of the resting heart rate", () => { + it("offers it as its own type, next to pulse", () => { + expect(MEASUREMENT_FORM_TYPE_VALUES).toContain("RESTING_HEART_RATE"); + const values = MEASUREMENT_TYPES.map((t) => t.value); + expect(values.indexOf("RESTING_HEART_RATE")).toBe( + values.indexOf("PULSE") + 1, + ); + }); + + it("uses the server's unit, a labelled row and a plausible placeholder", () => { + const row = MEASUREMENT_TYPES.find((t) => t.value === "RESTING_HEART_RATE"); + expect(row).toBeDefined(); + expect(row && "unit" in row ? row.unit : undefined).toBe( + getUnitForType("RESTING_HEART_RATE"), + ); + expect( + (en as { measurements: Record }).measurements + .typeRestingHeartRate, + ).toBeTruthy(); + const range = VALUE_RANGES.RESTING_HEART_RATE; + const placeholder = Number( + row && "placeholder" in row ? row.placeholder : NaN, + ); + if (range) { + expect(placeholder).toBeGreaterThanOrEqual(range.min); + expect(placeholder).toBeLessThanOrEqual(range.max); + } + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 092bc2e7a..662a6c310 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -67,6 +67,14 @@ export const MEASUREMENT_TYPES = [ unit: "bpm", placeholder: "72", }, + // The resting heart rate has its own chart and series; a value read off a + // watch or taken on waking belongs there rather than among spot pulses. + { + value: "RESTING_HEART_RATE", + labelKey: "measurements.typeRestingHeartRate", + unit: "bpm", + placeholder: "58", + }, { value: "BODY_FAT", labelKey: "measurements.typeBodyFat", diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index e0d3ba08d..efc803457 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -100,6 +100,9 @@ const STATIC_SURFACE_MODULE = { "insights-page:six-minute-walk": "recovery", "insights-page:stair-ascent-speed": "recovery", "insights-page:stair-descent-speed": "recovery", + // The resting heart rate is the recovery module's `resting_hr` domain; its + // page, trend slot, correlation channel and summary key follow it. + "insights-page:resting-pulse": "recovery", // ── Insights overview blocks ── "overview:cycle-summary": "cycle", @@ -115,6 +118,7 @@ const STATIC_SURFACE_MODULE = { // ── Trends row ── "trend:mood": "mood", "trend:sleep": "sleep", + "trend:resting_hr": "recovery", // ── Add menu ── "capture:mood": "mood", @@ -141,6 +145,9 @@ const STATIC_SURFACE_MODULE = { "summary:SLEEP_DURATION": "sleep", "summary:BLOOD_GLUCOSE": "glucose", "summary:CARDIO_RECOVERY": "recovery", + // The recovery module owns the resting_hr domain (`measurement-scope.ts`), + // so the server gates the type on it; the form and the dashboard follow. + "summary:RESTING_HEART_RATE": "recovery", "summary:SIX_MINUTE_WALK_DISTANCE": "recovery", "summary:STAIR_ASCENT_SPEED": "recovery", "summary:STAIR_DESCENT_SPEED": "recovery", @@ -189,6 +196,7 @@ const STATIC_SURFACE_MODULE = { "correlation:MOOD": "mood", "correlation:SLEEP_DURATION": "sleep", "correlation:BLOOD_GLUCOSE": "glucose", + "correlation:RESTING_HEART_RATE": "recovery", "correlation:MEDICATION_COMPLIANCE": "medications", // Symptom burden is read from the illness journal. "correlation:SYMPTOM_SEVERITY": "illness",