From 313d8491de59fafc9a83dc7be4484d45f57a7187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:27:27 +0200 Subject: [PATCH] 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 --- .../measurements/duration-read-as.tsx | 25 ++++ .../measurements/measurement-form.tsx | 59 ++++++++- .../__tests__/parse-duration.test.ts | 92 +++++++++++++ src/lib/measurements/entry-units.ts | 15 +++ src/lib/measurements/parse-duration.ts | 124 ++++++++++++++++++ 5 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 src/components/measurements/duration-read-as.tsx create mode 100644 src/lib/measurements/__tests__/parse-duration.test.ts create mode 100644 src/lib/measurements/parse-duration.ts 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 (
- { + setType(next); + setValueError(null); + }} + > @@ -614,6 +646,12 @@ export function MeasurementForm({ ) : ( + ) : 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 + } /> )} diff --git a/src/lib/measurements/__tests__/parse-duration.test.ts b/src/lib/measurements/__tests__/parse-duration.test.ts new file mode 100644 index 000000000..c3bdb53cc --- /dev/null +++ b/src/lib/measurements/__tests__/parse-duration.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts index 73e2a1e45..8ad07d81d 100644 --- a/src/lib/measurements/entry-units.ts +++ b/src/lib/measurements/entry-units.ts @@ -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> = { + 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. diff --git a/src/lib/measurements/parse-duration.ts b/src/lib/measurements/parse-duration.ts new file mode 100644 index 000000000..e5c4685c1 --- /dev/null +++ b/src/lib/measurements/parse-duration.ts @@ -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 }; +}