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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 12:48:03 +01:00
1 parent a4b14609bf
commit 048dbbee9f
6 files changed
+98 -9

No files matched your search

@@ -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.
@@ -81,9 +81,17 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// 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");
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
expect(
checkboxes(
wrap(
<MeasurementCardList measurements={measurements} rows={listRows} />,
),
),
).toBe(1);
expect(
checkboxes(
wrap(
<MeasurementTable
measurements={measurements}
rows={listRows}
filters={
{
sortBy: "measuredAt",
sortDir: "desc",
toggleSort: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["filters"]
}
list={
{
selectAll: "none",
pageIds: [],
onToggleSelectAll: () => {},
} as unknown as Parameters<typeof MeasurementTable>[0]["list"]
}
/>,
),
),
).toBe(1);
});
});
@@ -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({
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!isGrouped && canManage && (
{!isGrouped && !isServerComputed(m) && canManage && (
// The checkbox owns its 44 px hit area through an `after`
// hit-slop inside a plain layout `<div>`: a wrapper `<button>`
// would nest a button in a button, invalid markup that failed
@@ -23,6 +23,7 @@ import { MEASUREMENT_TYPE_COLORS as TYPE_COLORS } from "../measurement-list-meta
import { DayDrillDown, SleepNightCaption } from "./day-drill-down";
import {
MeasurementRowActions,
isServerComputed,
rowState,
typeLabel,
type MeasurementRowContext,
@@ -154,8 +155,9 @@ function MeasurementTableRow({
data-display-unit={isGrouped ? m.unit : rowDisplay(m).unit}
>
<TableCell className="pl-4">
{/* A grouped row is not individually deletable: no checkbox. */}
{!isGrouped && canManage && (
{/* A grouped row is not individually deletable, and a computed score
is the server's to rewrite: no checkbox for either. */}
{!isGrouped && !isServerComputed(m) && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
@@ -22,6 +22,7 @@ import {
} from "@/lib/query-keys";
import { PAGE_SIZE, isGroupedRow, type Measurement } from "./list-model";
import { isServerComputed } from "./row-actions";
import type { MeasurementListFilters } from "./use-measurement-list-filters";
/**
@@ -178,9 +179,10 @@ export function useMeasurementListData({
});
// Synthetic day and night rows collapse many raw rows behind a `dayKey`
// and are not individually deletable, so they are not selectable.
// and are not individually deletable, and a computed score is rewritten
// by the server, so neither is selectable.
const pageIds = (data?.measurements ?? [])
.filter((m) => !isGroupedRow(m))
.filter((m) => !isGroupedRow(m) && !isServerComputed(m))
.map((m) => m.id);
const selectAll = selectAllState(selectedIds, pageIds);
const selectedOnPage = selectedCountOnPage(selectedIds, pageIds);