From 17d9c15dbcd993f96e7adacd429e48f6161b5d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 10:34:01 +0100 Subject: [PATCH] fix(measurements): keep a folded hour out of a blood-pressure reading The reading delete found the cuff's pulse by owner, source and identical instant. The dense fold anchors an hour of heart rate at the local HH:30 under the samples' source, so a reading taken at half past took that hour with it. Aggregate (stats:) rows are never part of a reading now, for the delete and for the restore. --- .../measurements/blood-pressure-reading.ts | 25 ++++++++ .../blood-pressure-reading-delete.test.ts | 59 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/lib/measurements/blood-pressure-reading.ts b/src/lib/measurements/blood-pressure-reading.ts index 589a7fb66..717cf508c 100644 --- a/src/lib/measurements/blood-pressure-reading.ts +++ b/src/lib/measurements/blood-pressure-reading.ts @@ -14,6 +14,14 @@ * Deleting a systolic or a diastolic takes the whole reading with it. * Deleting a pulse takes only the pulse: a pulse is also a value of its own, * and removing one is no reason to remove a blood pressure. + * + * An aggregate row is never part of a reading. The dense fold stores an + * hour of heart rate as one `stats:` row at the local HH:30 of that hour + * (`canonicalHourlyTimestamp`), under the same source as the samples it + * folded, and the client's hourly heart-rate bucket is a `stats:` row as + * well. A cuff reading taken at half past, from the same source, shares its + * instant with that row; it is still an hour of somebody's heart rate, not + * the cuff's pulse, and a delete must not take it. */ import type { MeasurementType, Prisma } from "@/generated/prisma/client"; @@ -30,6 +38,9 @@ const BP_READING_TYPES: MeasurementType[] = [ "PULSE", ]; +/** The externalId prefix every aggregate (folded or bucketed) row carries. */ +const AGGREGATE_EXTERNAL_ID_PREFIX = "stats:"; + export interface ReadingAnchor { id: string; type: MeasurementType | string; @@ -70,6 +81,20 @@ export async function bloodPressureSiblings( userId, id: { notIn: rows.map((r) => r.id) }, type: { in: BP_READING_TYPES }, + // Spelled with the NULL arm: a bare `NOT startsWith` is NULL, not + // true, for the NULL externalId of a manual row and would drop it. + AND: [ + { + OR: [ + { externalId: null }, + { + NOT: { + externalId: { startsWith: AGGREGATE_EXTERNAL_ID_PREFIX }, + }, + }, + ], + }, + ], OR: anchors.map((a) => ({ source: a.source as Prisma.MeasurementWhereInput["source"], measuredAt: a.measuredAt, diff --git a/tests/integration/blood-pressure-reading-delete.test.ts b/tests/integration/blood-pressure-reading-delete.test.ts index d7f4028d9..2e930187d 100644 --- a/tests/integration/blood-pressure-reading-delete.test.ts +++ b/tests/integration/blood-pressure-reading-delete.test.ts @@ -10,11 +10,16 @@ * its own syncVersion (so the sync feed carries each tombstone), and the * undo, which names only the row the person tapped, brings the whole * reading back. A reading of another source at the same instant, a reading - * a minute later, and a pulse deleted on its own are left alone. + * a minute later, and a pulse deleted on its own are left alone. So is the + * folded hour of heart rate the dense fold anchors at the local HH:30: a + * reading taken at half past shares its instant and its source, and is + * still not the same thing. */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { canonicalHourlyTimestamp } from "@/lib/measurements/consolidation-tz"; + import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -195,4 +200,56 @@ describe("deleting a blood-pressure reading", () => { // The pulse deleted at another moment stays deleted. expect(await deletedIds()).toEqual(["next-pulse"]); }); + + it("BP at :30 beside a folded hour: leaves the hour of heart rate alone", async () => { + const prisma = getPrismaClient(); + // 09:30 local (Europe/Berlin, the default zone) on a day the fold took: + // the anchor of that hour's mean. + const halfPast = canonicalHourlyTimestamp("2026-10-20", 9, "Europe/Berlin"); + await prisma.measurement.createMany({ + data: [ + { + id: "ah-sys", + userId: USER, + type: "BLOOD_PRESSURE_SYS", + value: 134, + unit: "mmHg", + source: "APPLE_HEALTH", + measuredAt: halfPast, + externalId: "uuid-ah-sys", + }, + { + id: "ah-dia", + userId: USER, + type: "BLOOD_PRESSURE_DIA", + value: 86, + unit: "mmHg", + source: "APPLE_HEALTH", + measuredAt: halfPast, + externalId: "uuid-ah-dia", + }, + { + id: "folded-hour", + userId: USER, + type: "PULSE", + value: 71, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: halfPast, + externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-10-20T09", + }, + ], + }); + + await deleteOne("ah-sys"); + expect(await deletedIds()).toEqual(["ah-dia", "ah-sys"]); + expect((await rows())["folded-hour"]!.syncVersion).toBe(1); + + const { restored } = await post("restore", ["ah-sys"]); + expect(restored).toBe(2); + + const { deleted } = await post("bulk-delete", ["ah-dia"]); + expect(deleted).toBe(2); + expect(await deletedIds()).toEqual(["ah-dia", "ah-sys"]); + }); });