147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
|
|
import type {
|
||
|
|
CatalogFood,
|
||
|
|
PortionUnit,
|
||
|
|
Recipe,
|
||
|
|
RecipeIngredient,
|
||
|
|
RecipeRating,
|
||
|
|
} from "./types";
|
||
|
|
|
||
|
|
export function calcFoodForAmount(food: CatalogFood, amountG: number) {
|
||
|
|
const factor = amountG / 100;
|
||
|
|
return {
|
||
|
|
kcal: Math.round(food.kcalPer100g * factor),
|
||
|
|
protein: Math.round(food.proteinPer100g * factor * 10) / 10,
|
||
|
|
carbs: Math.round(food.carbsPer100g * factor * 10) / 10,
|
||
|
|
fat: Math.round(food.fatPer100g * factor * 10) / 10,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function unitLabel(unit: PortionUnit) {
|
||
|
|
const labels: Record<PortionUnit, string> = {
|
||
|
|
g: "g",
|
||
|
|
ml: "ml",
|
||
|
|
tsp: "TL",
|
||
|
|
tbsp: "EL",
|
||
|
|
piece: "Stück",
|
||
|
|
pack: "Packung",
|
||
|
|
bottle: "Flasche",
|
||
|
|
cup: "Becher",
|
||
|
|
slice: "Scheibe",
|
||
|
|
};
|
||
|
|
return labels[unit];
|
||
|
|
}
|
||
|
|
|
||
|
|
export function amountToGrams(food: CatalogFood, amount: number, unit: PortionUnit, portionId?: string) {
|
||
|
|
const portion = portionId ? food.portions?.find((item) => item.id === portionId) : undefined;
|
||
|
|
if (portion) return amount * (portion.gramsEquivalent / Math.max(1, portion.amount));
|
||
|
|
if (unit === "g") return amount;
|
||
|
|
if (unit === "ml") return amount * (food.densityGPerMl ?? 1);
|
||
|
|
if (unit === "tsp") return amount * 5 * (food.densityGPerMl ?? 1);
|
||
|
|
if (unit === "tbsp") return amount * 15 * (food.densityGPerMl ?? 1);
|
||
|
|
const matchingPortion = food.portions?.find((item) => item.unit === unit);
|
||
|
|
if (matchingPortion) return amount * (matchingPortion.gramsEquivalent / Math.max(1, matchingPortion.amount));
|
||
|
|
return amount * food.servingG;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function describeIngredientAmount(ingredient: RecipeIngredient) {
|
||
|
|
if (ingredient.amount && ingredient.unit) {
|
||
|
|
return `${ingredient.amount} ${unitLabel(ingredient.unit)}`;
|
||
|
|
}
|
||
|
|
return `${Math.round(ingredient.amountG)} g`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function calcRecipe(recipe: Recipe, foods: CatalogFood[], portions = recipe.portions) {
|
||
|
|
const byId = new Map(foods.map((food) => [food.id, food]));
|
||
|
|
const totals = recipe.ingredients.reduce(
|
||
|
|
(sum, ingredient) => {
|
||
|
|
const food = byId.get(ingredient.foodId);
|
||
|
|
if (!food) return sum;
|
||
|
|
const values = calcFoodForAmount(food, ingredient.amountG);
|
||
|
|
return {
|
||
|
|
kcal: sum.kcal + values.kcal,
|
||
|
|
protein: sum.protein + values.protein,
|
||
|
|
carbs: sum.carbs + values.carbs,
|
||
|
|
fat: sum.fat + values.fat,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
{ kcal: 0, protein: 0, carbs: 0, fat: 0 },
|
||
|
|
);
|
||
|
|
const divisor = Math.max(1, portions);
|
||
|
|
return {
|
||
|
|
kcal: Math.round(totals.kcal / divisor),
|
||
|
|
protein: Math.round((totals.protein / divisor) * 10) / 10,
|
||
|
|
carbs: Math.round((totals.carbs / divisor) * 10) / 10,
|
||
|
|
fat: Math.round((totals.fat / divisor) * 10) / 10,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function recipeMacroDistribution(totals: { protein: number; carbs: number; fat: number }) {
|
||
|
|
const proteinCalories = totals.protein * 4;
|
||
|
|
const carbsCalories = totals.carbs * 4;
|
||
|
|
const fatCalories = totals.fat * 9;
|
||
|
|
const total = Math.max(1, proteinCalories + carbsCalories + fatCalories);
|
||
|
|
return {
|
||
|
|
protein: Math.round((proteinCalories / total) * 100),
|
||
|
|
carbs: Math.round((carbsCalories / total) * 100),
|
||
|
|
fat: Math.round((fatCalories / total) * 100),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function classifyRecipe(recipe: Recipe, foods: CatalogFood[]) {
|
||
|
|
const byId = new Map(foods.map((food) => [food.id, food]));
|
||
|
|
const usedFoods = recipe.ingredients.map((ingredient) => byId.get(ingredient.foodId)).filter(Boolean) as CatalogFood[];
|
||
|
|
const text = usedFoods.map((food) => `${food.name} ${food.category}`).join(" ").toLowerCase();
|
||
|
|
const hasMeatOrFish = /fleisch|fisch|hähnchen|huhn|pute|rind|schwein|hack|wurst|speck|salami|schinken|thunfisch|lachs|garnel|shrimp|kabeljau/.test(text);
|
||
|
|
const hasAnimalProduct = hasMeatOrFish || /milch|joghurt|yoghurt|quark|skyr|käse|sahne|butter|ei\b|eier|honig|molke|whey|gelatine/.test(text);
|
||
|
|
const totals = calcRecipe(recipe, foods);
|
||
|
|
const distribution = recipeMacroDistribution(totals);
|
||
|
|
const tags = [
|
||
|
|
hasMeatOrFish ? "mit Fleisch/Fisch" : "vegetarisch",
|
||
|
|
!hasAnimalProduct ? "vegan" : undefined,
|
||
|
|
totals.protein >= 25 ? "proteinreich" : undefined,
|
||
|
|
totals.kcal <= 500 ? "kalorienbewusst" : undefined,
|
||
|
|
distribution.fat >= 45 ? "fettreich" : undefined,
|
||
|
|
distribution.carbs >= 55 ? "kohlenhydratbetont" : undefined,
|
||
|
|
].filter(Boolean) as string[];
|
||
|
|
const criteria = [
|
||
|
|
{ label: "Protein sinnvoll", ok: totals.protein >= 20, detail: `${totals.protein} g pro Portion` },
|
||
|
|
{ label: "Fett moderat", ok: distribution.fat <= 40, detail: `${distribution.fat} % der Makro-Kalorien` },
|
||
|
|
{ label: "Kalorien alltagstauglich", ok: totals.kcal <= 750, detail: `${totals.kcal} kcal pro Portion` },
|
||
|
|
];
|
||
|
|
return {
|
||
|
|
tags,
|
||
|
|
vegetarian: !hasMeatOrFish,
|
||
|
|
vegan: !hasAnimalProduct,
|
||
|
|
distribution,
|
||
|
|
criteria,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseNutritionLabel(text: string): Partial<CatalogFood> {
|
||
|
|
const normalized = text.replace(/,/g, ".").replace(/\s+/g, " ");
|
||
|
|
const read = (patterns: RegExp[]) => {
|
||
|
|
for (const pattern of patterns) {
|
||
|
|
const match = normalized.match(pattern);
|
||
|
|
if (match?.[1]) return Number(match[1]);
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
kcalPer100g: read([/(\d+(?:\.\d+)?)\s*kcal/i, /energie[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||
|
|
proteinPer100g: read([/(?:eiweiss|eiweiß|protein)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||
|
|
carbsPer100g: read([/(?:kohlenhydrate|carbohydrate)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||
|
|
fatPer100g: read([/(?:fett|fat)[^0-9]*(\d+(?:\.\d+)?)/i]),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function recipeRatingSummary(recipeId: string, ratings: RecipeRating[]) {
|
||
|
|
const recipeRatings = ratings.filter((rating) => rating.recipeId === recipeId);
|
||
|
|
if (!recipeRatings.length) return { average: 0, count: 0 };
|
||
|
|
const average = recipeRatings.reduce((sum, rating) => sum + rating.rating, 0) / recipeRatings.length;
|
||
|
|
return {
|
||
|
|
average: Math.round(average * 10) / 10,
|
||
|
|
count: recipeRatings.length,
|
||
|
|
};
|
||
|
|
}
|