diff --git a/src/hooks/use-unit-display.ts b/src/hooks/use-unit-display.ts index f74b311da..212823877 100644 --- a/src/hooks/use-unit-display.ts +++ b/src/hooks/use-unit-display.ts @@ -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(() => { 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]); } diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index ccb7c5d79..91f23e674 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -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, + ); + }); +}); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index ec61fe1f2..5e1cba548 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -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 = { + "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 +> = { + 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 diff --git a/src/lib/measurements/unit-preferences.ts b/src/lib/measurements/unit-preferences.ts new file mode 100644 index 000000000..5dadc9b7b --- /dev/null +++ b/src/lib/measurements/unit-preferences.ts @@ -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 { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { unitPreference: true, glucoseUnit: true }, + }); + return row ? resolveUnitPreferences(row) : DEFAULT_UNIT_PREFERENCES; +}