Files
HealthLog/src/lib/measurements/entry-units.ts
T
Marc-André Bombeck 313d8491de 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
2026-10-03 18:28:39 +02:00

83 lines
3.5 KiB
TypeScript

/**
* Where the entry form asks for one unit and the column stores another.
*
* This is not the metric/imperial display transform in `display-transform.ts`:
* that one follows a preference and is the identity for anyone on metric. This
* one holds regardless of preference, because the storage unit was chosen for
* the sync sources rather than for the person typing.
*
* Sleep is the case. `SLEEP_DURATION` is stored in minutes — every provider
* delivers minutes, and a per-stage segment is only a few of them — while a
* person logging a night by hand thinks in hours and the field says so. The
* form sent the typed number straight through, so a night entered as 7.5 was
* stored as seven and a half MINUTES and read back on the list as eight
* minutes of sleep. Nothing rejected it: seven and a half minutes is inside
* the column's plausibility band, because a single sleep STAGE really can be
* that short.
*
* The storage unit stays minutes. The conversion belongs at the entry
* boundary, in one place, so a second surface that starts taking sleep by hand
* has one answer to reach for rather than its own.
*
* Only the direction that exists is here. An inverse and a "does this type
* convert" predicate shipped alongside it at first, written for an editing
* surface that does not exist and has no ticket; they were nothing but two
* more things to keep true. The git history holds them if that surface ever
* 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.
*/
const ENTRY_TO_CANONICAL_FACTOR: Readonly<Record<string, number>> = {
SLEEP_DURATION: MINUTES_PER_HOUR,
};
/**
* Convert a value as typed in the entry form to the unit the column stores.
*
* The result is rounded to the nearest whole canonical unit for a converted
* type: 7.3 hours is 438 minutes, and floating-point multiplication would
* otherwise store 437.99999999999994 for it. An unconverted type is returned
* untouched, so no existing metric changes shape.
*/
export function entryValueToCanonical(type: string, entered: number): number {
const factor = ENTRY_TO_CANONICAL_FACTOR[type];
if (factor === undefined || !Number.isFinite(entered)) return entered;
return Math.round(entered * factor);
}
/**
* Read a number out of an entry field, accepting the decimal comma.
*
* Most of the application's readers are German, and "7,5" is how they write
* seven and a half. `parseFloat` reads that as 7 and discards the rest without
* complaint — the worst shape of wrong, since the entry looks accepted.
* Returns `null` for anything that is not a number, so the caller decides what
* an empty field means rather than inheriting a `NaN`.
*/
export function parseDecimalEntry(raw: string): number | null {
const trimmed = raw.trim().replace(",", ".");
if (trimmed === "") return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}