feat(measurements): read a sleep duration written as 7:30 or 7h 30m

The sleep field asked for decimal hours only. A shared parser
(parse-duration.ts) now reads a bare number with point or comma,
H:MM, and hours and minutes with unit words (7h, 7h30, 7 h 30 min,
45m), answers in the field's unit, and refuses a negative, unknown or
longer-than-a-day entry. The form shows how the entry was read under
the field and puts a refusal on the field itself. The stored value and
the API are unchanged.

Refs #1101
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:28:39 +02:00
1 parent 3a35dbcf37
commit 313d8491de
5 files changed
+309 -6

No files matched your search

@@ -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 (
<span aria-live="polite" data-duration-minutes={Math.round(minutes)}>
{t("measurements.durationReadAs", { duration })}
</span>
);
}
@@ -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<string | null>(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 (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<FieldGroup htmlFor="measurement-type" label={t("measurements.type")}>
<Select value={type} onValueChange={setType}>
<Select
value={type}
onValueChange={(next) => {
setType(next);
setValueError(null);
}}
>
<SelectTrigger id="measurement-type" className="w-full">
<SelectValue />
</SelectTrigger>
@@ -614,6 +646,12 @@ export function MeasurementForm({
) : (
<FieldGroup
htmlFor="value"
hint={
durationRead?.ok === true ? (
<DurationReadAs minutes={durationRead.minutes} />
) : null
}
error={valueError}
label={t("measurements.valueWithUnit", {
// For a type with a metric/imperial transform the label follows the
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
@@ -636,10 +674,15 @@ export function MeasurementForm({
// arrives as an empty string with no explanation. `inputMode`
// still raises the numeric keypad, and the submit path reads the
// comma — the same shape the labs and custom-metric fields use.
inputMode="decimal"
// A duration also takes "7:30" and "7h 30m", which the decimal
// keypad cannot type.
inputMode={durationUnit !== null ? "text" : "decimal"}
enterKeyHint="next"
value={value}
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
setValue(e.target.value);
setValueError(null);
}}
placeholder={
isGlucoseMode && glucoseUnit === "mmol/L"
? "5.3"
@@ -654,8 +697,12 @@ export function MeasurementForm({
}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
aria-invalid={!!error || !!valueError || undefined}
aria-describedby={
[valueError ? "value-error" : null, errorDescriptor]
.filter(Boolean)
.join(" ") || undefined
}
/>
</FieldGroup>
)}