Files
HealthLog/src/lib/nutrients/catalog.ts
T
Marc-André Bombeck da2de015fe feat(nutrients): source-aware day totals; manual water write path
NutrientIntakeDay's composite primary key gains `source` (migration
0249_nutrient_intake_day_source): a manual water entry and the
Apple-synced day total now live as two rows instead of the manual
write being silently clobbered by the next Apple last-writer-wins
sync. Every existing row already defaults to source=APPLE_HEALTH, so
the migration is a pure PK-constraint change with no data rewrite.

- POST /api/nutrients/batch keeps writing the APPLE_HEALTH row,
  byte-identical upsert semantics against the wider key.
- GET /api/nutrients sums amounts within (nutrient, day) across
  sources before folding into the window summary.
- POST /api/nutrients/water (new): manual water quick-add, add/set
  modes against the MANUAL row only, module-gated, rate-limited,
  idempotency-key aware, hard-evicts the dashboard snapshot cache.
- GET /api/nutrients/daily (new): dense per-day series for one
  nutrient (summed across sources) plus the EFSA reference resolved
  against the caller's profile sex — omitted when sex is unknown.
- catalog.ts gains resolveNutrientReference(), the sex-resolution
  helper both new routes and the insights cards share.

docs/api/openapi.yaml regenerated for the two new routes.
2026-07-17 16:08:39 +02:00

343 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Nutrient catalog — the closed code set for the micronutrient-intake
* sync (v1.28).
*
* 26 codes: the 24 HealthKit `Dietary*` vitamin/mineral quantity types
* plus DietaryWater and DietaryCaffeine. 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.
*/
export const 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;
export type NutrientCode = (typeof NUTRIENT_CODES)[number];
/** Canonical storage units. `ug` is µg on the wire — ASCII on purpose. */
export type NutrientUnit = "mg" | "ug" | "ml";
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. */
hkIdentifier: string;
/** 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;
reference: NutrientReference;
}
function def(
code: NutrientCode,
hkSuffix: string,
unit: NutrientUnit,
plausibleDailyMax: number,
reference: NutrientReference,
): NutrientDefinition {
return {
code,
hkIdentifier: `HKQuantityTypeIdentifierDietary${hkSuffix}`,
unit,
labelKey: `nutrients.names.${code}`,
plausibleDailyMax,
reference,
};
}
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)",
}),
thiamin: def("thiamin", "Thiamin", "mg", 1000, {
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)",
}),
});
/** 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);
}
/** 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: "MALE" | "FEMALE" | null,
): ResolvedNutrientReference | null {
const ref = NUTRIENT_CATALOG[code].reference;
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,
};
}