Files
Cibello-app/packages/nutrition-engine/src/calc.ts
T
2026-08-05 19:21:11 +07:00

120 lines
4.0 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.
import {
EMPTY_NUTRITION,
type NutritionDeclaration,
type NutritionValues,
type Unit,
} from "@app/shared-types";
import { toBase, toGrams, type IngredientUnitInfo } from "./units.js";
/** Skala näringsvärden med en faktor. */
export function scaleNutrition(values: NutritionValues, factor: number): NutritionValues {
const scaled: NutritionValues = {
kcal: values.kcal * factor,
proteinG: values.proteinG * factor,
carbsG: values.carbsG * factor,
fatG: values.fatG * factor,
saturatedFatG: values.saturatedFatG * factor,
fiberG: values.fiberG * factor,
sugarG: values.sugarG * factor,
saltG: values.saltG * factor,
};
if (values.micro) {
scaled.micro = Object.fromEntries(
Object.entries(values.micro).map(([k, v]) => [k, v == null ? v : v * factor]),
);
}
return scaled;
}
/** Summera två näringsvärden. */
export function addNutrition(a: NutritionValues, b: NutritionValues): NutritionValues {
const sum: NutritionValues = {
kcal: a.kcal + b.kcal,
proteinG: a.proteinG + b.proteinG,
carbsG: a.carbsG + b.carbsG,
fatG: a.fatG + b.fatG,
saturatedFatG: a.saturatedFatG + b.saturatedFatG,
fiberG: a.fiberG + b.fiberG,
sugarG: a.sugarG + b.sugarG,
saltG: a.saltG + b.saltG,
};
const micros = { ...(a.micro ?? {}) } as Record<string, number | undefined>;
if (b.micro) {
for (const [k, v] of Object.entries(b.micro)) {
if (v == null) continue;
micros[k] = (micros[k] ?? 0) + v;
}
}
if (Object.keys(micros).length > 0) sum.micro = micros;
return sum;
}
export function sumNutrition(items: NutritionValues[]): NutritionValues {
return items.reduce(addNutrition, { ...EMPTY_NUTRITION });
}
/** Avrunda för presentation (heltal kcal, en decimal på gram). */
export function roundNutrition(values: NutritionValues): NutritionValues {
const r1 = (v: number) => Math.round(v * 10) / 10;
const rounded: NutritionValues = {
kcal: Math.round(values.kcal),
proteinG: r1(values.proteinG),
carbsG: r1(values.carbsG),
fatG: r1(values.fatG),
saturatedFatG: r1(values.saturatedFatG),
fiberG: r1(values.fiberG),
sugarG: r1(values.sugarG),
saltG: r1(values.saltG),
};
if (values.micro) rounded.micro = values.micro;
return rounded;
}
/**
* Beräkna näring för en given mängd av en ingrediens/produkt utifrån dess
* deklaration. Returnerar null när beräkningen inte kan göras säkert
* anroparen ansvarar då för att visa osäkerheten (spec §61.4), aldrig gissa.
*/
export function computeItemNutrition(
quantity: number,
unit: Unit,
declaration: NutritionDeclaration,
info: IngredientUnitInfo = {},
): NutritionValues | null {
if (quantity < 0) return null;
switch (declaration.basis) {
case "per_100_g": {
const grams = toGrams(quantity, unit, info);
if (grams == null) return null;
return scaleNutrition(declaration.values, grams / 100);
}
case "per_100_ml": {
const base = toBase(quantity, unit);
let ml: number | null = null;
if (base.kind === "volume") ml = base.amount;
else {
// massa/antal → gram → ml via densitet
const grams = toGrams(quantity, unit, info);
const density = info.densityGPerMl;
if (grams != null && density != null && density > 0) ml = grams / density;
}
if (ml == null) return null;
return scaleNutrition(declaration.values, ml / 100);
}
case "per_piece": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
// Vikt angiven: räkna om via referensvikt.
const grams = toGrams(quantity, unit, info);
const ref = declaration.referenceWeightG;
if (grams == null || ref == null || ref <= 0) return null;
return scaleNutrition(declaration.values, grams / ref);
}
case "per_portion": {
const base = toBase(quantity, unit);
if (base.kind === "count") return scaleNutrition(declaration.values, base.amount);
return null;
}
}
}