mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
d00e106ce1
commit
70e6e07a89
4 files changed
+261
-5
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user