hellth-hub/apps/admin/lib/hellth/nutrition.ts
Sebastian Mayer 8ed7c083c4
Some checks failed
CI / Lint, Typecheck, Test, Build (push) Has been cancelled
Refactor: God-Router + Frontend-Lib in Domain-Module aufgeteilt
Router-Split (packages/api/src/trpc/routers/health/):
- health.ts (2061 Zeilen Monolith) entfernt und in 6 Sub-Router aufgeteilt:
  - dashboard.ts (overview, profile, goals, point settings)
  - foods.ts (foods, nutrition, OpenFoodFacts, food preferences)
  - recipes.ts (recipes, submissions, reviews, activity feed)
  - stores.ts (stores, change-requests, shopping lists)
  - tracking.ts (meals, drinks, weight, mealPlans, activities, leaderboard)
  - community.ts (club, wearables, coach, stats)
- index.ts mergt alle Sub-Router via mergeRouters, sodass Frontend-Aufrufe
  (trpc.health.listFoods etc.) unveraendert weiterfunktionieren.
- t.mergeRouters in init.ts exportiert.

Frontend-Lib-Split (apps/admin/lib/hellth/):
- hellth-data.ts (1149 Zeilen Monolith) in 5 Domain-Module aufgeteilt:
  - types.ts (alle TypeScript-Types)
  - constants.ts (Seeds, Defaults, todayKey, uid)
  - nutrition.ts (calcRecipe, classifyRecipe, parseNutritionLabel, ...)
  - storage.ts (load/save HellthData, ClubData, hasCompletedHealthProfile)
  - stats.ts (Budget, Stats, Coach, Shopping, Weight-Projection)
- hellth-data.ts wird zum reinen Re-Export-Index, damit bestehende
  Imports `from "@/lib/hellth-data"` unveraendert weiterfunktionieren.

Funktional identisch zum Original - reine Code-Reorganisation.
Verifiziert: 28 API-Tests + 10 Admin-Tests gruen, Typecheck (3 Packages)
gruen, Admin-Build (26 Pages) gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:09:53 +02:00

146 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,
};
}