mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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);
|
||||
|
||||
Reference in new issue
Block a user