From 048dbbee9f53bf3f5b986d31acc5e27a6e72ab00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:48:03 +0100 Subject: [PATCH] fix(measurements): keep computed scores out of the bulk delete The row menu stopped offering edit and delete on a computed score (recovery, strain, stress), but the list still drew its selection checkbox and "select all" took it into the page, so the bulk bar could delete a page of scores, and the tombstones kept the recompute out. A computed row now has no checkbox on either layout and stays out of the page selection, and the bulk-delete route skips computed rows if a client sends them anyway. The iOS app treats these rows as read-only and does not rely on deleting them. --- .../bulk-delete/__tests__/route.test.ts | 14 ++++ src/app/api/measurements/bulk-delete/route.ts | 10 ++- .../list/__tests__/row-actions.test.tsx | 68 ++++++++++++++++++- .../list/measurement-card-list.tsx | 3 +- .../measurements/list/measurement-table.tsx | 6 +- .../list/use-measurement-list-data.ts | 6 +- 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index 55f4cb17c..cadf4b3e0 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -156,6 +156,20 @@ describe("POST /api/measurements/bulk-delete", () => { }); }); + it("leaves a computed score out of the selection it deletes", async () => { + // The read is scoped like the write, so a computed row never reaches it. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + await POST(postReq({ ids: ["score-1"] })); + + expect( + vi.mocked(prisma.measurement.findMany).mock.calls[0][0]?.where, + ).toMatchObject({ source: { not: "COMPUTED" } }); + }); + it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => { // 4 deleted rows: 2× WEIGHT on the same UTC day, 1× WEIGHT next day, // 1× PULSE same day as the first → 3 distinct (type, day) keys. diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 16e2a22ee..c146b2580 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise { // other half and the cuff's pulse, `bloodPressureSiblings`) into the same // statement, so the shared `deletedAt` lets the restore route bring the // reading back whole. + // + // A computed score (recovery, strain, stress) is skipped: the server + // rewrites it from its inputs, and a tombstone kept the recompute out. const { affected, count } = await prisma.$transaction(async (tx) => { const selected = await tx.measurement.findMany({ - where: { id: { in: ids }, userId: user.id, deletedAt: null }, + where: { + id: { in: ids }, + userId: user.id, + deletedAt: null, + source: { not: "COMPUTED" }, + }, select: { id: true, type: true, source: true, measuredAt: true }, }); const siblings = await bloodPressureSiblings(tx, user.id, selected, "live"); diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx index e481a9427..db5bd6912 100644 --- a/src/components/measurements/list/__tests__/row-actions.test.tsx +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -1,7 +1,7 @@ /** - * A computed score offers neither edit nor delete in the measurement list; - * a reading of the person's own, or one a device or provider sent, keeps - * both. + * A computed score offers neither edit nor delete in the measurement list, + * and no selection checkbox for the bulk delete; a reading of the person's + * own, or one a device or provider sent, keeps all three. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -15,6 +15,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import type { Measurement } from "../list-model"; +import { MeasurementCardList } from "../measurement-card-list"; +import { MeasurementTable } from "../measurement-table"; import { MeasurementRowActions, type MeasurementRowContext, @@ -73,3 +75,63 @@ describe("MeasurementRowActions", () => { }, ); }); + +describe("the row checkbox", () => { + const listRows: MeasurementRowContext = { + ...rows, + display: { + rowDisplay: (m: Measurement) => ({ value: m.value, unit: m.unit }), + rowIdentityAttrs: () => ({}), + } as unknown as MeasurementRowContext["display"], + }; + const reading = (id: string, source: string): Measurement => ({ + id, + type: "RECOVERY_SCORE", + value: 85, + unit: "score", + source, + measuredAt: "2026-11-10T12:00:00.000Z", + notes: null, + }); + const checkboxes = (html: string) => + html.split('aria-label="Select row"').length - 1; + + it("is offered for a reading and not for a computed score, on both layouts", () => { + const measurements = [reading("m1", "MANUAL"), reading("m2", "COMPUTED")]; + const wrap = (node: React.ReactNode) => + renderToStaticMarkup( + {node}, + ); + expect( + checkboxes( + wrap( + , + ), + ), + ).toBe(1); + expect( + checkboxes( + wrap( + {}, + } as unknown as Parameters[0]["filters"] + } + list={ + { + selectAll: "none", + pageIds: [], + onToggleSelectAll: () => {}, + } as unknown as Parameters[0]["list"] + } + />, + ), + ), + ).toBe(1); + }); +}); diff --git a/src/components/measurements/list/measurement-card-list.tsx b/src/components/measurements/list/measurement-card-list.tsx index ce5f90d09..fdc48cf75 100644 --- a/src/components/measurements/list/measurement-card-list.tsx +++ b/src/components/measurements/list/measurement-card-list.tsx @@ -18,6 +18,7 @@ import { import { DayDrillDown, SleepNightCaption } from "./day-drill-down"; import { MeasurementRowActions, + isServerComputed, rowState, typeLabel, type MeasurementRowContext, @@ -83,7 +84,7 @@ function MeasurementCard({ >
- {!isGrouped && canManage && ( + {!isGrouped && !isServerComputed(m) && canManage && ( // The checkbox owns its 44 px hit area through an `after` // hit-slop inside a plain layout `
`: a wrapper `