refactor(units): resolve both unit preferences through one registry

Metric/imperial and the glucose unit were two separate lookups, so every
server-side text producer had to remember both and most remembered
neither. display-transform.ts now carries a glucose branch keyed by the
glucose preference (getReadingTransform), the metre-valued workout
quantities (distance, elevation, pace), and a resolver for the two
stored columns together. loadUnitPreferences reads them in one query and
useUnitDisplay hands the glucose unit out beside the metric/imperial
branch.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:48:39 +02:00
1 parent d00e106ce1
commit 70e6e07a89
4 files changed
+261 -5

No files matched your search

+15 -5
View File
@@ -23,13 +23,21 @@ import {
getDisplayTransform,
invertDisplayTransform,
hasDisplayTransform,
resolveUnitPreference,
type DisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
export interface UnitDisplay {
/** Resolved preference ("metric" by default / on a stale payload). */
preference: UnitPreference;
/**
* Resolved glucose unit ("mg/dL" by default). Carried beside the
* metric/imperial branch so a surface that edits or renders glucose reads
* both preferences from the one hook.
*/
glucoseUnit: GlucoseUnit;
/** The full transform for a type under the current preference. */
transformFor: (type: string) => DisplayTransform;
/** Convert a raw canonical ABSOLUTE value to the display value. */
@@ -48,7 +56,7 @@ export interface UnitDisplay {
export function useUnitDisplay(): UnitDisplay {
const { user } = useAuth();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
/**
@@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay {
*/
export function useUnitDisplayOnceMounted(): UnitDisplay {
const user = useAccountOnceMounted();
return useDisplayForPreference(user?.unitPreference);
return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit);
}
function useDisplayForPreference(
rawPreference: string | null | undefined,
rawGlucoseUnit: string | null | undefined,
): UnitDisplay {
const preference: UnitPreference =
rawPreference === "imperial" ? "imperial" : "metric";
const preference = resolveUnitPreference(rawPreference);
const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit);
return useMemo<UnitDisplay>(() => {
const transformFor = (type: string) =>
getDisplayTransform(type, preference);
return {
preference,
glucoseUnit,
transformFor,
toDisplay: (type, rawValue) =>
applyDisplayTransform(rawValue, transformFor(type)),
@@ -94,5 +104,5 @@ function useDisplayForPreference(
decimalsFor: (type) => transformFor(type).decimals,
isTransformed: (type) => hasDisplayTransform(type),
};
}, [preference]);
}, [preference, glucoseUnit]);
}
@@ -10,7 +10,14 @@ import {
transformRescales,
TRANSFORMED_TYPES,
DEFAULT_UNIT_PREFERENCE,
DEFAULT_UNIT_PREFERENCES,
getQuantityTransform,
getReadingTransform,
isUnitSensitiveType,
paceSecondsPerDistanceUnit,
resolveUnitPreferences,
} from "../display-transform";
import { convertGlucose } from "@/lib/glucose";
describe("display-transform", () => {
it("converts WALKING_SPEED m/s → km/h (factor 3.6)", () => {
@@ -308,3 +315,85 @@ describe("display-transform — converted values carry the declared precision",
expect(invertDisplayTransform(185.2, imperial)).not.toBe(84);
});
});
describe("display-transform — both unit preferences, one resolver", () => {
it("reads glucose in the preferred clinical unit, matching convertGlucose", () => {
const mmol = getReadingTransform("BLOOD_GLUCOSE", {
system: "metric",
glucoseUnit: "mmol/L",
});
expect(mmol.displayUnit).toBe("mmol/L");
for (const mgdl of [54, 70, 99, 126, 140, 250]) {
expect(applyDisplayTransform(mgdl, mmol)).toBe(
convertGlucose(mgdl, "mmol/L"),
);
}
const mgdl = getReadingTransform("BLOOD_GLUCOSE", DEFAULT_UNIT_PREFERENCES);
expect(mgdl.displayUnit).toBe("mg/dL");
expect(applyDisplayTransform(99, mgdl)).toBe(99);
});
it("leaves glucose alone under the imperial preference", () => {
const t = getReadingTransform("BLOOD_GLUCOSE", {
system: "imperial",
glucoseUnit: "mg/dL",
});
expect(t.displayUnit).toBe("mg/dL");
});
it("delegates every other type to the metric/imperial registry", () => {
expect(
getReadingTransform("WEIGHT", {
system: "imperial",
glucoseUnit: "mmol/L",
}).displayUnit,
).toBe("lb");
expect(
getReadingTransform("PULSE", {
system: "imperial",
glucoseUnit: "mmol/L",
}).displayUnit,
).toBe("bpm");
});
it("marks glucose and the transformed set as unit-sensitive", () => {
expect(isUnitSensitiveType("BLOOD_GLUCOSE")).toBe(true);
expect(isUnitSensitiveType("WEIGHT")).toBe(true);
expect(isUnitSensitiveType("HEART_RATE_VARIABILITY")).toBe(false);
});
it("resolves the stored columns with their defaults", () => {
expect(resolveUnitPreferences({})).toEqual(DEFAULT_UNIT_PREFERENCES);
expect(
resolveUnitPreferences({
unitPreference: "imperial",
glucoseUnit: "mmol/L",
}),
).toEqual({ system: "imperial", glucoseUnit: "mmol/L" });
expect(resolveUnitPreferences({ unitPreference: "bogus" }).system).toBe(
"metric",
);
});
it("converts workout distance, elevation and pace per preference", () => {
const km = getQuantityTransform("distance", "metric");
const mi = getQuantityTransform("distance", "imperial");
expect(applyDisplayTransform(5000, km)).toBe(5);
expect(km.displayUnit).toBe("km");
expect(applyDisplayTransform(5000, mi)).toBe(3.11);
expect(mi.displayUnit).toBe("mi");
expect(
applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")),
).toBe(328);
expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m");
// 30 min over 5 km: 360 s/km, 579.4 s/mi.
expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo(
360,
6,
);
expect(paceSecondsPerDistanceUnit(1800, 5000, "imperial")).toBeCloseTo(
579.36,
2,
);
});
});
+131
View File
@@ -61,12 +61,54 @@
*/
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;
@@ -156,6 +198,95 @@ const TRANSFORMS: Partial<
},
};
/**
* 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: 0 },
imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 },
},
};
/** 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
+26
View File
@@ -0,0 +1,26 @@
/**
* Server-side read of a record's two unit preferences.
*
* Every text the server writes for a person to read — a status note, a period
* narrative, a score label — quotes numbers, and a number is only right in the
* unit its reader chose. This is the one read those producers share, so none
* of them can load `unitPreference` and forget `glucoseUnit` or the reverse.
*/
import { prisma } from "@/lib/db";
import {
DEFAULT_UNIT_PREFERENCES,
resolveUnitPreferences,
type UnitPreferences,
} from "./display-transform";
/** Both preferences of one record; the defaults when the row is gone. */
export async function loadUnitPreferences(
userId: string,
): Promise<UnitPreferences> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { unitPreference: true, glucoseUnit: true },
});
return row ? resolveUnitPreferences(row) : DEFAULT_UNIT_PREFERENCES;
}