mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The workout list, the detail header, the stat tiles, the sport-average line and the elevation chart printed kilometres, minutes per kilometre and metres for every reader. They now read distance, pace and elevation through the unit registry (km or mi, /km or /mi, m or ft). The splits table keeps its kilometre rows, which are cut server-side, and says so. A pace that rounded to sixty seconds now carries into the minute. Refs #1067
445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
/**
|
|
* v1.7.0 — display-time unit transforms.
|
|
*
|
|
* Canonical storage stays SI (the iOS wire contract + the Withings
|
|
* passthrough both lock raw SI — see the convention block in
|
|
* `apple-health-mapping.ts`). Storage never changes; the metric/imperial
|
|
* user preference (`unitPreference`) only selects which branch a
|
|
* transform exposes at the render boundary.
|
|
*
|
|
* A handful of metrics also read better in a derived unit regardless of
|
|
* preference:
|
|
*
|
|
* - WALKING_SPEED is stored in m/s but a casual gait of 1.3 m/s
|
|
* reads as "4.7 km/h" to a human.
|
|
* - WALKING_RUNNING_DISTANCE is stored in metres but a 5 km walk
|
|
* reads as "5000 m"; daily totals make more sense in km.
|
|
*
|
|
* The transform is applied at the render layer only (chart `valueScale`
|
|
* + the measurement-list cell + tooltip + entry form). Raw rows,
|
|
* plausibility ranges, rollup math, FHIR/CSV export, the doctor report,
|
|
* and the AI snapshot all keep canonical SI. For a metric user every
|
|
* transform below is the identity on the number (factor 1, offset 0), so
|
|
* the metric render is byte-identical to before — charts stay visually
|
|
* identical. The imperial branch rescales; that is the intended
|
|
* behaviour, not a regression.
|
|
*
|
|
* v1.32.26 — the registry now covers the full weight-class / length /
|
|
* temperature set the display surfaces render, and the interface gained
|
|
* an affine `offset` so °C→°F (a shift, not just a scale) can be
|
|
* expressed. The offset applies to ABSOLUTE values only; deltas, slopes,
|
|
* standard deviations, and band widths take the factor alone — use
|
|
* `applyDisplayTransformDelta` for those.
|
|
*
|
|
* v1.32.39 — the conversion helpers ROUND to the transform's own
|
|
* `decimals`. Every transform has always declared how many decimals its
|
|
* unit is read at, but the number was handed to the render layer and
|
|
* only one screen ever asked for it, so everywhere else the raw product
|
|
* of a kilogram and 2.20462262185 went straight to the surface
|
|
* ("185.18790000000001 lb"). Rounding at the conversion means a surface
|
|
* gets a correctly-scaled number whether or not it remembers to ask, and
|
|
* a new converted-value surface cannot reintroduce the class.
|
|
*
|
|
* Two boundaries are deliberately exempt:
|
|
*
|
|
* - A transform that does NOT rescale (`factor === 1`, no offset) is
|
|
* the exact identity, never rounded. That covers every untransformed
|
|
* type and the whole metric branch of the mass / length /
|
|
* temperature registry, whose numbers are the stored values
|
|
* themselves — a stored 78.45 kg is not conversion noise and must
|
|
* keep rendering as 78.45. `transformRescales()` is the predicate.
|
|
* - `invertDisplayTransform` — the ENTRY boundary — stays at full
|
|
* precision. A value rounded on the way out and inverted on the way
|
|
* back would drift the stored canonical number; the entry surfaces
|
|
* quantise the inverted value to their own canonical dialect.
|
|
*
|
|
* `applyDisplayTransformUnrounded` is the single documented escape
|
|
* hatch, for arithmetic whose result is NOT rendered (the target
|
|
* adapter's inward-rounded guardrails, which need the unrounded edge to
|
|
* round the right way). Its call sites are frozen by
|
|
* `src/__tests__/display-transform-rounding-guard.test.ts`.
|
|
*/
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
|
|
import {
|
|
MGDL_PER_MMOL,
|
|
resolveGlucoseUnit,
|
|
type GlucoseUnit,
|
|
} from "@/lib/glucose";
|
|
import { getUnitForType } from "@/lib/validations/measurement";
|
|
|
|
export type UnitPreference = "metric" | "imperial";
|
|
|
|
export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric";
|
|
|
|
/** The stored preference column resolved to a branch; anything unknown is metric. */
|
|
export function resolveUnitPreference(
|
|
raw: string | null | undefined,
|
|
): UnitPreference {
|
|
return raw === "imperial" ? "imperial" : DEFAULT_UNIT_PREFERENCE;
|
|
}
|
|
|
|
/**
|
|
* Both unit choices a person makes, together. They are separate columns
|
|
* because they are separate questions — metric versus imperial is about mass,
|
|
* length and temperature, mg/dL versus mmol/L is the clinical convention of
|
|
* the country the record lives in — but a surface that renders a reading
|
|
* needs both, and taking them as one value is what keeps a server-side text
|
|
* producer from honouring one and forgetting the other.
|
|
*/
|
|
export interface UnitPreferences {
|
|
system: UnitPreference;
|
|
glucoseUnit: GlucoseUnit;
|
|
}
|
|
|
|
/** Resolve both stored columns at once (each falls back to its default). */
|
|
export function resolveUnitPreferences(row: {
|
|
unitPreference?: string | null;
|
|
glucoseUnit?: string | null;
|
|
}): UnitPreferences {
|
|
return {
|
|
system: resolveUnitPreference(row.unitPreference),
|
|
glucoseUnit: resolveGlucoseUnit(row.glucoseUnit),
|
|
};
|
|
}
|
|
|
|
/** The defaults: metric, mg/dL. */
|
|
export const DEFAULT_UNIT_PREFERENCES: UnitPreferences = {
|
|
system: DEFAULT_UNIT_PREFERENCE,
|
|
glucoseUnit: "mg/dL",
|
|
};
|
|
|
|
export interface DisplayTransform {
|
|
/** Multiplier applied to the raw canonical value for display. */
|
|
factor: number;
|
|
/**
|
|
* Additive shift applied AFTER the factor for an absolute display
|
|
* value (`display = raw * factor + offset`). Defaults to 0. Present
|
|
* only for affine conversions (°C→°F). Deltas/slopes/SD must NOT use
|
|
* it — see `applyDisplayTransformDelta`.
|
|
*/
|
|
offset?: number;
|
|
/** Unit string shown next to the displayed value. */
|
|
displayUnit: string;
|
|
/** Decimal places for the displayed value. */
|
|
decimals: number;
|
|
}
|
|
|
|
/** Identity transform — raw canonical value, canonical unit. */
|
|
const IDENTITY: DisplayTransform = {
|
|
factor: 1,
|
|
displayUnit: "",
|
|
decimals: 1,
|
|
};
|
|
|
|
// kg → lb (exact: 1 lb = 0.45359237 kg → 1 kg = 2.20462262185 lb).
|
|
const KG_TO_LB = 2.20462262185;
|
|
// cm → in (exact: 1 in = 2.54 cm → 1 cm = 0.393700787402 in).
|
|
const CM_TO_IN = 0.393700787402;
|
|
|
|
/** Shared metric/imperial branch pair for a body-mass metric (kg ↔ lb). */
|
|
const MASS_TRANSFORM: Record<UnitPreference, DisplayTransform> = {
|
|
metric: { factor: 1, displayUnit: "kg", decimals: 1 },
|
|
imperial: { factor: KG_TO_LB, displayUnit: "lb", decimals: 1 },
|
|
};
|
|
|
|
/** Shared branch pair for an ABSOLUTE temperature (°C ↔ °F, affine). */
|
|
const TEMPERATURE_TRANSFORM: Record<UnitPreference, DisplayTransform> = {
|
|
metric: { factor: 1, displayUnit: "°C", decimals: 1 },
|
|
imperial: { factor: 1.8, offset: 32, displayUnit: "°F", decimals: 1 },
|
|
};
|
|
|
|
/**
|
|
* Per-type, per-preference transforms. Types absent here resolve to the
|
|
* identity transform with the canonical unit from `getUnitForType()`.
|
|
*
|
|
* The metric branch of every entry below is the identity on the number
|
|
* (factor 1, no offset), differing from `getUnitForType` only in the
|
|
* DISPLAY symbol it surfaces (e.g. "°C" for the stored "celsius"). The
|
|
* imperial branch carries the real conversion.
|
|
*/
|
|
const TRANSFORMS: Partial<
|
|
Record<MeasurementType, Record<UnitPreference, DisplayTransform>>
|
|
> = {
|
|
// ── Body-mass metrics (kg canonical → lb imperial) ──
|
|
WEIGHT: MASS_TRANSFORM,
|
|
TOTAL_BODY_WATER: MASS_TRANSFORM,
|
|
BONE_MASS: MASS_TRANSFORM,
|
|
FAT_MASS: MASS_TRANSFORM,
|
|
FAT_FREE_MASS: MASS_TRANSFORM,
|
|
MUSCLE_MASS: MASS_TRANSFORM,
|
|
LEAN_BODY_MASS: MASS_TRANSFORM,
|
|
GRIP_STRENGTH: MASS_TRANSFORM,
|
|
// ── Circumference (cm canonical → in imperial) ──
|
|
WAIST_CIRCUMFERENCE: {
|
|
metric: { factor: 1, displayUnit: "cm", decimals: 1 },
|
|
imperial: { factor: CM_TO_IN, displayUnit: "in", decimals: 1 },
|
|
},
|
|
// ── Absolute temperatures (°C canonical → °F imperial, affine) ──
|
|
BODY_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
|
SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
|
WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM,
|
|
// A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by
|
|
// 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a
|
|
// 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading.
|
|
BODY_TEMPERATURE_DEVIATION: {
|
|
metric: { factor: 1, displayUnit: "°C", decimals: 1 },
|
|
imperial: { factor: 1.8, displayUnit: "°F", decimals: 1 },
|
|
},
|
|
// 1 m/s = 3.6 km/h = 2.236936 mph.
|
|
WALKING_SPEED: {
|
|
metric: { factor: 3.6, displayUnit: "km/h", decimals: 1 },
|
|
imperial: { factor: 2.2369362920544, displayUnit: "mph", decimals: 1 },
|
|
},
|
|
// Daily totals — 1 m = 0.001 km = 0.000621371192 mi.
|
|
WALKING_RUNNING_DISTANCE: {
|
|
metric: { factor: 0.001, displayUnit: "km", decimals: 2 },
|
|
imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 },
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Blood glucose, stored in mg/dL, read in either clinical unit. Keyed by the
|
|
* glucose preference rather than metric/imperial. The mmol/L branch rounds to
|
|
* one decimal through `applyDisplayTransform`, which is exactly what
|
|
* `mgdlToMmol` does, so a server-side text and a screen built on
|
|
* `convertGlucose` print the same figure.
|
|
*/
|
|
const GLUCOSE_TRANSFORM: Record<GlucoseUnit, DisplayTransform> = {
|
|
"mg/dL": { factor: 1, displayUnit: "mg/dL", decimals: 0 },
|
|
"mmol/L": { factor: 1 / MGDL_PER_MMOL, displayUnit: "mmol/L", decimals: 1 },
|
|
};
|
|
|
|
/**
|
|
* The transform for a reading of `type` under BOTH unit preferences — the
|
|
* one resolver a server-side text producer (a status note, a narrative, a
|
|
* prompt) takes its numbers and unit symbols from.
|
|
*
|
|
* The client surfaces keep `getDisplayTransform` through `useUnitDisplay`
|
|
* and convert glucose through `convertGlucose` at the few places that render
|
|
* it; folding glucose into that path would convert it a second time on the
|
|
* screens that already do. Text built on the server has no such history, so
|
|
* it starts from the complete answer.
|
|
*/
|
|
export function getReadingTransform(
|
|
type: string,
|
|
preferences: UnitPreferences,
|
|
): DisplayTransform {
|
|
if (type === "BLOOD_GLUCOSE")
|
|
return GLUCOSE_TRANSFORM[preferences.glucoseUnit];
|
|
return getDisplayTransform(type, preferences.system);
|
|
}
|
|
|
|
/**
|
|
* Every measurement type whose displayed number or unit depends on a unit
|
|
* preference: the metric/imperial set plus glucose. Text that quoted one of
|
|
* these was written in a unit, and goes stale when the preference changes.
|
|
*/
|
|
export function isUnitSensitiveType(type: string): boolean {
|
|
return type === "BLOOD_GLUCOSE" || TRANSFORMED_TYPES.has(type);
|
|
}
|
|
|
|
/**
|
|
* Quantities that are not a stored measurement type but are read in a unit
|
|
* system all the same: a workout's distance and its elevation gain, both
|
|
* stored in metres. Same rules as the type registry — the metric distance
|
|
* branch reads kilometres, which is a rescale, so it rounds; the metric
|
|
* elevation branch is the identity.
|
|
*/
|
|
export type QuantityKind = "distance" | "elevation";
|
|
|
|
// 1 m = 3.28083989501 ft.
|
|
const M_TO_FT = 3.28083989501;
|
|
|
|
const QUANTITY_TRANSFORMS: Record<
|
|
QuantityKind,
|
|
Record<UnitPreference, DisplayTransform>
|
|
> = {
|
|
distance: {
|
|
metric: { factor: 0.001, displayUnit: "km", decimals: 2 },
|
|
imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 },
|
|
},
|
|
elevation: {
|
|
metric: { factor: 1, displayUnit: "m", decimals: 1 },
|
|
imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 },
|
|
},
|
|
};
|
|
|
|
/** The transform for a metre-valued quantity under a metric/imperial preference. */
|
|
export function getQuantityTransform(
|
|
kind: QuantityKind,
|
|
preference: UnitPreference,
|
|
): DisplayTransform {
|
|
return QUANTITY_TRANSFORMS[kind][preference];
|
|
}
|
|
|
|
/**
|
|
* Seconds per displayed distance unit (per km, or per mile) from a duration
|
|
* and a distance in metres. A pace is a rate in the inverse direction of a
|
|
* distance, so it divides by the factor instead of multiplying.
|
|
*/
|
|
export function paceSecondsPerDistanceUnit(
|
|
durationSec: number,
|
|
meters: number,
|
|
preference: UnitPreference,
|
|
): number {
|
|
const { factor } = QUANTITY_TRANSFORMS.distance[preference];
|
|
return durationSec / (meters * factor);
|
|
}
|
|
|
|
/**
|
|
* Every type with a registered transform. The structural guard test uses
|
|
* this set to assert no display surface hardcodes a unit string / scale
|
|
* for a type that should route through the transform instead.
|
|
*/
|
|
export const TRANSFORMED_TYPES: ReadonlySet<string> = new Set(
|
|
Object.keys(TRANSFORMS),
|
|
);
|
|
|
|
/** True when `type` has a per-preference transform (vs the identity path). */
|
|
export function hasDisplayTransform(type: string): boolean {
|
|
return TRANSFORMED_TYPES.has(type);
|
|
}
|
|
|
|
/**
|
|
* Resolve the display transform for a `(type, preference)` pair.
|
|
* Returns the identity transform (with the canonical unit) for every
|
|
* type that has no registered transform, so call sites can apply the
|
|
* result unconditionally.
|
|
*/
|
|
export function getDisplayTransform(
|
|
type: string,
|
|
preference: UnitPreference = DEFAULT_UNIT_PREFERENCE,
|
|
): DisplayTransform {
|
|
const perType = TRANSFORMS[type as MeasurementType];
|
|
if (!perType) {
|
|
return { ...IDENTITY, displayUnit: getUnitForType(type) };
|
|
}
|
|
return perType[preference] ?? perType.metric;
|
|
}
|
|
|
|
/**
|
|
* v1.16.4 — count metrics are integers by definition; any fractional
|
|
* part on a stored sample is source noise (HK chunk splitting, device
|
|
* estimation), so the raw-value surfaces render them with 0 decimals
|
|
* ("9.689 steps", never "9.688,595 steps"). Every other type keeps the
|
|
* formatter's default precision (≤3 digits, trailing zeros dropped).
|
|
*/
|
|
const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
|
|
"ACTIVITY_STEPS",
|
|
"FLIGHTS_CLIMBED",
|
|
"FALL_COUNT",
|
|
// kcal + daylight minutes — whole units are the meaningful display
|
|
// grain for these day-cumulative counts.
|
|
"ACTIVE_ENERGY_BURNED",
|
|
"TIME_IN_DAYLIGHT",
|
|
]);
|
|
|
|
/**
|
|
* Display fraction digits for a RAW (untransformed) value of `type`.
|
|
* `0` for integer count metrics, `undefined` (= formatter default)
|
|
* otherwise — pass the result straight into `Formatters.number`.
|
|
*/
|
|
export function rawDisplayFractionDigits(type: string): number | undefined {
|
|
return INTEGER_DISPLAY_TYPES.has(type) ? 0 : undefined;
|
|
}
|
|
|
|
/**
|
|
* True when a transform actually rescales the number it is applied to
|
|
* (as opposed to only relabelling the unit symbol). False for the
|
|
* identity transform of an untransformed type and for the metric branch
|
|
* of every mass / length / temperature entry, where the displayed number
|
|
* IS the stored number.
|
|
*/
|
|
export function transformRescales(transform: DisplayTransform): boolean {
|
|
return transform.factor !== 1 || (transform.offset ?? 0) !== 0;
|
|
}
|
|
|
|
/** Round to `decimals` fraction digits. Non-finite input passes through. */
|
|
function roundToDecimals(value: number, decimals: number): number {
|
|
if (!Number.isFinite(value)) return value;
|
|
const scale = 10 ** decimals;
|
|
return Math.round(value * scale) / scale;
|
|
}
|
|
|
|
/**
|
|
* The raw affine arithmetic — `raw * factor + offset`, NO rounding.
|
|
*
|
|
* Escape hatch, not the default. Use it only where the result is a
|
|
* computation input rather than something a surface renders: the target
|
|
* adapter needs the unrounded pound edge of the 30 kg guardrail to round
|
|
* it INWARD (rounding it to the nearest tenth first gives 66.1 lb, which
|
|
* inverts to 29.98 kg and the server rejects it). Anything that reaches
|
|
* a screen takes `applyDisplayTransform`. The call sites of this export
|
|
* are frozen by the rounding guard test.
|
|
*/
|
|
export function applyDisplayTransformUnrounded(
|
|
rawValue: number,
|
|
transform: DisplayTransform,
|
|
): number {
|
|
return rawValue * transform.factor + (transform.offset ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Apply a transform to a raw canonical ABSOLUTE value:
|
|
* `display = round(raw * factor + offset, decimals)`. Pure helper —
|
|
* keeps the arithmetic in one place so the chart, list cell, tooltip,
|
|
* and entry form all scale identically, and rounds to the transform's
|
|
* declared `decimals` so no surface can render a converted value at
|
|
* more precision than its unit is read at.
|
|
*
|
|
* A transform that does not rescale returns the raw value untouched —
|
|
* the metric branch stays byte-identical to storage.
|
|
*/
|
|
export function applyDisplayTransform(
|
|
rawValue: number,
|
|
transform: DisplayTransform,
|
|
): number {
|
|
if (!transformRescales(transform)) return rawValue;
|
|
return roundToDecimals(
|
|
applyDisplayTransformUnrounded(rawValue, transform),
|
|
transform.decimals,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Apply a transform to a DELTA / slope / standard-deviation / band-width
|
|
* — factor only, NEVER the offset. A 1 °C change is a 1.8 °F change; it
|
|
* must not pick up the +32 absolute-scale shift.
|
|
*
|
|
* Rounded to the same `decimals` as an absolute value: every consumer of
|
|
* a converted delta in the tree renders it (the dashboard 7-day trend,
|
|
* the cycle and mood crosstab rows, the target range bar), and a delta
|
|
* shown at a finer grain than the readings it was computed from is the
|
|
* same defect in a different place. Comparisons in the tree are made on
|
|
* canonical values, never on a converted delta; a future one that needs
|
|
* the unrounded difference takes `applyDisplayTransformUnrounded`.
|
|
*
|
|
* A factor of 1 returns the delta untouched (the offset is irrelevant to
|
|
* a difference, so the metric branch is the exact identity here too).
|
|
*/
|
|
export function applyDisplayTransformDelta(
|
|
deltaValue: number,
|
|
transform: DisplayTransform,
|
|
): number {
|
|
if (transform.factor === 1) return deltaValue;
|
|
return roundToDecimals(deltaValue * transform.factor, transform.decimals);
|
|
}
|
|
|
|
/**
|
|
* Invert an absolute display value back to the canonical stored value:
|
|
* `raw = (display - offset) / factor`. Used at the entry boundary so an
|
|
* imperial user's typed number persists in canonical SI.
|
|
*
|
|
* Deliberately NOT rounded — this is the write path. Rounding here would
|
|
* quantise the stored canonical value to the display unit's grain and
|
|
* drift it a little further on every save. The entry surfaces round the
|
|
* result to their own canonical dialect (two decimals) instead.
|
|
*/
|
|
export function invertDisplayTransform(
|
|
displayValue: number,
|
|
transform: DisplayTransform,
|
|
): number {
|
|
return (displayValue - (transform.offset ?? 0)) / transform.factor;
|
|
}
|