mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
702 lines
22 KiB
TypeScript
702 lines
22 KiB
TypeScript
/**
|
||
* Nutrient catalog — the closed code set for the micronutrient-intake
|
||
* sync (v1.28).
|
||
*
|
||
* 26 syncable codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity
|
||
* types plus DietaryWater and DietaryCaffeine. v1.43 adds two codes no
|
||
* HealthKit type carries (`dha`, `epa`), which only a supplement's
|
||
* composition produces; they appear in responses and never in the batch. Energy, carbohydrates,
|
||
* protein, fat, sugar, fiber AND sodium/potassium are deliberately NOT
|
||
* here — the anti-goal against diet-app territory stands, and
|
||
* sodium/potassium are food-composition signals, not "substances
|
||
* taken" (maintainer decision, out until explicit user demand).
|
||
*
|
||
* This catalog is the single source of truth for:
|
||
* - the closed `nutrient` code enum on the wire and in storage;
|
||
* - the canonical storage unit per code, which is ALSO the pinned
|
||
* HealthKit query unit for iOS (mass types in mg or µg as listed,
|
||
* water in mL — never IU). The batch route rejects any entry whose
|
||
* echoed `unit` differs, because a silent µg/mg confusion is a
|
||
* 1000× corruption and the one-string echo is the cheapest guard;
|
||
* - the generous per-day plausibility cap (ingest guard against
|
||
* unit-confused or corrupted writers, NOT a health judgement —
|
||
* values above high-dose supplement reality get skipped);
|
||
* - the EFSA dietary reference values a later Coach block compares
|
||
* against. Every reference names its EFSA value kind (PRI /
|
||
* AI / safeLevel) and a direction: `target` for reference intakes,
|
||
* `upperGuidance` for caffeine's safe-level ceiling. Sex-split
|
||
* values resolve against the user profile at consumption time —
|
||
* a profile without sex omits the reference, never guesses.
|
||
*
|
||
* Reference values are the EFSA DRV Finder adult values (Dietary
|
||
* Reference Values for the EU population); each entry cites its source
|
||
* verbatim in `reference.source`. Where EFSA expresses a vitamin per
|
||
* energy unit (thiamin, niacin), the entry carries the value at the
|
||
* 10 MJ/day reference energy intake and says so in the citation.
|
||
* Chromium is the one exception: EFSA concluded no DRV can be set, so
|
||
* the entry cites the NIH ODS adequate intake instead — spelled out in
|
||
* its source string.
|
||
*/
|
||
|
||
import type { BinaryReferenceSex } from "@/lib/profile/sex";
|
||
|
||
/**
|
||
* The codes HealthKit may send in the nutrient batch. `NUTRIENT_CODES` adds
|
||
* the supplement-only codes below them; the batch validates against this
|
||
* tuple, so its contract stays the 26 codes it always was.
|
||
*/
|
||
export const SYNCABLE_NUTRIENT_CODES = [
|
||
// 13 vitamins
|
||
"vitamin_a",
|
||
"thiamin",
|
||
"riboflavin",
|
||
"niacin",
|
||
"pantothenic_acid",
|
||
"vitamin_b6",
|
||
"biotin",
|
||
"folate",
|
||
"vitamin_b12",
|
||
"vitamin_c",
|
||
"vitamin_d",
|
||
"vitamin_e",
|
||
"vitamin_k",
|
||
// 11 minerals (sodium + potassium excluded by directive)
|
||
"calcium",
|
||
"iron",
|
||
"magnesium",
|
||
"phosphorus",
|
||
"zinc",
|
||
"copper",
|
||
"manganese",
|
||
"selenium",
|
||
"chromium",
|
||
"molybdenum",
|
||
"iodine",
|
||
// taken substances with correlation value
|
||
"water",
|
||
"caffeine",
|
||
] as const;
|
||
|
||
/**
|
||
* v1.43 — codes that come only from a supplement's composition. No HealthKit
|
||
* quantity type carries them, so the batch never accepts them and they appear
|
||
* in responses only.
|
||
*/
|
||
export const SUPPLEMENT_ONLY_NUTRIENT_CODES = ["dha", "epa"] as const;
|
||
|
||
export const NUTRIENT_CODES = [
|
||
...SYNCABLE_NUTRIENT_CODES,
|
||
...SUPPLEMENT_ONLY_NUTRIENT_CODES,
|
||
] as const;
|
||
|
||
export type NutrientCode = (typeof NUTRIENT_CODES)[number];
|
||
export type SyncableNutrientCode = (typeof SYNCABLE_NUTRIENT_CODES)[number];
|
||
|
||
/** Canonical storage units. `ug` is µg on the wire — ASCII on purpose. */
|
||
export type NutrientUnit = "mg" | "ug" | "ml";
|
||
|
||
/**
|
||
* v1.43 — every unit an amount may be ENTERED in. The canonical units plus
|
||
* gram, litre and the international unit. IU exists only where one IU maps to
|
||
* one fixed mass (vitamin D, preformed vitamin A); vitamin E has none, because
|
||
* its IU depends on whether the form is natural or synthetic.
|
||
*/
|
||
export type NutrientInputUnitCode = NutrientUnit | "g" | "l" | "IU";
|
||
|
||
/** The unit vocabulary, spelled once. Every other file reads it from here. */
|
||
export const NUTRIENT_UNIT = {
|
||
mg: "mg",
|
||
ug: "ug",
|
||
ml: "ml",
|
||
g: "g",
|
||
l: "l",
|
||
IU: "IU",
|
||
} as const satisfies Record<NutrientInputUnitCode, NutrientInputUnitCode>;
|
||
|
||
export interface NutrientInputUnit {
|
||
unit: NutrientInputUnitCode;
|
||
/** Multiply an entered amount by this to get the canonical amount. */
|
||
factorToCanonical: number;
|
||
}
|
||
|
||
/**
|
||
* v1.43 — a tolerable upper intake level (UL): the highest daily intake not
|
||
* expected to cause harm. `scope: "total"` applies to everything taken in a
|
||
* day; `scope: "supplemental"` applies to the part from supplements only
|
||
* (magnesium salts, folic acid), which the server reads as the SUPPLEMENT
|
||
* source. `null` on a code where no UL is established.
|
||
*/
|
||
export interface NutrientUpperLimit {
|
||
value: number;
|
||
unit: NutrientUnit;
|
||
scope: "total" | "supplemental";
|
||
/**
|
||
* The chemical form the level is set for, where it is narrower than the
|
||
* nutrient: vitamin A's level covers preformed retinol only (beta-carotene
|
||
* does not count), folate's the folic acid, magnesium's the dissociable
|
||
* salts in supplements, vitamin E's alpha-tocopherol. `null` where the
|
||
* level covers the nutrient as such. Clients phrase it in the reader's
|
||
* language; `source` stays the bare citation.
|
||
*/
|
||
form: NutrientUpperLimitForm | null;
|
||
/** The citation (EFSA overview and the opinion it rests on), verbatim. */
|
||
source: string;
|
||
}
|
||
|
||
export type NutrientUpperLimitForm =
|
||
"retinol" | "folic_acid" | "magnesium_salts" | "alpha_tocopherol";
|
||
|
||
export interface NutrientReference {
|
||
/** EFSA value type, named in Coach prose later (Slice B). */
|
||
kind: "PRI" | "AI" | "safeLevel";
|
||
/** `target` = reference intake; `upperGuidance` = do-not-exceed. */
|
||
direction: "target" | "upperGuidance";
|
||
/** Uniform adult value where the source is uniform. */
|
||
adult?: number;
|
||
/** Sex-split values where the source splits (e.g. iron 11 / 16). */
|
||
male?: number;
|
||
female?: number;
|
||
/** Citation, quoted verbatim wherever the reference surfaces. */
|
||
source: string;
|
||
}
|
||
|
||
export interface NutrientDefinition {
|
||
code: NutrientCode;
|
||
/**
|
||
* HealthKit quantity type identifier iOS reads this code from. `null` for a
|
||
* supplement-only code (`dha`, `epa`).
|
||
*/
|
||
hkIdentifier: string | null;
|
||
/** Whether HealthKit may send this code in the nutrient batch. */
|
||
syncable: boolean;
|
||
/** Canonical storage unit AND the pinned HK query unit. */
|
||
unit: NutrientUnit;
|
||
/** i18n key for the display name (settings card + exports). */
|
||
labelKey: string;
|
||
/**
|
||
* Generous per-day ingest cap in the canonical unit. Entries above it
|
||
* are skipped `value_out_of_range` — sized well above high-dose
|
||
* supplement reality so only unit confusion / corruption trips it.
|
||
*/
|
||
plausibleDailyMax: number;
|
||
/** The reference intake, or `null` where none is set per code. */
|
||
reference: NutrientReference | null;
|
||
/** v1.43 — the units an amount may be entered in, canonical first. */
|
||
inputUnits: ReadonlyArray<NutrientInputUnit>;
|
||
/** v1.43 — the tolerable upper intake level, or `null`. */
|
||
upperLimit: NutrientUpperLimit | null;
|
||
}
|
||
|
||
/**
|
||
* The input units each canonical unit offers by default: the unit itself and
|
||
* its neighbours in the metric ladder. A code adds IU on top where one exists.
|
||
*/
|
||
const METRIC_INPUT_UNITS: Readonly<
|
||
Record<NutrientUnit, ReadonlyArray<NutrientInputUnit>>
|
||
> = {
|
||
mg: [
|
||
{ unit: NUTRIENT_UNIT.mg, factorToCanonical: 1 },
|
||
{ unit: NUTRIENT_UNIT.ug, factorToCanonical: 0.001 },
|
||
{ unit: NUTRIENT_UNIT.g, factorToCanonical: 1000 },
|
||
],
|
||
ug: [
|
||
{ unit: NUTRIENT_UNIT.ug, factorToCanonical: 1 },
|
||
{ unit: NUTRIENT_UNIT.mg, factorToCanonical: 1000 },
|
||
],
|
||
ml: [
|
||
{ unit: NUTRIENT_UNIT.ml, factorToCanonical: 1 },
|
||
{ unit: NUTRIENT_UNIT.l, factorToCanonical: 1000 },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* The international unit where one IU is one fixed mass of the canonical unit.
|
||
* Vitamin D: 1 IU = 0.025 µg cholecalciferol. Vitamin A: 1 IU = 0.3 µg
|
||
* retinol (preformed vitamin A, the form supplements state in IU).
|
||
*/
|
||
const IU_FACTOR: Partial<Record<NutrientCode, number>> = {
|
||
vitamin_d: 0.025,
|
||
vitamin_a: 0.3,
|
||
};
|
||
|
||
/**
|
||
* The tolerable upper intake levels for adults, from EFSA's overview of the
|
||
* tolerable upper intake levels (2024 edition), each citing the opinion it
|
||
* rests on. A code that is absent has no established UL: EFSA set none
|
||
* (vitamin C, K, B1, B2, B5, B12, biotin, phosphorus, chromium), set a safe
|
||
* level of intake instead of a UL (iron 2024, manganese 2023), or set one that
|
||
* depends on a chemical form the composition cannot say (niacin: nicotinic
|
||
* acid 10 mg, nicotinamide 900 mg). EPA and DHA have none: EFSA (2012) found
|
||
* supplemental intakes up to 5 g a day of both combined raise no concern, which
|
||
* is a statement about the pair, not a per-nutrient limit.
|
||
*/
|
||
const UPPER_LIMITS: Partial<Record<NutrientCode, NutrientUpperLimit>> = {
|
||
vitamin_a: {
|
||
value: 3000,
|
||
unit: "ug",
|
||
scope: "total",
|
||
form: "retinol",
|
||
source: "EFSA UL overview 2024 (SCF 2002)",
|
||
},
|
||
vitamin_b6: {
|
||
value: 12,
|
||
unit: "mg",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (NDA Panel 2023)",
|
||
},
|
||
folate: {
|
||
value: 1000,
|
||
unit: "ug",
|
||
scope: "supplemental",
|
||
form: "folic_acid",
|
||
source: "EFSA UL overview 2024 (NDA Panel 2023)",
|
||
},
|
||
vitamin_d: {
|
||
value: 100,
|
||
unit: "ug",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (NDA Panel 2023)",
|
||
},
|
||
vitamin_e: {
|
||
value: 300,
|
||
unit: "mg",
|
||
scope: "total",
|
||
form: "alpha_tocopherol",
|
||
source: "EFSA UL overview 2024 (NDA Panel 2024)",
|
||
},
|
||
calcium: {
|
||
value: 2500,
|
||
unit: "mg",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (NDA Panel 2012)",
|
||
},
|
||
magnesium: {
|
||
value: 250,
|
||
unit: "mg",
|
||
scope: "supplemental",
|
||
form: "magnesium_salts",
|
||
source: "EFSA UL overview 2024 (SCF 2001)",
|
||
},
|
||
zinc: {
|
||
value: 25,
|
||
unit: "mg",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (SCF 2003)",
|
||
},
|
||
copper: {
|
||
value: 5,
|
||
unit: "mg",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (SCF 2003)",
|
||
},
|
||
selenium: {
|
||
value: 255,
|
||
unit: "ug",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (NDA Panel 2023)",
|
||
},
|
||
molybdenum: {
|
||
value: 600,
|
||
unit: "ug",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (SCF 2000)",
|
||
},
|
||
iodine: {
|
||
value: 600,
|
||
unit: "ug",
|
||
scope: "total",
|
||
form: null,
|
||
source: "EFSA UL overview 2024 (SCF 2002)",
|
||
},
|
||
};
|
||
|
||
function inputUnitsFor(
|
||
code: NutrientCode,
|
||
unit: NutrientUnit,
|
||
): ReadonlyArray<NutrientInputUnit> {
|
||
const iu = IU_FACTOR[code];
|
||
return iu === undefined
|
||
? METRIC_INPUT_UNITS[unit]
|
||
: [
|
||
...METRIC_INPUT_UNITS[unit],
|
||
{ unit: NUTRIENT_UNIT.IU, factorToCanonical: iu },
|
||
];
|
||
}
|
||
|
||
function def(
|
||
code: NutrientCode,
|
||
hkSuffix: string,
|
||
unit: NutrientUnit,
|
||
plausibleDailyMax: number,
|
||
reference: NutrientReference,
|
||
): NutrientDefinition {
|
||
return {
|
||
code,
|
||
hkIdentifier: `HKQuantityTypeIdentifierDietary${hkSuffix}`,
|
||
syncable: true,
|
||
unit,
|
||
labelKey: `nutrients.names.${code}`,
|
||
plausibleDailyMax,
|
||
reference,
|
||
inputUnits: inputUnitsFor(code, unit),
|
||
upperLimit: UPPER_LIMITS[code] ?? null,
|
||
};
|
||
}
|
||
|
||
/** A code that only a supplement's composition produces (v1.43). */
|
||
function supplementOnly(
|
||
code: NutrientCode,
|
||
unit: NutrientUnit,
|
||
plausibleDailyMax: number,
|
||
): NutrientDefinition {
|
||
return {
|
||
code,
|
||
hkIdentifier: null,
|
||
syncable: false,
|
||
unit,
|
||
labelKey: `nutrients.names.${code}`,
|
||
plausibleDailyMax,
|
||
// EFSA's adequate intake for these (250 mg a day) is set for EPA and DHA
|
||
// combined, so neither code carries a reference of its own.
|
||
reference: null,
|
||
inputUnits: inputUnitsFor(code, unit),
|
||
upperLimit: UPPER_LIMITS[code] ?? null,
|
||
};
|
||
}
|
||
|
||
export const NUTRIENT_CATALOG: Readonly<
|
||
Record<NutrientCode, NutrientDefinition>
|
||
> = Object.freeze({
|
||
// ── Vitamins ─────────────────────────────────────────────────────
|
||
vitamin_a: def("vitamin_a", "VitaminA", "ug", 15000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
male: 750,
|
||
female: 650,
|
||
source: "EFSA DRV 2015 (retinol equivalents, adults)",
|
||
}),
|
||
// The cap is 500, not the 1000 the other B-vitamins carry, because thiamin's
|
||
// PRI is exactly 1.0 mg: a µg-read-as-mg sample lands on exactly 1000, and
|
||
// the ingest guard compares with `>`, so a 1000× error slipped through at
|
||
// precisely this one analyte. 500 still clears any real supplement dose
|
||
// (high-dose thiamin tops out around 300 mg) while catching that collision.
|
||
thiamin: def("thiamin", "Thiamin", "mg", 500, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
adult: 1.0,
|
||
source: "EFSA DRV 2016 (PRI 0.1 mg/MJ ≈ 1.0 mg/d at 10 MJ/d)",
|
||
}),
|
||
riboflavin: def("riboflavin", "Riboflavin", "mg", 1000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
adult: 1.6,
|
||
source: "EFSA DRV 2017 (adults)",
|
||
}),
|
||
niacin: def("niacin", "Niacin", "mg", 3000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
adult: 16,
|
||
source: "EFSA DRV 2014 (PRI 1.6 mg NE/MJ ≈ 16 mg NE/d at 10 MJ/d)",
|
||
}),
|
||
pantothenic_acid: def("pantothenic_acid", "PantothenicAcid", "mg", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 5,
|
||
source: "EFSA DRV 2014 (adults)",
|
||
}),
|
||
vitamin_b6: def("vitamin_b6", "VitaminB6", "mg", 1000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
male: 1.7,
|
||
female: 1.6,
|
||
source: "EFSA DRV 2016 (adults)",
|
||
}),
|
||
biotin: def("biotin", "Biotin", "ug", 20000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 40,
|
||
source: "EFSA DRV 2014 (adults)",
|
||
}),
|
||
folate: def("folate", "Folate", "ug", 5000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
adult: 330,
|
||
source: "EFSA DRV 2014 (dietary folate equivalents, adults)",
|
||
}),
|
||
vitamin_b12: def("vitamin_b12", "VitaminB12", "ug", 5000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 4,
|
||
source: "EFSA DRV 2015 (cobalamin, adults)",
|
||
}),
|
||
vitamin_c: def("vitamin_c", "VitaminC", "mg", 10000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
male: 110,
|
||
female: 95,
|
||
source: "EFSA DRV 2013 (adults)",
|
||
}),
|
||
vitamin_d: def("vitamin_d", "VitaminD", "ug", 1000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 15,
|
||
source: "EFSA DRV 2016 (adults, assuming minimal cutaneous synthesis)",
|
||
}),
|
||
vitamin_e: def("vitamin_e", "VitaminE", "mg", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
male: 13,
|
||
female: 11,
|
||
source: "EFSA DRV 2015 (alpha-tocopherol, adults)",
|
||
}),
|
||
vitamin_k: def("vitamin_k", "VitaminK", "ug", 10000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 70,
|
||
source: "EFSA DRV 2017 (phylloquinone, adults)",
|
||
}),
|
||
// ── Minerals ─────────────────────────────────────────────────────
|
||
calcium: def("calcium", "Calcium", "mg", 5000, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
adult: 950,
|
||
source: "EFSA DRV 2015 (adults ≥ 25 y)",
|
||
}),
|
||
iron: def("iron", "Iron", "mg", 500, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
male: 11,
|
||
female: 16,
|
||
source: "EFSA DRV 2015 (adults; 16 mg premenopausal women)",
|
||
}),
|
||
magnesium: def("magnesium", "Magnesium", "mg", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
male: 350,
|
||
female: 300,
|
||
source: "EFSA DRV 2015 (adults)",
|
||
}),
|
||
phosphorus: def("phosphorus", "Phosphorus", "mg", 5000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 550,
|
||
source: "EFSA DRV 2015 (adults)",
|
||
}),
|
||
zinc: def("zinc", "Zinc", "mg", 200, {
|
||
kind: "PRI",
|
||
direction: "target",
|
||
male: 9.4,
|
||
female: 7.5,
|
||
source: "EFSA DRV 2014 (adults, at 300 mg/d phytate intake)",
|
||
}),
|
||
copper: def("copper", "Copper", "mg", 20, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
male: 1.6,
|
||
female: 1.3,
|
||
source: "EFSA DRV 2015 (adults)",
|
||
}),
|
||
manganese: def("manganese", "Manganese", "mg", 50, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 3,
|
||
source: "EFSA DRV 2013 (adults)",
|
||
}),
|
||
selenium: def("selenium", "Selenium", "ug", 1000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 70,
|
||
source: "EFSA DRV 2014 (adults)",
|
||
}),
|
||
chromium: def("chromium", "Chromium", "ug", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
male: 35,
|
||
female: 25,
|
||
source: "NIH ODS 2018 (adults 19–50 y; EFSA 2014 sets no DRV for chromium)",
|
||
}),
|
||
molybdenum: def("molybdenum", "Molybdenum", "ug", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 65,
|
||
source: "EFSA DRV 2013 (adults)",
|
||
}),
|
||
iodine: def("iodine", "Iodine", "ug", 2000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
adult: 150,
|
||
source: "EFSA DRV 2014 (adults)",
|
||
}),
|
||
// ── Taken substances with correlation value ──────────────────────
|
||
water: def("water", "Water", "ml", 20000, {
|
||
kind: "AI",
|
||
direction: "target",
|
||
male: 2500,
|
||
female: 2000,
|
||
source: "EFSA DRV 2010 (total water incl. food moisture, adults)",
|
||
}),
|
||
caffeine: def("caffeine", "Caffeine", "mg", 2000, {
|
||
kind: "safeLevel",
|
||
direction: "upperGuidance",
|
||
adult: 400,
|
||
source: "EFSA 2015 scientific opinion on the safety of caffeine (adults)",
|
||
}),
|
||
// ── Supplement-only (v1.43) ──────────────────────────────────────
|
||
// Omega-3 fatty acids from fish or algae oil capsules. The cap sits well
|
||
// above the 5 g a day EFSA reviewed, so only a unit slip trips it.
|
||
dha: supplementOnly("dha", "mg", 20000),
|
||
epa: supplementOnly("epa", "mg", 20000),
|
||
});
|
||
|
||
/** Catalog rows in canonical display order (the tuple order above). */
|
||
export const NUTRIENT_DEFINITIONS: ReadonlyArray<NutrientDefinition> =
|
||
NUTRIENT_CODES.map((code) => NUTRIENT_CATALOG[code]);
|
||
|
||
const NUTRIENT_CODE_SET: ReadonlySet<string> = new Set(NUTRIENT_CODES);
|
||
|
||
/** True when `code` is a known catalog code. */
|
||
export function isNutrientCode(code: string): code is NutrientCode {
|
||
return NUTRIENT_CODE_SET.has(code);
|
||
}
|
||
|
||
const SYNCABLE_CODE_SET: ReadonlySet<string> = new Set(SYNCABLE_NUTRIENT_CODES);
|
||
|
||
/** True when HealthKit may send `code` in the nutrient batch. */
|
||
export function isSyncableNutrientCode(
|
||
code: string,
|
||
): code is SyncableNutrientCode {
|
||
return SYNCABLE_CODE_SET.has(code);
|
||
}
|
||
|
||
/**
|
||
* A code's catalogue entry with its position in display order, or `null` for
|
||
* a code the catalogue does not know.
|
||
*/
|
||
export function catalogEntryFor(
|
||
code: string,
|
||
): (NutrientDefinition & { order: number }) | null {
|
||
if (!isNutrientCode(code)) return null;
|
||
return { ...NUTRIENT_CATALOG[code], order: NUTRIENT_CODES.indexOf(code) };
|
||
}
|
||
|
||
/** Round away binary noise from a unit factor (1000 IU × 0.025 = 25 µg). */
|
||
function roundAmount(value: number): number {
|
||
return Math.round(value * 1e6) / 1e6;
|
||
}
|
||
|
||
/**
|
||
* v1.43 — convert an entered amount to the code's canonical unit. `null` when
|
||
* `unit` is not one of the code's input units: the caller refuses, it never
|
||
* guesses. This is the only unit conversion for nutrient amounts.
|
||
*/
|
||
export function toCanonicalNutrientAmount(
|
||
code: NutrientCode,
|
||
amount: number,
|
||
unit: string,
|
||
): number | null {
|
||
const input = NUTRIENT_CATALOG[code].inputUnits.find((u) => u.unit === unit);
|
||
if (!input) return null;
|
||
return roundAmount(amount * input.factorToCanonical);
|
||
}
|
||
|
||
/**
|
||
* How labels print the input units, folded to lower case without dots or
|
||
* spaces. A label says "mcg", "µg" or "μg" for microgram and "IE" (German),
|
||
* "UI" (French, Spanish, Italian) or "j.m." (Polish) for the international
|
||
* unit.
|
||
*/
|
||
const INPUT_UNIT_SPELLINGS: Readonly<Record<string, NutrientInputUnitCode>> = {
|
||
mg: NUTRIENT_UNIT.mg,
|
||
ug: NUTRIENT_UNIT.ug,
|
||
µg: NUTRIENT_UNIT.ug,
|
||
μg: NUTRIENT_UNIT.ug,
|
||
mcg: NUTRIENT_UNIT.ug,
|
||
g: NUTRIENT_UNIT.g,
|
||
ml: NUTRIENT_UNIT.ml,
|
||
l: NUTRIENT_UNIT.l,
|
||
iu: NUTRIENT_UNIT.IU,
|
||
ie: NUTRIENT_UNIT.IU,
|
||
ui: NUTRIENT_UNIT.IU,
|
||
jm: NUTRIENT_UNIT.IU,
|
||
};
|
||
|
||
/**
|
||
* v1.43 — read a unit as a label prints it into the catalogue's input-unit
|
||
* code, or `null` when it is not one. Whether the code accepts it is a second
|
||
* question ({@link toCanonicalNutrientAmount}).
|
||
*/
|
||
export function parseNutrientInputUnit(
|
||
raw: string,
|
||
): NutrientInputUnitCode | null {
|
||
const folded = raw.trim().toLowerCase().replace(/[\s.]/g, "");
|
||
return INPUT_UNIT_SPELLINGS[folded] ?? null;
|
||
}
|
||
|
||
/** Whether `unit` is the code's canonical unit (nothing to convert). */
|
||
export function isCanonicalNutrientUnit(
|
||
code: NutrientCode,
|
||
unit: string,
|
||
): boolean {
|
||
return NUTRIENT_CATALOG[code].unit === unit;
|
||
}
|
||
|
||
/**
|
||
* v1.43 — whether a day is above the code's upper limit. `total` is the day's
|
||
* sum across sources; `supplemental` the part from taken supplement doses.
|
||
* `null` when the code has no upper limit, so "not above" and "no limit to be
|
||
* above" stay two different answers.
|
||
*/
|
||
export function isAboveUpperLimit(
|
||
code: NutrientCode,
|
||
amounts: { total: number; supplemental: number },
|
||
): boolean | null {
|
||
const limit = NUTRIENT_CATALOG[code].upperLimit;
|
||
if (!limit) return null;
|
||
const compared =
|
||
limit.scope === "supplemental" ? amounts.supplemental : amounts.total;
|
||
return compared > limit.value;
|
||
}
|
||
|
||
/** A catalog reference resolved to one concrete number for a profile. */
|
||
export interface ResolvedNutrientReference {
|
||
kind: NutrientReference["kind"];
|
||
direction: NutrientReference["direction"];
|
||
value: number;
|
||
source: string;
|
||
}
|
||
|
||
/**
|
||
* Resolve a catalog reference against the user's profile sex (v1.29).
|
||
*
|
||
* `adult` values (uniform across sexes) resolve unconditionally. A
|
||
* sex-split reference (`male` / `female`, no `adult`) resolves only
|
||
* when `sex` is known — a profile without sex on file OMITS the
|
||
* reference rather than guessing, the catalog's own documented
|
||
* contract (see the module docblock). Every insights surface honours
|
||
* this: no reference line, no meta sentence, nothing inferred.
|
||
*/
|
||
export function resolveNutrientReference(
|
||
code: NutrientCode,
|
||
sex: BinaryReferenceSex | null,
|
||
): ResolvedNutrientReference | null {
|
||
const ref = NUTRIENT_CATALOG[code].reference;
|
||
if (!ref) return null;
|
||
const value =
|
||
ref.adult ??
|
||
(sex === "MALE" ? ref.male : sex === "FEMALE" ? ref.female : undefined);
|
||
if (value === undefined) return null;
|
||
return {
|
||
kind: ref.kind,
|
||
direction: ref.direction,
|
||
value,
|
||
source: ref.source,
|
||
};
|
||
}
|