mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The dashboard tiles printed every value at one decimal: '121,0 mmHg',
'60,0 bpm', '9.240,0' steps. TrendCard takes the metric's precision, and
blood pressure, pulse, HRV, steps and mg/dL glucose pass zero. The
measurement list names count-shaped units the way the insights pages do
('13 Etagen', '12,5 Atemzüge/min') instead of the source's 'count'.
226 lines
8.2 KiB
TypeScript
226 lines
8.2 KiB
TypeScript
/**
|
|
* Blood-glucose unit conversions.
|
|
*
|
|
* HealthLog stores glucose canonically in **mg/dL**. The UI can display in
|
|
* mmol/L for users who prefer that (SI-unit countries). Conversion factor is
|
|
* 18.0182 (per DGIM / DDG S3 guideline). We round to clinically meaningful
|
|
* precision: 0 fractional digits for mg/dL, 1 for mmol/L.
|
|
*/
|
|
|
|
export type GlucoseUnit = "mg/dL" | "mmol/L";
|
|
|
|
/**
|
|
* mg/dL per mmol/L, per the DGIM / DDG S3 guideline. Exported because it is
|
|
* the one factor for this conversion in the tree: the inbound alias resolver
|
|
* normalises a mmol/L cell with it, the glucose insight page scales its chart
|
|
* by its reciprocal, and the helpers below round with it. Two nearly-equal
|
|
* copies of a health conversion is how the same reading comes out as two
|
|
* different numbers on two screens.
|
|
*/
|
|
export const MGDL_PER_MMOL = 18.0182;
|
|
|
|
export function mgdlToMmol(mgdl: number): number {
|
|
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
|
|
}
|
|
|
|
export function mmolToMgdl(mmol: number): number {
|
|
return Math.round(mmol * MGDL_PER_MMOL);
|
|
}
|
|
|
|
/** Decimal places a glucose value is shown at: whole mg/dL, tenths of mmol/L. */
|
|
export function glucoseFractionDigits(unit: GlucoseUnit): number {
|
|
return unit === "mmol/L" ? 1 : 0;
|
|
}
|
|
|
|
export function convertGlucose(value: number, to: GlucoseUnit): number {
|
|
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
|
|
}
|
|
|
|
/**
|
|
* Convert a value the user typed in their display unit back to the
|
|
* canonical mg/dL HealthLog stores. The inverse of {@link convertGlucose}
|
|
* for the editor write path: a `5.5 mmol/L` target the user enters must
|
|
* persist as `99 mg/dL`, not as the literal `5.5`. A `mg/dL` display unit
|
|
* is already canonical, so it only rounds.
|
|
*/
|
|
export function toCanonicalMgdl(value: number, from: GlucoseUnit): number {
|
|
return from === "mmol/L" ? mmolToMgdl(value) : Math.round(value);
|
|
}
|
|
|
|
/**
|
|
* A published glucose cut-point, in both units as its source states it.
|
|
*
|
|
* The SI figure is carried rather than converted because a guideline prints
|
|
* its own rounding: ADA's fasting cut-point is 100 mg/dL and 5.6 mmol/L, and
|
|
* `mgdlToMmol(100)` gives 5.5. A reader on mmol/L must see the number their
|
|
* own guideline prints, not a re-rounded neighbour of it.
|
|
*/
|
|
export interface GlucoseCutPoint {
|
|
mgdl: number;
|
|
mmol: number;
|
|
}
|
|
|
|
/** Consensus time-in-range band edges (Battelino 2019). */
|
|
export const GLUCOSE_TIR_CUT_POINTS = {
|
|
veryLow: { mgdl: 54, mmol: 3.0 },
|
|
low: { mgdl: 70, mmol: 3.9 },
|
|
high: { mgdl: 180, mmol: 10.0 },
|
|
veryHigh: { mgdl: 250, mmol: 13.9 },
|
|
} as const satisfies Record<string, GlucoseCutPoint>;
|
|
|
|
/** Fasting-glucose diagnostic cut-points for adults (ADA 2024). */
|
|
export const GLUCOSE_FASTING_CUT_POINTS = {
|
|
prediabetesFrom: { mgdl: 100, mmol: 5.6 },
|
|
prediabetesTo: { mgdl: 125, mmol: 6.9 },
|
|
diabetesFrom: { mgdl: 126, mmol: 7.0 },
|
|
} as const satisfies Record<string, GlucoseCutPoint>;
|
|
|
|
/**
|
|
* The interpolation parameters every glucose band sentence takes: each
|
|
* cut-point in the reader's unit, formatted by the caller's locale-aware
|
|
* number formatter, plus the unit symbol. A sentence that names a band
|
|
* reads its numbers from here, so no locale string carries a fixed unit.
|
|
*/
|
|
export function glucoseBandParams(
|
|
unit: GlucoseUnit,
|
|
formatNumber: (value: number, fractionDigits: number) => string,
|
|
): Record<string, string> {
|
|
const digits = glucoseFractionDigits(unit);
|
|
const v = (point: GlucoseCutPoint) =>
|
|
formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits);
|
|
return {
|
|
unit,
|
|
veryLow: v(GLUCOSE_TIR_CUT_POINTS.veryLow),
|
|
low: v(GLUCOSE_TIR_CUT_POINTS.low),
|
|
high: v(GLUCOSE_TIR_CUT_POINTS.high),
|
|
veryHigh: v(GLUCOSE_TIR_CUT_POINTS.veryHigh),
|
|
prediabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesFrom),
|
|
prediabetesTo: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesTo),
|
|
diabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.diabetesFrom),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The stored glucose-unit choice as the closed pair, or `null` when none was
|
|
* made. The column is free text (a restored backup writes it unchecked), so a
|
|
* value that is neither unit reads as no choice rather than passing through to
|
|
* a client that decodes the pair.
|
|
*/
|
|
export function storedGlucoseUnit(
|
|
value: string | null | undefined,
|
|
): GlucoseUnit | null {
|
|
return value === "mg/dL" || value === "mmol/L" ? value : null;
|
|
}
|
|
|
|
export function resolveGlucoseUnit(
|
|
userPreference: string | null | undefined,
|
|
): GlucoseUnit {
|
|
return userPreference === "mmol/L" ? "mmol/L" : "mg/dL";
|
|
}
|
|
|
|
/**
|
|
* The `GlucoseContext` enum members, in the order every surface iterates them.
|
|
*/
|
|
export const NAMED_GLUCOSE_CONTEXTS = [
|
|
"FASTING",
|
|
"POSTPRANDIAL",
|
|
"RANDOM",
|
|
"BEDTIME",
|
|
] as const;
|
|
|
|
export type NamedGlucoseContext = (typeof NAMED_GLUCOSE_CONTEXTS)[number];
|
|
|
|
/**
|
|
* The bucket every reading lands in whose `glucoseContext` column is NULL,
|
|
* blank, or not one of the four enum members.
|
|
*
|
|
* It is not a `GlucoseContext` value and never reaches the database — the
|
|
* column stays NULL. It exists because a reading without a meal-time tag is
|
|
* still a reading, and a surface that iterates only the named contexts drops
|
|
* it silently. That is not hypothetical: a meter synced through Apple Health
|
|
* writes no HealthKit meal-time metadata at all, so such an account has one
|
|
* hundred percent of its readings here and saw an empty dashboard, empty
|
|
* targets, and an empty doctor-report panel while the data was current
|
|
* (#943). The Coach's glucose block already grouped them under the same
|
|
* name — this promotes that one bucket to the shared notion the rest of the
|
|
* tree resolves through.
|
|
*/
|
|
export const GLUCOSE_CONTEXT_UNSPECIFIED = "UNSPECIFIED";
|
|
|
|
export type GlucoseContextBucket =
|
|
NamedGlucoseContext | typeof GLUCOSE_CONTEXT_UNSPECIFIED;
|
|
|
|
/** Canonical iteration order: the named contexts first, untagged last. */
|
|
export const GLUCOSE_CONTEXT_BUCKETS = [
|
|
...NAMED_GLUCOSE_CONTEXTS,
|
|
GLUCOSE_CONTEXT_UNSPECIFIED,
|
|
] as const;
|
|
|
|
/**
|
|
* Resolve a stored `glucose_context` cell to the bucket it belongs to.
|
|
* NULL, an empty string, and any value outside the enum all resolve to
|
|
* {@link GLUCOSE_CONTEXT_UNSPECIFIED} — the fail-open arm, because dropping
|
|
* a reading is worse than filing it under "no meal-time tag".
|
|
*/
|
|
export function glucoseContextBucket(
|
|
raw: string | null | undefined,
|
|
): GlucoseContextBucket {
|
|
if (raw == null) return GLUCOSE_CONTEXT_UNSPECIFIED;
|
|
const key = raw.trim().toUpperCase();
|
|
return (NAMED_GLUCOSE_CONTEXTS as readonly string[]).includes(key)
|
|
? (key as NamedGlucoseContext)
|
|
: GLUCOSE_CONTEXT_UNSPECIFIED;
|
|
}
|
|
|
|
/**
|
|
* Group rows into their glucose buckets, in {@link GLUCOSE_CONTEXT_BUCKETS}
|
|
* order, skipping buckets with no rows. Row order inside a bucket is the
|
|
* caller's input order, so a caller that reads "latest" off the head or tail
|
|
* keeps doing so.
|
|
*/
|
|
export function groupByGlucoseContext<T>(
|
|
rows: readonly T[],
|
|
contextOf: (row: T) => string | null | undefined,
|
|
): Array<[GlucoseContextBucket, T[]]> {
|
|
const byBucket = new Map<GlucoseContextBucket, T[]>();
|
|
for (const row of rows) {
|
|
const bucket = glucoseContextBucket(contextOf(row));
|
|
const list = byBucket.get(bucket);
|
|
if (list) list.push(row);
|
|
else byBucket.set(bucket, [row]);
|
|
}
|
|
return GLUCOSE_CONTEXT_BUCKETS.flatMap((bucket) => {
|
|
const list = byBucket.get(bucket);
|
|
return list ? [[bucket, list] as [GlucoseContextBucket, T[]]] : [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Threshold metric key corresponding to a glucose bucket. Keeps the
|
|
* effective-range resolver the single source of truth.
|
|
*
|
|
* The untagged bucket borrows the RANDOM band: a reading with no meal-time
|
|
* tag IS a spot reading, which is exactly what RANDOM means, and inventing a
|
|
* fifth threshold would give the user a second editor row that judges the
|
|
* same readings by a different number.
|
|
*/
|
|
export function thresholdMetricForContext(
|
|
context: GlucoseContextBucket,
|
|
):
|
|
| "BLOOD_GLUCOSE_FASTING"
|
|
| "BLOOD_GLUCOSE_POSTPRANDIAL"
|
|
| "BLOOD_GLUCOSE_RANDOM"
|
|
| "BLOOD_GLUCOSE_BEDTIME" {
|
|
switch (context) {
|
|
case "FASTING":
|
|
return "BLOOD_GLUCOSE_FASTING";
|
|
case "POSTPRANDIAL":
|
|
return "BLOOD_GLUCOSE_POSTPRANDIAL";
|
|
case "BEDTIME":
|
|
return "BLOOD_GLUCOSE_BEDTIME";
|
|
case "RANDOM":
|
|
case GLUCOSE_CONTEXT_UNSPECIFIED:
|
|
return "BLOOD_GLUCOSE_RANDOM";
|
|
}
|
|
}
|