fix(measurements): mark an implausible edited value on its field

Typing 720 instead of 72.0 in the measurement edit sheet showed the
English banner "Validation failed" in every locale, with no field
marked. The route refuses a changed value outside the type's band with a
422 whose issue list names the field, and the client dropped that list.

The sheet now checks the same band before sending and says, under the
value input and in the unit it shows, which range is accepted. ApiError
carries the field paths of a refusal, so a value the server still
refuses lands on the field too; any other refusal goes through
localizedApiError instead of printing the server's prose.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:56:59 +01:00
1 parent 0639d28f6a
commit 7001308636
15 files changed
+243 -36

No files matched your search

+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Stresslevel",
"typeTrainingReadiness": "Trainingsbereitschaft",
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein."
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Stress level",
"typeTrainingReadiness": "Training readiness",
"typeAcuteTrainingLoad": "Acute training load",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Enter a value between {min} and {max} {unit}."
},
"metricCatalog": {
"title": "What HealthLog can track",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Nivel de estrés",
"typeTrainingReadiness": "Preparación para entrenar",
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}."
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Niveau de stress",
"typeTrainingReadiness": "Disponibilité à l'entraînement",
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}."
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Livello di stress",
"typeTrainingReadiness": "Prontezza all'allenamento",
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}."
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "스트레스 수준",
"typeTrainingReadiness": "트레이닝 준비도",
"typeAcuteTrainingLoad": "급성 훈련 부하",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요."
},
"metricCatalog": {
"title": "HealthLog가 기록할 수 있는 것",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Stressniveau",
"typeTrainingReadiness": "Trainingsgereedheid",
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}."
},
"metricCatalog": {
"title": "Wat HealthLog kan bijhouden",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Poziom stresu",
"typeTrainingReadiness": "Gotowość do treningu",
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}."
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Nível de estresse",
"typeTrainingReadiness": "Prontidão para treinar",
"typeAcuteTrainingLoad": "Carga de treino aguda",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Informe um valor entre {min} e {max} {unit}."
},
"metricCatalog": {
"title": "O que o HealthLog pode acompanhar",
+2 -1
View File
@@ -986,7 +986,8 @@
"typeStressLevel": "Уровень стресса",
"typeTrainingReadiness": "Готовность к тренировке",
"typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
"sourceGarmin": "Garmin"
"sourceGarmin": "Garmin",
"valueOutOfRange": "Введите значение от {min} до {max} {unit}."
},
"metricCatalog": {
"title": "Что умеет отслеживать HealthLog",
@@ -0,0 +1,74 @@
/**
* The edit sheet refuses an implausible value at the value field, before the
* request, in the unit the input shows. The server checks the same band and
* used to be the only one that did: its 422 reached the sheet as the English
* banner "Validation failed", in every locale, with no field marked.
*/
import { describe, expect, it } from "vitest";
import type { UnitDisplay } from "@/hooks/use-unit-display";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { editValueRange } from "../list/use-measurement-edit";
import { rowDisplayFor } from "../list/use-measurement-row-display";
const KG_PER_LB = 0.45359237;
function display(
preference: "metric" | "imperial",
glucose: "mg/dL" | "mmol/L",
) {
const imperial = preference === "imperial";
const unitDisplay = {
preference,
isTransformed: (type: string) => imperial && type === "WEIGHT",
toDisplay: (type: string, v: number) =>
imperial && type === "WEIGHT" ? v / KG_PER_LB : v,
fromDisplay: (type: string, v: number) =>
imperial && type === "WEIGHT" ? v * KG_PER_LB : v,
unitFor: (type: string) =>
imperial && type === "WEIGHT" ? "lb" : getUnitForType(type),
} as unknown as UnitDisplay;
return rowDisplayFor(glucose, unitDisplay);
}
const row = (type: string, unit: string) => ({
id: "m1",
type,
value: 72,
unit,
source: "MANUAL",
measuredAt: "2026-11-10T08:00:00.000Z",
notes: null,
});
describe("editValueRange", () => {
it("names the band in the stored unit for a metric reader", () => {
expect(
editValueRange(display("metric", "mg/dL"), row("WEIGHT", "kg")),
).toEqual({ min: 1, max: 500, unit: "kg" });
});
it("names it in pounds for an imperial reader, who types pounds", () => {
const range = editValueRange(
display("imperial", "mg/dL"),
row("WEIGHT", "kg"),
);
expect(range?.unit).toBe("lb");
expect(range?.max).toBeCloseTo(1102.3, 1);
});
it("names it in mmol/L for a glucose edit in mmol/L", () => {
const range = editValueRange(
display("metric", "mmol/L"),
row("BLOOD_GLUCOSE", "mg/dL"),
);
expect(range?.unit).toBe("mmol/L");
expect(range!.max).toBeLessThan(100);
});
it("has nothing to say for a type without a band", () => {
expect(
editValueRange(display("metric", "mg/dL"), row("NO_SUCH_TYPE", "x")),
).toBeNull();
});
});
@@ -3,6 +3,7 @@
import { createPortal } from "react-dom";
import { Loader2, MoreHorizontal, Trash2 } from "lucide-react";
import { FieldError } from "@/components/forms/field-error";
import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal";
import {
AlertDialog,
@@ -57,6 +58,8 @@ export function MeasurementEditSheet({
setEditValue,
editError,
editErrorDescriptor,
editValueError,
editValueErrorId,
editMeasuredAt,
setEditMeasuredAt,
editNotes,
@@ -103,9 +106,12 @@ export function MeasurementEditSheet({
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
aria-invalid={!!(editValueError || editError) || undefined}
aria-describedby={
editValueError ? editValueErrorId : editErrorDescriptor
}
/>
<FieldError id={editValueErrorId} message={editValueError} />
</div>
<div className="space-y-2">
@@ -5,9 +5,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal";
import { ApiError, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { toCanonicalMgdl } from "@/lib/glucose";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/measurements/unit-map";
import {
VALUE_RANGES,
validateMeasurementRange,
} from "@/lib/measurements/value-ranges";
import {
invalidateKeys,
measurementDependentKeys,
@@ -78,6 +83,58 @@ export function storedValueFor(
return parsedValue;
}
/**
* The stored value's plausible band for the row's type, in the unit the edit
* input shows, or null for a type without one. The same table the server
* checks the edit against.
*/
export function editValueRange(
{ rowDisplay }: Pick<MeasurementRowDisplay, "rowDisplay">,
measurement: Measurement,
): { min: number; max: number; unit: string } | null {
const range = VALUE_RANGES[measurement.type];
if (!range) return null;
const low = rowDisplay({ ...measurement, value: range.min });
const high = rowDisplay({ ...measurement, value: range.max });
const round = (n: number) => Math.round(n * 10) / 10;
return { min: round(low.value), max: round(high.value), unit: low.unit };
}
type Translate = ReturnType<typeof useTranslations>["t"];
/** The sentence for a value outside its band, in the unit the input shows. */
function outOfRangeMessage(
display: Pick<MeasurementRowDisplay, "rowDisplay">,
measurement: Measurement,
t: Translate,
formatNumber: (value: number) => string,
): string {
const range = editValueRange(display, measurement);
return range
? t("measurements.valueOutOfRange", {
min: formatNumber(range.min),
max: formatNumber(range.max),
unit: range.unit,
})
: t("measurements.invalidValue");
}
/**
* The banner for a refused save. A 409 names its reason in `meta.errorCode`:
* the edit collides with another reading's (type, time, source, stage), or
* the reading belongs to a server-owned source (connector, import, computed).
*/
function saveRefusal(err: unknown, t: Translate): string {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
if (code === "measurement.duplicate_timestamp") {
return t("measurements.duplicateTimestamp");
}
if (code === "measurement.update.server_owned_source") {
return t("measurements.serverOwnedSource");
}
return localizedApiError(err, t, "measurements.saveError");
}
/**
* The edit sheet's state: the reading being edited, the draft seeded in the
* person's display unit, the save (converted back to the stored unit) and
@@ -94,6 +151,7 @@ export function useMeasurementEdit({
deleteMutation: MeasurementListData["deleteMutation"];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [editing, setEditing] = useState<Measurement | null>(null);
const [editValue, setEditValue] = useState("");
@@ -105,10 +163,13 @@ export function useMeasurementEdit({
notes: "",
});
const [editError, setEditError] = useState<string | null>(null);
// A refused value is said under the value input, not in the banner.
const [editValueError, setEditValueError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// The error banner is linked to the inputs through `aria-describedby`, so a
// screen reader announces why a submit was refused.
const editErrorId = useId();
const editValueErrorId = useId();
// The sheet's footer is a slot the form portals its buttons into.
const editFormId = useId();
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
@@ -138,28 +199,19 @@ export function useMeasurementEdit({
void refetchInactiveDailyReads(queryClient);
},
onError: (err) => {
// A 409 names its reason in `meta.errorCode`: the edit collides with
// another reading's (type, time, source, stage), or the reading belongs
// to a server-owned source (connector, import, computed).
if (
err instanceof ApiError &&
err.meta?.errorCode === "measurement.duplicate_timestamp"
) {
setEditError(t("measurements.duplicateTimestamp"));
return;
if (err instanceof ApiError && err.issuePaths.includes("value")) {
setEditValueError(valueRefusal());
} else {
setEditError(saveRefusal(err, t));
}
if (
err instanceof ApiError &&
err.meta?.errorCode === "measurement.update.server_owned_source"
) {
setEditError(t("measurements.serverOwnedSource"));
return;
}
setEditError(
err instanceof Error ? err.message : t("measurements.saveError"),
);
},
});
function valueRefusal(): string {
return editing
? outOfRangeMessage(display, editing, t, fmt.number)
: t("measurements.invalidValue");
}
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
@@ -188,11 +240,13 @@ export function useMeasurementEdit({
setEditNotes(seed.notes);
setEditSeed(seed);
setEditError(null);
setEditValueError(null);
}
function dismissEdit() {
setEditing(null);
setEditError(null);
setEditValueError(null);
setEditDeleteDialogOpen(false);
}
@@ -216,9 +270,11 @@ export function useMeasurementEdit({
e.preventDefault();
if (!editing) return;
setEditError(null);
setEditValueError(null);
const parsedValue = Number(editValue);
if (!Number.isFinite(parsedValue)) {
setEditError(t("measurements.invalidValue"));
setEditValueError(t("measurements.invalidValue"));
return;
}
@@ -229,8 +285,16 @@ export function useMeasurementEdit({
}
const canonicalValue = storedValueFor(display, editing, parsedValue);
// The server refuses a changed value outside the type's band; say so at
// the field before sending, in the unit the input shows.
if (
editValue !== editSeed.value &&
validateMeasurementRange(editing.type, canonicalValue) !== null
) {
setEditValueError(valueRefusal());
return;
}
setEditError(null);
updateMutation.mutate({
id: editing.id,
value: canonicalValue,
@@ -250,6 +314,8 @@ export function useMeasurementEdit({
editError,
editErrorId,
editErrorDescriptor: editError ? editErrorId : undefined,
editValueError,
editValueErrorId,
editFormId,
editFooterEl,
setEditFooterEl,
+32
View File
@@ -190,6 +190,38 @@ describe("apiFetchEnvelope", () => {
});
});
describe("ApiError.issuePaths", () => {
it("names the fields a 422's issue list refused", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
{
data: null,
error: "Validation failed",
details: {
issues: [
{ path: "value", code: "custom", message: "out of range" },
],
},
},
422,
),
);
const error = await apiPut("/api/measurements/m1", { value: 720 }).catch(
(e: unknown) => e,
);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).issuePaths).toEqual(["value"]);
});
it("is empty when the refusal named no field", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ data: null, error: "Nope" }, 409),
);
const error = await apiGet("/api/x").catch((e: unknown) => e);
expect((error as ApiError).issuePaths).toEqual([]);
});
});
describe("apiFetchRaw", () => {
it("returns the raw Response without ok-check or unwrap", async () => {
const res = new Response("plain", { status: 500 });
+21 -2
View File
@@ -17,7 +17,8 @@
* through `readError` (server envelope `error` string, falling back
* to `Request failed (<status>)`), `status` carrying the HTTP code,
* `meta` carrying the error envelope's `meta` object when present
* (e.g. `meta.errorCode`).
* (e.g. `meta.errorCode`), `issuePaths` the fields a 422's
* `details.issues` names.
* - Network failures reject with the native `TypeError` from `fetch`,
* exactly as before — only HTTP-level failures become `ApiError`.
* - 401 / 403 / 429 are NOT special-cased: the wrapper throws the same
@@ -70,17 +71,24 @@ export class ApiError extends Error {
* server). Its first characters are the error id a person reads out.
*/
readonly requestId: string | null;
/**
* The fields a refusal's `details.issues` names (`"value"`, `"measuredAt"`),
* so a form can mark the one the server refused. Empty when it named none.
*/
readonly issuePaths: readonly string[];
constructor(
message: string,
status: number,
meta?: Record<string, unknown>,
requestId?: string | null,
issuePaths: readonly string[] = [],
) {
super(message);
this.name = "ApiError";
this.status = status;
this.meta = meta;
this.issuePaths = issuePaths;
const fromMeta =
typeof meta?.requestId === "string" && meta.requestId.length > 0
? meta.requestId
@@ -97,11 +105,21 @@ interface Envelope<T, M = Record<string, unknown>> {
async function throwApiError(res: Response): Promise<never> {
let meta: Record<string, unknown> | undefined;
let issuePaths: string[] = [];
try {
const json = (await res.clone().json()) as { meta?: unknown };
const json = (await res.clone().json()) as {
meta?: unknown;
details?: { issues?: unknown };
};
if (json && typeof json.meta === "object" && json.meta !== null) {
meta = json.meta as Record<string, unknown>;
}
const issues = json?.details?.issues;
if (Array.isArray(issues)) {
issuePaths = issues.flatMap((issue: { path?: unknown }) =>
typeof issue?.path === "string" ? [issue.path] : [],
);
}
} catch {
/* non-JSON body — readError supplies the fallback message */
}
@@ -110,6 +128,7 @@ async function throwApiError(res: Response): Promise<never> {
res.status,
meta,
res.headers.get("x-request-id"),
issuePaths,
);
// A server-side failure is worth an id in the session's support info,
// whether or not the call site ends up showing it.