diff --git a/messages/de.json b/messages/de.json index c35f312d1..38a6915f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 29d5ddf0b..f237a82cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 71ee4da7e..78b9b5535 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8d7b7a40c..ca9d45fbf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 02478f1e1..50bc0e36f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index fd4860a74..7fc9fdf61 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -986,7 +986,8 @@ "typeStressLevel": "스트레스 수준", "typeTrainingReadiness": "트레이닝 준비도", "typeAcuteTrainingLoad": "급성 훈련 부하", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요." }, "metricCatalog": { "title": "HealthLog가 기록할 수 있는 것", diff --git a/messages/nl.json b/messages/nl.json index 6680b0928..0e5b745b5 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 02ca079b0..c46749950 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ć", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 18df65df1..cbe230a62 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index 4cf579664..4fabe3cfa 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -986,7 +986,8 @@ "typeStressLevel": "Уровень стресса", "typeTrainingReadiness": "Готовность к тренировке", "typeAcuteTrainingLoad": "Острая тренировочная нагрузка", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Введите значение от {min} до {max} {unit}." }, "metricCatalog": { "title": "Что умеет отслеживать HealthLog", diff --git a/src/components/measurements/__tests__/measurement-edit-range.test.ts b/src/components/measurements/__tests__/measurement-edit-range.test.ts new file mode 100644 index 000000000..fa62aef1d --- /dev/null +++ b/src/components/measurements/__tests__/measurement-edit-range.test.ts @@ -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(); + }); +}); diff --git a/src/components/measurements/list/measurement-edit-sheet.tsx b/src/components/measurements/list/measurement-edit-sheet.tsx index 8a8200370..f0576b6e6 100644 --- a/src/components/measurements/list/measurement-edit-sheet.tsx +++ b/src/components/measurements/list/measurement-edit-sheet.tsx @@ -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 + } /> +
diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts index f4213ba97..8a524db5c 100644 --- a/src/components/measurements/list/use-measurement-edit.ts +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -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, + 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["t"]; + +/** The sentence for a value outside its band, in the unit the input shows. */ +function outOfRangeMessage( + display: Pick, + 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(null); const [editValue, setEditValue] = useState(""); @@ -105,10 +163,13 @@ export function useMeasurementEdit({ notes: "", }); const [editError, setEditError] = useState(null); + // A refused value is said under the value input, not in the banner. + const [editValueError, setEditValueError] = useState(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(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, diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index e526e6f12..e876a0ee2 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -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 }); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 6f2648c1d..2a6adcd8b 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -17,7 +17,8 @@ * through `readError` (server envelope `error` string, falling back * to `Request failed ()`), `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, 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> { async function throwApiError(res: Response): Promise { let meta: Record | 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; } + 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 { 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.