mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(measurements): honor the unit preference at entry and in the record list
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the temperature offset carried as an absolute-only shift), a single client hook that reads the preference, and a shared inbound unit-alias resolver. Wire the manual entry form to label, prefill, and convert at the boundary so an imperial reading persists in canonical SI, and route the record list value cells and edit dialog through the same choke point. Close the one write path that could persist a non-canonical unit: the MCP log_measurement tool now resolves a unit hint to canonical or refuses it, reusing the resolver the CSV importer already relied on.
This commit is contained in:
1 parent
14dcab786e
commit
dd7213db5c
8 files changed
+485
-94
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
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 {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -50,6 +51,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeWeight",
|
||||
unit: "kg",
|
||||
placeholder: "75.5",
|
||||
placeholderImperial: "165",
|
||||
},
|
||||
{
|
||||
value: "PULSE",
|
||||
@@ -86,12 +88,14 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeTotalBodyWater",
|
||||
unit: "kg",
|
||||
placeholder: "42",
|
||||
placeholderImperial: "93",
|
||||
},
|
||||
{
|
||||
value: "BONE_MASS",
|
||||
labelKey: "measurements.typeBoneMass",
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
placeholderImperial: "7",
|
||||
},
|
||||
{
|
||||
value: "OXYGEN_SATURATION",
|
||||
@@ -104,6 +108,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeBodyTemperature",
|
||||
unit: "°C",
|
||||
placeholder: "36.6",
|
||||
placeholderImperial: "97.9",
|
||||
},
|
||||
// v1.25 — physical / clinical signals. Manual web capture so a web-only
|
||||
// self-hoster can log them (the iOS client + measurements API already do).
|
||||
@@ -112,6 +117,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeWaistCircumference",
|
||||
unit: "cm",
|
||||
placeholder: "84",
|
||||
placeholderImperial: "33",
|
||||
},
|
||||
{
|
||||
value: "WAIST_TO_HEIGHT",
|
||||
@@ -124,6 +130,7 @@ const MEASUREMENT_TYPES = [
|
||||
labelKey: "measurements.typeGripStrength",
|
||||
unit: "kg",
|
||||
placeholder: "38",
|
||||
placeholderImperial: "84",
|
||||
},
|
||||
{
|
||||
value: "PAIN_NRS",
|
||||
@@ -232,6 +239,7 @@ export function MeasurementForm({
|
||||
}: MeasurementFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
|
||||
// Normalize legacy BP types to combined mode
|
||||
const normalizedDefault =
|
||||
@@ -327,10 +335,24 @@ export function MeasurementForm({
|
||||
|
||||
await apiPost("/api/measurements", batch);
|
||||
} else {
|
||||
// Single measurement
|
||||
// Single measurement. Canonical storage stays SI: an imperial user
|
||||
// types in lb / in / °F and we invert to the canonical unit at the
|
||||
// entry boundary. A metric user (or an untransformed type) takes the
|
||||
// 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);
|
||||
let canonicalValue = typed;
|
||||
if (unitDisplay.isTransformed(type)) {
|
||||
const inverted = unitDisplay.fromDisplay(type, typed);
|
||||
canonicalValue =
|
||||
unitDisplay.preference === "imperial"
|
||||
? Math.round(inverted * 100) / 100
|
||||
: inverted;
|
||||
}
|
||||
await apiPost("/api/measurements", {
|
||||
type,
|
||||
value: parseFloat(value),
|
||||
value: canonicalValue,
|
||||
measuredAt: timestamp,
|
||||
notes: notes || undefined,
|
||||
...(isGlucoseMode ? { glucoseContext } : {}),
|
||||
@@ -483,10 +505,15 @@ export function MeasurementForm({
|
||||
<FieldGroup
|
||||
htmlFor="value"
|
||||
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
|
||||
// its static catalogue unit.
|
||||
unit: typeInfo
|
||||
? "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
? unitDisplay.isTransformed(type)
|
||||
? unitDisplay.unitFor(type)
|
||||
: "unitKey" in typeInfo
|
||||
? t(typeInfo.unitKey)
|
||||
: typeInfo.unit
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
@@ -498,8 +525,13 @@ export function MeasurementForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
typeInfo && "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
typeInfo
|
||||
? unitDisplay.preference === "imperial" &&
|
||||
"placeholderImperial" in typeInfo
|
||||
? typeInfo.placeholderImperial
|
||||
: "placeholder" in typeInfo
|
||||
? typeInfo.placeholder
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
|
||||
@@ -66,6 +66,8 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -282,6 +284,24 @@ export function MeasurementList({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
// v1.32.26 — a single-reading row renders its value + unit in the user's
|
||||
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
|
||||
// transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the
|
||||
// type's canonical unit) is shown verbatim so its stored unit is never
|
||||
// silently relabelled. Metric users take the identity path — unchanged.
|
||||
const rowDisplay = useCallback(
|
||||
(m: { type: string; value: number; unit: string }) => {
|
||||
if (m.unit !== getUnitForType(m.type)) {
|
||||
return { value: m.value, unit: m.unit };
|
||||
}
|
||||
return {
|
||||
value: unitDisplay.toDisplay(m.type, m.value),
|
||||
unit: unitDisplay.unitFor(m.type),
|
||||
};
|
||||
},
|
||||
[unitDisplay],
|
||||
);
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
|
||||
// rows) or sleep page built every row subtree twice — double the DOM, double
|
||||
@@ -710,8 +730,20 @@ export function MeasurementList({
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed the input in the user's preferred unit so an imperial user edits in
|
||||
// lb / in / °F (converted back to canonical on save). Metric users and
|
||||
// legacy non-canonical rows take the identity path — the seed is the raw
|
||||
// stored value, unchanged. Round the converted imperial seed so the input
|
||||
// shows "210" rather than a long float.
|
||||
const rd = rowDisplay(measurement);
|
||||
const seedValue =
|
||||
unitDisplay.preference === "imperial" &&
|
||||
unitDisplay.isTransformed(measurement.type) &&
|
||||
measurement.unit === getUnitForType(measurement.type)
|
||||
? Math.round(rd.value * 100) / 100
|
||||
: measurement.value;
|
||||
const seed = {
|
||||
value: String(measurement.value),
|
||||
value: String(seedValue),
|
||||
measuredAt: toDateTimeLocalValue(measurement.measuredAt),
|
||||
notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH),
|
||||
};
|
||||
@@ -761,10 +793,25 @@ export function MeasurementList({
|
||||
return;
|
||||
}
|
||||
|
||||
// The input holds a display-unit value; invert it to canonical SI before
|
||||
// the PATCH so an imperial edit persists in the stored unit. Metric users
|
||||
// and legacy non-canonical rows are the identity path — value unchanged.
|
||||
let canonicalValue = parsedValue;
|
||||
if (
|
||||
unitDisplay.isTransformed(editing.type) &&
|
||||
editing.unit === getUnitForType(editing.type)
|
||||
) {
|
||||
const inverted = unitDisplay.fromDisplay(editing.type, parsedValue);
|
||||
canonicalValue =
|
||||
unitDisplay.preference === "imperial"
|
||||
? Math.round(inverted * 100) / 100
|
||||
: inverted;
|
||||
}
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
value: parsedValue,
|
||||
value: canonicalValue,
|
||||
measuredAt: measuredDate.toISOString(),
|
||||
notes: editNotes.trim() ? editNotes.trim() : null,
|
||||
});
|
||||
@@ -1050,10 +1097,10 @@ export function MeasurementList({
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
@@ -1253,10 +1300,10 @@ export function MeasurementList({
|
||||
{isGrouped
|
||||
? fmt.integer(m.value)
|
||||
: fmt.number(
|
||||
m.value,
|
||||
rowDisplay(m).value,
|
||||
rawDisplayFractionDigits(m.type),
|
||||
)}{" "}
|
||||
{m.unit}
|
||||
{isGrouped ? m.unit : rowDisplay(m).unit}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -1423,7 +1470,9 @@ export function MeasurementList({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-value">
|
||||
{t("measurements.valueWithUnit", { unit: editing.unit })}
|
||||
{t("measurements.valueWithUnit", {
|
||||
unit: rowDisplay(editing).unit,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-value"
|
||||
|
||||
Reference in new issue
Block a user