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,