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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 10:34:01 +01:00
1 parent ad5d154561
commit 17d9c15dbc
2 files changed
+83 -1

No files matched your search

@@ -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,
@@ -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"]);
});
});