fix(measurements): store a hand-entered night in the unit the field asks for

The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.

Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.

Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.

The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.

The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.

`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 14:30:10 +02:00
1 parent dfd9b4679b
commit d97e3c0976
4 files changed
+351 -3

No files matched your search

@@ -26,6 +26,10 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
entryValueToCanonical,
parseDecimalEntry,
} from "@/lib/measurements/entry-units";
import {
invalidateKeys,
measurementDependentKeys,
@@ -343,7 +347,17 @@ export function MeasurementForm({
// identity path — `fromDisplay` returns the typed number unchanged, so
// the payload is byte-identical to before. Round only the converted
// imperial value so 210 lb stores as 95.25 kg, not 95.25439770….
const typed = parseFloat(value);
//
// A second boundary sits beside it: a few types are stored in a unit
// the form does not ask for. Sleep is entered in hours and stored in
// 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);
if (typed === null) {
setError(t("measurements.saveError"));
return;
}
let canonicalValue = typed;
if (unitDisplay.isTransformed(type)) {
const inverted = unitDisplay.fromDisplay(type, typed);
@@ -352,6 +366,7 @@ export function MeasurementForm({
? Math.round(inverted * 100) / 100
: inverted;
}
canonicalValue = entryValueToCanonical(type, canonicalValue);
await apiPost("/api/measurements", {
type,
value: canonicalValue,
@@ -533,9 +548,13 @@ export function MeasurementForm({
>
<Input
id="value"
type="number"
// Not `type="number"`: its sanitisation drops a decimal comma on
// the engines that do not localise the field, and "7,5" then
// 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"
enterKeyHint="next"
step="any"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={