fix(measurements): offer no edit or delete on a computed score

Recovery, strain and other computed rows in the measurement list carried
a pencil and a bin. The pencil opened an edit sheet labelled "Value ():"
whose save the server then refused, and the bin deleted a score the
engine owns. Both are gone for COMPUTED rows.

Readings from a device or a provider keep both controls: the server takes
a note or time edit on them, and a wrong scale reading has to stay
removable. The server is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:59:43 +01:00
1 parent a28c6b55ab
commit af673a63da
2 files changed
+90 -3

No files matched your search

@@ -0,0 +1,75 @@
/**
* 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.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManageDomain: () => true,
inSharedRecord: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import type { Measurement } from "../list-model";
import {
MeasurementRowActions,
type MeasurementRowContext,
} from "../row-actions";
const rows: MeasurementRowContext = {
canManage: true,
selectedIds: new Set<string>(),
expandedDayKeys: new Set<string>(),
toggleExpand: () => {},
navigateForType: () => {},
startEdit: () => {},
onToggleRow: () => {},
onDelete: () => {},
// The row's value formatting; the actions never read it.
display: {} as MeasurementRowContext["display"],
};
function render(source: string): string {
const m: Measurement = {
id: "m1",
type: "RECOVERY_SCORE",
value: 85,
unit: "score",
source,
measuredAt: "2026-11-10T12:00:00.000Z",
notes: null,
};
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementRowActions
m={m}
rows={rows}
isGrouped={false}
isExpanded={false}
drilldownId="d1"
layout="desktop"
/>
</I18nProvider>,
);
}
describe("MeasurementRowActions", () => {
it("offers no edit and no delete for a computed score", () => {
const html = render("COMPUTED");
expect(html).not.toContain('aria-label="Edit"');
expect(html).not.toContain('aria-label="Delete"');
});
it.each(["MANUAL", "WITHINGS", "APPLE_HEALTH"])(
"keeps edit and delete for a %s reading",
(source) => {
const html = render(source);
expect(html).toContain('aria-label="Edit"');
expect(html).toContain('aria-label="Delete"');
},
);
});
@@ -43,10 +43,22 @@ export function rowState(m: Measurement, rows: MeasurementRowContext) {
};
}
/**
* A score the server computes (recovery, strain, stress) offers neither
* edit nor delete: its value is the engine's, the server refuses a changed
* one on save, and the edit sheet has nothing of the person's to correct.
* Readings from a device or a provider keep both: the server takes a note or
* time edit on them, and a wrong scale reading has to be removable.
*/
export function isServerComputed(m: Pick<Measurement, "source">): boolean {
return m.source === "COMPUTED";
}
/**
* A row's controls: the chevron into a day's or night's samples for a
* grouped row; edit (owner only) and delete for a reading. The table's
* buttons are 40 px, the phone list's the large icon size.
* grouped row; edit (owner only) and delete for a reading, neither for a
* computed score. The table's buttons are 40 px, the phone list's the large
* icon size.
*/
export function MeasurementRowActions({
m,
@@ -85,7 +97,7 @@ export function MeasurementRowActions({
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`}
/>
</Button>
) : (
) : isServerComputed(m) ? null : (
<>
{canManage && (
<Button