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>
)}
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { parseDurationEntry, splitDurationMinutes } from "../parse-duration";
describe("parseDurationEntry — an hours field", () => {
it.each([
["7.5", 7.5, 450],
["7,5", 7.5, 450],
[" 8 ", 8, 480],
["7:30", 7.5, 450],
["7:05", 7 + 5 / 60, 425],
["0:45", 0.75, 45],
["7h", 7, 420],
["7h30", 7.5, 450],
["7h30m", 7.5, 450],
["7h 30m", 7.5, 450],
["7 h 30 min", 7.5, 450],
["7 hrs. 30 mins.", 7.5, 450],
["7H30M", 7.5, 450],
["8h52m", 8 + 52 / 60, 532],
["7,5h", 7.5, 450],
["45m", 0.75, 45],
["45 min", 0.75, 45],
["7 Std 30 Min", 7.5, 450],
["7 Stunden 30 Minuten", 7.5, 450],
["7u30", 7.5, 450],
["7시간 30분", 7.5, 450],
["24", 24, 1440],
])("reads %j as %d h", (raw, hours, minutes) => {
const result = parseDurationEntry(raw, "h");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeCloseTo(hours, 10);
expect(result.minutes).toBe(minutes);
});
it.each([
["", "empty"],
[" ", "empty"],
["-7", "invalid"],
["-7h", "invalid"],
["abc", "invalid"],
["7 30", "invalid"],
["7:60", "invalid"],
["7h 90m", "invalid"],
["7.5.1", "invalid"],
["7x", "invalid"],
["30m 7h", "invalid"],
["25", "tooLong"],
["24h 1m", "tooLong"],
["1e3", "invalid"],
])("refuses %j (%s)", (raw, reason) => {
expect(parseDurationEntry(raw, "h")).toEqual({ ok: false, reason });
});
});
describe("parseDurationEntry — a minutes field", () => {
it.each([
["45", 45],
["45,5", 45.5],
["1h15", 75],
["1h 15m", 75],
["1:15", 75],
["90 min", 90],
["2h", 120],
])("reads %j as %d min", (raw, minutes) => {
const result = parseDurationEntry(raw, "min");
expect(result).toMatchObject({ ok: true, value: minutes });
});
it("refuses more than a day", () => {
expect(parseDurationEntry("1441", "min")).toEqual({
ok: false,
reason: "tooLong",
});
});
it("honours a caller's own ceiling", () => {
expect(parseDurationEntry("3h", "min", 120)).toEqual({
ok: false,
reason: "tooLong",
});
});
});
describe("splitDurationMinutes", () => {
it("splits into whole hours and the minutes left over", () => {
expect(splitDurationMinutes(450)).toEqual({ hours: 7, minutes: 30 });
expect(splitDurationMinutes(45)).toEqual({ hours: 0, minutes: 45 });
expect(splitDurationMinutes(479.6)).toEqual({ hours: 8, minutes: 0 });
});
});
+15
View File
@@ -26,8 +26,23 @@
* arrives.
*/
import type { DurationFieldUnit } from "./parse-duration";
export const MINUTES_PER_HOUR = 60;
/**
* Types whose entry field asks for a duration, and the unit it asks in. Such a
* field reads `7:30` and `7h 30m` as well as a decimal (`parseDurationEntry`).
*/
const DURATION_ENTRY_UNIT: Readonly<Record<string, DurationFieldUnit>> = {
SLEEP_DURATION: "h",
};
/** The duration unit a type's entry field asks in, or null for a plain number. */
export function durationEntryUnit(type: string): DurationFieldUnit | null {
return DURATION_ENTRY_UNIT[type] ?? null;
}
/**
* Entry unit → canonical unit multiplier, per measurement type. A type absent
* from this table enters in the unit it is stored in.
+124
View File
@@ -0,0 +1,124 @@
/**
* Read a duration the way a person writes one.
*
* A field that asks for hours used to take only a decimal, so a night of seven
* and a half hours had to be typed as `7.5`. People write it as `7:30`,
* `7h30m` or `7 h 30 min`, and a minutes field gets `1h15` just as often. This
* reads all of those, with the decimal comma, and answers in the field's own
* unit so the caller's existing conversion to the stored unit stays the one
* place that knows about storage.
*
* Accepted shapes, `H` and `M` being whole or decimal numbers:
*
* - `7.5`, `7,5` a bare number in the field's unit
* - `7:30` hours and minutes (minutes below 60)
* - `7h`, `7h30`, `7h30m`, `7 h 30 min`, `45m`, `45 min`
* hours and/or minutes with a unit word; a number
* after an hours part without a unit is minutes
*
* Unit words: h, hr, hrs, hour, hours, std, stunde, stunden, u (Dutch), godz
* (Polish), 시간 for hours; m, min, mins, minute, minutes, minuten, 분 for
* minutes. Anything else is refused rather than guessed at.
*/
export type DurationFieldUnit = "h" | "min";
export type DurationParseResult =
| {
ok: true;
/** The value in the field's unit (hours or minutes). */
value: number;
/** The same duration in whole minutes, for display. */
minutes: number;
}
| { ok: false; reason: "empty" | "invalid" | "tooLong" };
/** A single manual entry never covers more than a day. */
export const MAX_DURATION_MINUTES = 24 * 60;
const NUMBER = String.raw`(\d+(?:[.,]\d+)?)`;
const HOUR_WORD = String.raw`(?:hours?|hrs?|h|stunden?|std|godz|u|시간)`;
const MINUTE_WORD = String.raw`(?:minutes?|minuten|mins?|m|분)`;
const BARE = new RegExp(String.raw`^${NUMBER}$`);
const CLOCK = /^(\d+):(\d{1,2})$/;
const WORDS = new RegExp(
String.raw`^(?:${NUMBER}\s*${HOUR_WORD}\.?)?\s*(?:${NUMBER}\s*(${MINUTE_WORD})?\.?)?$`,
"iu",
);
const MINUTES_ONLY = new RegExp(
String.raw`^${NUMBER}\s*${MINUTE_WORD}\.?$`,
"iu",
);
function toNumber(raw: string): number {
return Number(raw.replace(",", "."));
}
function totalMinutes(
raw: string,
unit: DurationFieldUnit,
): number | null | "invalid" {
const text = raw.trim().replace(/\s+/g, " ");
if (text === "") return null;
const bare = BARE.exec(text);
if (bare) {
const n = toNumber(bare[1]!);
return unit === "h" ? n * 60 : n;
}
const clock = CLOCK.exec(text);
if (clock) {
const minutes = Number(clock[2]);
if (minutes >= 60) return "invalid";
return Number(clock[1]) * 60 + minutes;
}
const minutesOnly = MINUTES_ONLY.exec(text);
if (minutesOnly) return toNumber(minutesOnly[1]!);
const words = WORDS.exec(text);
// An hours part is required here: a lone number was handled above, and
// "30m" by the minutes-only shape.
if (words && words[1] !== undefined) {
const hours = toNumber(words[1]);
if (words[2] === undefined) return hours * 60;
const minutes = toNumber(words[2]);
// "7h 90" is more likely a typo than an hour and a half.
if (minutes >= 60) return "invalid";
return hours * 60 + minutes;
}
return "invalid";
}
/**
* Parse `raw` for a field that asks in `unit`. A negative sign, an unknown
* word or a duration longer than `maxMinutes` is refused.
*/
export function parseDurationEntry(
raw: string,
unit: DurationFieldUnit,
maxMinutes: number = MAX_DURATION_MINUTES,
): DurationParseResult {
const minutes = totalMinutes(raw, unit);
if (minutes === null) return { ok: false, reason: "empty" };
if (minutes === "invalid" || !Number.isFinite(minutes)) {
return { ok: false, reason: "invalid" };
}
if (minutes > maxMinutes) return { ok: false, reason: "tooLong" };
return {
ok: true,
value: unit === "h" ? minutes / 60 : minutes,
minutes: Math.round(minutes),
};
}
/** Whole hours and the minutes left over, for the "read as" hint. */
export function splitDurationMinutes(minutes: number): {
hours: number;
minutes: number;
} {
const whole = Math.max(0, Math.round(minutes));
return { hours: Math.floor(whole / 60), minutes: whole % 60 };
}