mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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 });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user