mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user