mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user