Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { DailyTargets, NutritionValues } from "@app/shared-types";
|
||||
import { roundNutrition, sumNutrition } from "./calc.js";
|
||||
|
||||
export interface DaySummary {
|
||||
consumed: NutritionValues;
|
||||
targets: DailyTargets;
|
||||
remaining: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
};
|
||||
/** Andel av dagsmålet (0–1+), för progressvisning. */
|
||||
progress: {
|
||||
kcal: number;
|
||||
proteinG: number;
|
||||
carbsG: number;
|
||||
fatG: number;
|
||||
fiberG: number;
|
||||
saltOfMax: number;
|
||||
};
|
||||
saltWarning: boolean;
|
||||
}
|
||||
|
||||
/** "Min dag" (spec §4.3): summera loggade måltider mot dagsmål. */
|
||||
export function summarizeDay(meals: NutritionValues[], targets: DailyTargets): DaySummary {
|
||||
const consumed = roundNutrition(sumNutrition(meals));
|
||||
const remaining = {
|
||||
kcal: Math.round(targets.kcal - consumed.kcal),
|
||||
proteinG: Math.round((targets.proteinG - consumed.proteinG) * 10) / 10,
|
||||
carbsG: Math.round((targets.carbsG - consumed.carbsG) * 10) / 10,
|
||||
fatG: Math.round((targets.fatG - consumed.fatG) * 10) / 10,
|
||||
fiberG: Math.round((targets.fiberG - consumed.fiberG) * 10) / 10,
|
||||
};
|
||||
const safeDiv = (a: number, b: number) => (b > 0 ? a / b : 0);
|
||||
return {
|
||||
consumed,
|
||||
targets,
|
||||
remaining,
|
||||
progress: {
|
||||
kcal: safeDiv(consumed.kcal, targets.kcal),
|
||||
proteinG: safeDiv(consumed.proteinG, targets.proteinG),
|
||||
carbsG: safeDiv(consumed.carbsG, targets.carbsG),
|
||||
fatG: safeDiv(consumed.fatG, targets.fatG),
|
||||
fiberG: safeDiv(consumed.fiberG, targets.fiberG),
|
||||
saltOfMax: safeDiv(consumed.saltG, targets.saltMaxG),
|
||||
},
|
||||
saltWarning: consumed.saltG > targets.saltMaxG,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ActivityLevel, DailyTargets, GoalType, Sex } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* Energiberäkning enligt Mifflin–St Jeor. Detta är vägledning baserad på
|
||||
* officiella rekommendationer – Appen är inte medicinsk rådgivning (spec §6, §21).
|
||||
*/
|
||||
|
||||
export interface EnergyProfile {
|
||||
sex: Sex;
|
||||
age: number;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
activityLevel: ActivityLevel;
|
||||
primaryGoal?: GoalType | undefined;
|
||||
}
|
||||
|
||||
const ACTIVITY_FACTORS: Record<ActivityLevel, number> = {
|
||||
sedentary: 1.2,
|
||||
light: 1.375,
|
||||
moderate: 1.55,
|
||||
active: 1.725,
|
||||
very_active: 1.9,
|
||||
};
|
||||
|
||||
/** Justering per mål (kcal/dag). Konservativa, väldokumenterade nivåer. */
|
||||
const GOAL_ADJUSTMENTS: Partial<Record<GoalType, number>> = {
|
||||
lose_weight: -500,
|
||||
gain_weight: 400,
|
||||
build_muscle: 250,
|
||||
maintain_weight: 0,
|
||||
};
|
||||
|
||||
/** Lägsta rekommenderade energiintag – under detta kapas aldrig målet (säkerhetsgolv). */
|
||||
const MIN_KCAL = 1200;
|
||||
|
||||
export function bmrMifflinStJeor(
|
||||
p: Pick<EnergyProfile, "sex" | "age" | "heightCm" | "weightKg">,
|
||||
): number {
|
||||
const base = 10 * p.weightKg + 6.25 * p.heightCm - 5 * p.age;
|
||||
if (p.sex === "male") return base + 5;
|
||||
if (p.sex === "female") return base - 161;
|
||||
// Ospecificerat: medelvärde av formlerna, transparent redovisat i UI.
|
||||
return base - 78;
|
||||
}
|
||||
|
||||
export function tdee(p: EnergyProfile): number {
|
||||
return bmrMifflinStJeor(p) * ACTIVITY_FACTORS[p.activityLevel];
|
||||
}
|
||||
|
||||
export interface DailyTargetsResult {
|
||||
targets: DailyTargets;
|
||||
basis: {
|
||||
bmrKcal: number;
|
||||
tdeeKcal: number;
|
||||
goalAdjustmentKcal: number;
|
||||
activityLevel: ActivityLevel;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dagsmål: energi via TDEE + måljustering; protein per kg kroppsvikt;
|
||||
* fett som andel av energi; kolhydrater = resten; fiber 3 g/MJ (nordisk rekommendation);
|
||||
* salt max 6 g/dag.
|
||||
*/
|
||||
export function computeDailyTargets(p: EnergyProfile): DailyTargetsResult {
|
||||
const bmr = bmrMifflinStJeor(p);
|
||||
const maintenance = tdee(p);
|
||||
const adjustment = GOAL_ADJUSTMENTS[p.primaryGoal ?? "maintain_weight"] ?? 0;
|
||||
const kcal = Math.max(MIN_KCAL, Math.round(maintenance + adjustment));
|
||||
|
||||
const proteinPerKg =
|
||||
p.primaryGoal === "build_muscle" || p.primaryGoal === "more_protein"
|
||||
? 1.8
|
||||
: p.primaryGoal === "lose_weight"
|
||||
? 1.6
|
||||
: 1.2;
|
||||
const proteinG = Math.round(p.weightKg * proteinPerKg);
|
||||
|
||||
const fatShare = p.primaryGoal === "less_fat" ? 0.25 : 0.3;
|
||||
const fatG = Math.round((kcal * fatShare) / 9);
|
||||
|
||||
const carbsKcal = Math.max(0, kcal - proteinG * 4 - fatG * 9);
|
||||
const carbsG = Math.round(carbsKcal / 4);
|
||||
|
||||
// 3 g fiber per MJ (1 MJ ≈ 239 kcal)
|
||||
const fiberG = Math.round((kcal / 239) * 3);
|
||||
|
||||
return {
|
||||
targets: { kcal, proteinG, carbsG, fatG, fiberG, saltMaxG: 6 },
|
||||
basis: {
|
||||
bmrKcal: Math.round(bmr),
|
||||
tdeeKcal: Math.round(maintenance),
|
||||
goalAdjustmentKcal: adjustment,
|
||||
activityLevel: p.activityLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Standardmål när profil saknas (visas tydligt som schablon i appen). */
|
||||
export const DEFAULT_TARGETS: DailyTargets = {
|
||||
kcal: 2000,
|
||||
proteinG: 80,
|
||||
carbsG: 220,
|
||||
fatG: 67,
|
||||
fiberG: 25,
|
||||
saltMaxG: 6,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./units.js";
|
||||
export * from "./calc.js";
|
||||
export * from "./energy.js";
|
||||
export * from "./day.js";
|
||||
export * from "./recipe.js";
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { NutritionValues, Unit } from "@app/shared-types";
|
||||
import { computeItemNutrition, roundNutrition, scaleNutrition, sumNutrition } from "./calc.js";
|
||||
import type { IngredientUnitInfo } from "./units.js";
|
||||
import type { NutritionDeclaration } from "@app/shared-types";
|
||||
|
||||
export interface RecipeIngredientForCalc {
|
||||
canonicalIngredientId: string;
|
||||
quantity: number;
|
||||
unit: Unit;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface IngredientNutritionSource extends IngredientUnitInfo {
|
||||
nutritionPer100: NutritionDeclaration;
|
||||
}
|
||||
|
||||
export interface RecipeNutritionResult {
|
||||
perPortion: NutritionValues;
|
||||
total: NutritionValues;
|
||||
/** Ingredienser som inte kunde beräknas (saknad densitet etc.) – redovisas öppet. */
|
||||
uncomputableIngredientIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beräkna ett recepts näringsvärden ur dess ingredienser – deterministiskt.
|
||||
* Valfria ingredienser exkluderas ur grundberäkningen.
|
||||
*/
|
||||
export function computeRecipeNutrition(
|
||||
ingredients: RecipeIngredientForCalc[],
|
||||
portions: number,
|
||||
sources: Map<string, IngredientNutritionSource>,
|
||||
): RecipeNutritionResult {
|
||||
const parts: NutritionValues[] = [];
|
||||
const uncomputable: string[] = [];
|
||||
for (const ing of ingredients) {
|
||||
if (ing.optional) continue;
|
||||
const source = sources.get(ing.canonicalIngredientId);
|
||||
if (!source) {
|
||||
uncomputable.push(ing.canonicalIngredientId);
|
||||
continue;
|
||||
}
|
||||
const values = computeItemNutrition(ing.quantity, ing.unit, source.nutritionPer100, source);
|
||||
if (values == null) {
|
||||
uncomputable.push(ing.canonicalIngredientId);
|
||||
continue;
|
||||
}
|
||||
parts.push(values);
|
||||
}
|
||||
const total = sumNutrition(parts);
|
||||
const perPortion =
|
||||
portions > 0 ? roundNutrition(scaleNutrition(total, 1 / portions)) : roundNutrition(total);
|
||||
return { perPortion, total: roundNutrition(total), uncomputableIngredientIds: uncomputable };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { UNIT_INFO, type Unit, type UnitKind } from "@app/shared-types";
|
||||
|
||||
export interface IngredientUnitInfo {
|
||||
/** g per ml – krävs för volym → massa. */
|
||||
densityGPerMl?: number | null;
|
||||
/** g per styck – krävs för antal → massa. */
|
||||
gramsPerPiece?: number | null;
|
||||
}
|
||||
|
||||
export function unitKind(unit: Unit): UnitKind {
|
||||
return UNIT_INFO[unit].kind;
|
||||
}
|
||||
|
||||
/** Konvertera till basenhet inom samma slag (g, ml eller st). */
|
||||
export function toBase(quantity: number, unit: Unit): { kind: UnitKind; amount: number } {
|
||||
const info = UNIT_INFO[unit];
|
||||
return { kind: info.kind, amount: quantity * info.toBase };
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertera valfri mängd till gram. Returnerar null när konvertering inte är
|
||||
* möjlig utan mer information – hellre ärlig osäkerhet än gissning (spec §61.4).
|
||||
*/
|
||||
export function toGrams(
|
||||
quantity: number,
|
||||
unit: Unit,
|
||||
info: IngredientUnitInfo = {},
|
||||
): number | null {
|
||||
const base = toBase(quantity, unit);
|
||||
switch (base.kind) {
|
||||
case "mass":
|
||||
return base.amount;
|
||||
case "volume": {
|
||||
const density = info.densityGPerMl;
|
||||
if (density == null || density <= 0) return null;
|
||||
return base.amount * density;
|
||||
}
|
||||
case "count": {
|
||||
const perPiece = info.gramsPerPiece;
|
||||
if (perPiece == null || perPiece <= 0) return null;
|
||||
return base.amount * perPiece;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Konvertera en mängd mellan enheter (samma slag, eller via densitet/styckvikt). */
|
||||
export function convert(
|
||||
quantity: number,
|
||||
from: Unit,
|
||||
to: Unit,
|
||||
info: IngredientUnitInfo = {},
|
||||
): number | null {
|
||||
const fromInfo = UNIT_INFO[from];
|
||||
const toInfo = UNIT_INFO[to];
|
||||
if (fromInfo.kind === toInfo.kind) {
|
||||
return (quantity * fromInfo.toBase) / toInfo.toBase;
|
||||
}
|
||||
// Olika slag: gå via gram.
|
||||
const grams = toGrams(quantity, from, info);
|
||||
if (grams == null) return null;
|
||||
if (toInfo.kind === "mass") return grams / toInfo.toBase;
|
||||
if (toInfo.kind === "volume") {
|
||||
const density = info.densityGPerMl;
|
||||
if (density == null || density <= 0) return null;
|
||||
return grams / density / toInfo.toBase;
|
||||
}
|
||||
const perPiece = info.gramsPerPiece;
|
||||
if (perPiece == null || perPiece <= 0) return null;
|
||||
return grams / perPiece / toInfo.toBase;
|
||||
}
|
||||
Reference in New Issue
Block a user