diff --git a/src/components/measurements/duration-read-as.tsx b/src/components/measurements/duration-read-as.tsx
new file mode 100644
index 000000000..6af89a891
--- /dev/null
+++ b/src/components/measurements/duration-read-as.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { splitDurationMinutes } from "@/lib/measurements/parse-duration";
+
+/**
+ * How a duration entry was understood, under the field: "Read as 7 h 30 min".
+ * Shown for every entry the parser accepts, so "7.5", "7:30" and "7h30" can
+ * each be checked against what the person meant before saving.
+ */
+export function DurationReadAs({ minutes }: { minutes: number }) {
+ const { t } = useTranslations();
+ const parts = splitDurationMinutes(minutes);
+ const duration =
+ parts.hours === 0
+ ? t("measurements.durationMinutesOnly", { minutes: parts.minutes })
+ : parts.minutes === 0
+ ? t("measurements.durationHoursOnly", { hours: parts.hours })
+ : t("measurements.durationHoursMinutes", parts);
+ return (
+
+ {t("measurements.durationReadAs", { duration })}
+
+ );
+}
diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx
index 662a6c310..c588d32ec 100644
--- a/src/components/measurements/measurement-form.tsx
+++ b/src/components/measurements/measurement-form.tsx
@@ -30,9 +30,11 @@ import { useAuth } from "@/hooks/use-auth";
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import {
+ durationEntryUnit,
entryValueToCanonical,
parseDecimalEntry,
} from "@/lib/measurements/entry-units";
+import { parseDurationEntry } from "@/lib/measurements/parse-duration";
import {
invalidateKeys,
measurementDependentKeys,
@@ -46,6 +48,8 @@ import {
setLastUsedMeasurementType,
} from "@/lib/measurements/last-used-type";
+import { DurationReadAs } from "./duration-read-as";
+
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
export const MEASUREMENT_TYPES = [
@@ -343,6 +347,7 @@ export function MeasurementForm({
? selectedType
: (offeredTypes[0]?.value ?? "BLOOD_PRESSURE");
const [value, setValue] = useState("");
+ const [valueError, setValueError] = useState(null);
const [sysBp, setSysBp] = useState("");
const [diaBp, setDiaBp] = useState("");
const [pulse, setPulse] = useState("");
@@ -367,6 +372,11 @@ export function MeasurementForm({
const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type);
const isBpMode = type === "BLOOD_PRESSURE";
const isGlucoseMode = type === "BLOOD_GLUCOSE";
+ // A duration field (sleep, in hours) also reads "7:30" and "7h 30m"; the
+ // hint under it says how the entry was understood.
+ const durationUnit = durationEntryUnit(type);
+ const durationRead =
+ durationUnit !== null ? parseDurationEntry(value, durationUnit) : null;
function resetForm() {
setType(normalizedDefault || "BLOOD_PRESSURE");
@@ -377,6 +387,7 @@ export function MeasurementForm({
setNotes("");
setMeasuredAt(getDefaultMeasuredAtValue());
setError(null);
+ setValueError(null);
}
async function handleSubmit(e: React.FormEvent) {
@@ -432,7 +443,22 @@ export function MeasurementForm({
// minutes, and sending the typed number through unconverted filed a
// 7.5-hour night as seven and a half minutes — inside the column's
// plausibility band, so nothing objected.
- const typed = parseDecimalEntry(value);
+ let typed: number | null;
+ if (durationRead) {
+ if (!durationRead.ok) {
+ setValueError(
+ t(
+ durationRead.reason === "tooLong"
+ ? "measurements.durationTooLong"
+ : "measurements.durationInvalid",
+ ),
+ );
+ return;
+ }
+ typed = durationRead.value;
+ } else {
+ typed = parseDecimalEntry(value);
+ }
if (typed === null) {
setError(t("measurements.saveError"));
return;
@@ -539,7 +565,13 @@ export function MeasurementForm({
return (