Files
HealthLog/src/lib/measurements/display-transform.ts
T
Marc-André Bombeck a8149e0bdf fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to
leak into the chrome, checkboxes no longer nest inside buttons, delete
confirmations admit the undo they offer, raw step counts drop their
decimals, the truncated unit label wraps instead, streaks know their
singular, the medication header speaks the user's date format, a failed
coach turn keeps the user's message and says what went wrong, settings
status notes follow a language switch, the locale only flips once its
bundle actually loaded, and the last raw api fetches in hooks move onto
the shared client.
2026-06-11 03:30:18 +02:00

130 lines
4.6 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`). A handful of metrics read better in a
* derived unit at the display boundary only:
*
* - 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). Raw rows, plausibility
* ranges, rollup math, and the AI snapshot all keep canonical SI. A
* `factor` of 1 is the identity transform and is what every other
* type resolves to, so callers that pass an un-transformed value see
* no change (charts stay visually identical).
*
* The metric/imperial user preference (`unitPreference`) selects which
* branch a transform exposes. The default is metric; the imperial
* branch is additive and only wired for the two distance/speed metrics
* so far — every other type ignores the preference entirely.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { getUnitForType } from "@/lib/validations/measurement";
export type UnitPreference = "metric" | "imperial";
export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric";
export interface DisplayTransform {
/** Multiplier applied to the raw canonical value for display. */
factor: 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,
};
/**
* Per-type, per-preference transforms. Only types that genuinely read
* better in a derived unit appear here; everything else resolves to
* the identity transform with the canonical unit from
* `getUnitForType()`.
*
* Metric branch ships in v1.7.0. The imperial branch is additive: it
* covers the same two metrics in mph / miles so the global
* metric/imperial toggle has somewhere to land without a follow-up
* migration.
*/
const TRANSFORMS: Partial<
Record<MeasurementType, Record<UnitPreference, DisplayTransform>>
> = {
// 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 },
},
};
/**
* 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;
}
/**
* Apply a transform's factor to a raw canonical value. Pure helper —
* keeps the multiply in one place so the chart, list cell, and tooltip
* all scale identically.
*/
export function applyDisplayTransform(
rawValue: number,
transform: DisplayTransform,
): number {
return rawValue * transform.factor;
}