From af673a63daea25d24e81d17dcdf9d2ab3804f4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:59:43 +0100 Subject: [PATCH] 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. --- .../list/__tests__/row-actions.test.tsx | 75 +++++++++++++++++++ .../measurements/list/row-actions.tsx | 18 ++++- 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/components/measurements/list/__tests__/row-actions.test.tsx diff --git a/src/components/measurements/list/__tests__/row-actions.test.tsx b/src/components/measurements/list/__tests__/row-actions.test.tsx new file mode 100644 index 000000000..e481a9427 --- /dev/null +++ b/src/components/measurements/list/__tests__/row-actions.test.tsx @@ -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(), + expandedDayKeys: new Set(), + 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( + + + , + ); +} + +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"'); + }, + ); +}); diff --git a/src/components/measurements/list/row-actions.tsx b/src/components/measurements/list/row-actions.tsx index 92a0c0244..cf40248d3 100644 --- a/src/components/measurements/list/row-actions.tsx +++ b/src/components/measurements/list/row-actions.tsx @@ -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): 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" : ""}`} /> - ) : ( + ) : isServerComputed(m) ? null : ( <> {canManage && (