hellth-hub/apps/admin/lib/hellth-data.test.ts

291 lines
8.7 KiB
TypeScript
Raw Permalink Normal View History

import assert from "node:assert/strict";
import test from "node:test";
import {
CatalogFood,
Recipe,
amountToGrams,
buildShoppingItemsFromPlan,
buildHealthStatsSummary,
buildWeeklyBudgetSummary,
calcFoodForAmount,
calcRecipe,
classifyRecipe,
defaultHellthData,
describeIngredientAmount,
parseNutritionLabel,
recipeMacroDistribution,
redistributeWeeklyCalories,
startOfWeek,
unitLabel,
weekDates,
} from "./hellth-data";
const oliveOil: CatalogFood = {
id: "oil",
name: "Olivenöl",
category: "Öl & Fett",
kcalPer100g: 884,
proteinPer100g: 0,
carbsPer100g: 0,
fatPer100g: 100,
servingG: 15,
preferredUnit: "tbsp",
densityGPerMl: 0.91,
portions: [
{ id: "oil-tsp", label: "1 TL", unit: "tsp", amount: 1, gramsEquivalent: 4.6 },
{ id: "oil-tbsp", label: "1 EL", unit: "tbsp", amount: 1, gramsEquivalent: 13.7 },
],
source: "manual",
};
test("amountToGrams converts metric, liquid and portion units", () => {
assert.equal(amountToGrams(oliveOil, 10, "g"), 10);
assert.equal(amountToGrams(oliveOil, 100, "ml"), 91);
assert.equal(Math.round(amountToGrams(oliveOil, 2, "tsp") * 10) / 10, 9.1);
assert.equal(amountToGrams(oliveOil, 2, "tbsp", "oil-tbsp"), 27.4);
});
test("unitLabel and describeIngredientAmount keep user-facing German labels", () => {
assert.equal(unitLabel("piece"), "Stück");
assert.equal(unitLabel("tbsp"), "EL");
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7, amount: 1, unit: "tbsp" }), "1 EL");
assert.equal(describeIngredientAmount({ foodId: "oil", amountG: 13.7 }), "14 g");
});
test("calcFoodForAmount and calcRecipe calculate calories and macros per portion", () => {
const skyr: CatalogFood = {
id: "skyr",
name: "Skyr",
category: "Milchprodukte",
kcalPer100g: 64,
proteinPer100g: 11,
carbsPer100g: 4,
fatPer100g: 0.2,
servingG: 500,
source: "manual",
};
const oats: CatalogFood = {
id: "oats",
name: "Haferflocken",
category: "Getreide",
kcalPer100g: 372,
proteinPer100g: 13.5,
carbsPer100g: 58.7,
fatPer100g: 7,
servingG: 50,
source: "manual",
};
const recipe: Recipe = {
id: "recipe",
name: "Protein-Frühstück",
portions: 2,
minutes: 5,
ingredients: [
{ foodId: "skyr", amountG: 500 },
{ foodId: "oats", amountG: 100 },
],
};
assert.deepEqual(calcFoodForAmount(skyr, 250), { kcal: 160, protein: 27.5, carbs: 10, fat: 0.5 });
assert.deepEqual(calcRecipe(recipe, [skyr, oats]), {
kcal: 346,
protein: 34.3,
carbs: 39.4,
fat: 4,
});
});
test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
const oats: CatalogFood = {
id: "oats",
name: "Haferflocken",
category: "Getreide",
kcalPer100g: 372,
proteinPer100g: 13.5,
carbsPer100g: 58.7,
fatPer100g: 7,
servingG: 50,
source: "manual",
};
const tofu: CatalogFood = {
id: "tofu",
name: "Tofu natur",
category: "Protein",
kcalPer100g: 140,
proteinPer100g: 16,
carbsPer100g: 2,
fatPer100g: 8,
servingG: 200,
source: "manual",
};
const chicken: CatalogFood = {
id: "chicken",
name: "Hähnchenbrust",
category: "Fleisch",
kcalPer100g: 110,
proteinPer100g: 23,
carbsPer100g: 0,
fatPer100g: 1.5,
servingG: 180,
source: "manual",
};
const veganRecipe: Recipe = {
id: "vegan",
name: "Tofu Bowl",
portions: 1,
Refactor: CI, Schema-Split, 2FA-Optimierung und Code-Hygiene CI-Pipeline (Forgejo Actions): - .forgejo/workflows/ci.yml: lint + typecheck + test + admin build - typecheck-Scripts in packages/api, packages/db, apps/admin ergaenzt - ESLint FlatConfig (eslint-config-next 16) in apps/admin angelegt - React-19-Hook-Findings im bestehenden Code als Warnings markiert (set-state-in-effect, preserve-manual-memoization, no-unused-vars) DB-Schema-Split: - packages/db/src/schema/health.ts (636 Zeilen, Monolith) entfernt und auf 7 Domain-Module aufgeteilt: profiles, foods, stores, recipes, tracking, shopping, community. index.ts re-exportiert alles. API-Refactoring: - 2FA-Middleware: 2 DB-Queries pro Admin-Call sind jetzt lazy + per-Request gecached. Bei tRPC-Batch-Requests mit mehreren protected Procedures wird der 2FA-State nur einmal geladen (siehe context.ts getTwoFactorState). - any-Typen in trpc/init.ts und trpc/routers/health.ts entfernt; Drizzle inferiert Closure-Typen automatisch. - packages/api/src/trpc/routers/health/_shared.ts: alle Helper-Funktionen und Zod-Input-Schemas aus health.ts extrahiert. health.ts: 2419 -> 2061 Zeilen. Vorbereitung fuer Procedure-Split in Folge-Session. Konfiguration: - .env.example um alle in turbo.json deklarierten Env-Vars erweitert, jeweils mit Kontext-Kommentar. - turbo.json um real genutzte Env-Vars erweitert: NEXT_PUBLIC_HELP_URL, ENABLE_DB_MIGRATIONS, ADMIN_INITIAL_PASSWORD, DEMO_RECIPE_OWNER_EMAIL. - .gitignore: *.tsbuildinfo (TypeScript-Build-Cache). - apps/admin/lib/hellth-data.test.ts: fehlendes Recipe.minutes Feld in Mocks. Offene Folge-Sessions: - Vollstaendiger Procedure-Split von routers/health.ts in Sub-Files - Aufteilung von apps/admin/lib/hellth-data.ts (1149 Zeilen) in Domain-Module Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:34:39 +02:00
minutes: 10,
ingredients: [
{ foodId: "tofu", amountG: 200 },
{ foodId: "oats", amountG: 50 },
],
};
const chickenRecipe: Recipe = {
id: "chicken",
name: "Hähnchen Bowl",
portions: 1,
Refactor: CI, Schema-Split, 2FA-Optimierung und Code-Hygiene CI-Pipeline (Forgejo Actions): - .forgejo/workflows/ci.yml: lint + typecheck + test + admin build - typecheck-Scripts in packages/api, packages/db, apps/admin ergaenzt - ESLint FlatConfig (eslint-config-next 16) in apps/admin angelegt - React-19-Hook-Findings im bestehenden Code als Warnings markiert (set-state-in-effect, preserve-manual-memoization, no-unused-vars) DB-Schema-Split: - packages/db/src/schema/health.ts (636 Zeilen, Monolith) entfernt und auf 7 Domain-Module aufgeteilt: profiles, foods, stores, recipes, tracking, shopping, community. index.ts re-exportiert alles. API-Refactoring: - 2FA-Middleware: 2 DB-Queries pro Admin-Call sind jetzt lazy + per-Request gecached. Bei tRPC-Batch-Requests mit mehreren protected Procedures wird der 2FA-State nur einmal geladen (siehe context.ts getTwoFactorState). - any-Typen in trpc/init.ts und trpc/routers/health.ts entfernt; Drizzle inferiert Closure-Typen automatisch. - packages/api/src/trpc/routers/health/_shared.ts: alle Helper-Funktionen und Zod-Input-Schemas aus health.ts extrahiert. health.ts: 2419 -> 2061 Zeilen. Vorbereitung fuer Procedure-Split in Folge-Session. Konfiguration: - .env.example um alle in turbo.json deklarierten Env-Vars erweitert, jeweils mit Kontext-Kommentar. - turbo.json um real genutzte Env-Vars erweitert: NEXT_PUBLIC_HELP_URL, ENABLE_DB_MIGRATIONS, ADMIN_INITIAL_PASSWORD, DEMO_RECIPE_OWNER_EMAIL. - .gitignore: *.tsbuildinfo (TypeScript-Build-Cache). - apps/admin/lib/hellth-data.test.ts: fehlendes Recipe.minutes Feld in Mocks. Offene Folge-Sessions: - Vollstaendiger Procedure-Split von routers/health.ts in Sub-Files - Aufteilung von apps/admin/lib/hellth-data.ts (1149 Zeilen) in Domain-Module Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:34:39 +02:00
minutes: 15,
ingredients: [{ foodId: "chicken", amountG: 180 }],
};
const vegan = classifyRecipe(veganRecipe, [oats, tofu, chicken]);
const meat = classifyRecipe(chickenRecipe, [oats, tofu, chicken]);
assert.equal(vegan.vegetarian, true);
assert.equal(vegan.vegan, true);
assert.ok(vegan.tags.includes("vegan"));
assert.ok(vegan.criteria.some((item) => item.label === "Protein sinnvoll" && item.ok));
assert.equal(meat.vegetarian, false);
assert.equal(recipeMacroDistribution({ protein: 25, carbs: 50, fat: 10 }).carbs, 51);
});
test("parseNutritionLabel extracts German nutrition labels with comma decimals", () => {
const parsed = parseNutritionLabel(`
Durchschnittliche Nährwerte je 100 g
Energie 57 kcal
Fett 0,1 g
Kohlenhydrate 3,8 g
Eiweiß 10,0 g
`);
assert.equal(parsed.kcalPer100g, 57);
assert.equal(parsed.fatPer100g, 0.1);
assert.equal(parsed.carbsPer100g, 3.8);
assert.equal(parsed.proteinPer100g, 10);
});
test("week helpers start on Monday and build seven dates", () => {
assert.equal(startOfWeek("2026-05-31"), "2026-05-25");
assert.deepEqual(weekDates("2026-05-31"), [
"2026-05-25",
"2026-05-26",
"2026-05-27",
"2026-05-28",
"2026-05-29",
"2026-05-30",
"2026-05-31",
]);
});
test("weekly budget includes planned meals and activity calories", () => {
const data = defaultHellthData();
data.goals.calories = 2000;
data.weeklyCaloriePlans = [{ date: "2026-05-30", plannedCalories: 2800, note: "Restaurant" }];
data.activityLogs = [{ id: "run", date: "2026-05-25", name: "Laufen", minutes: 60, points: 100, calories: 550 }];
data.plannedMeals = [
{ id: "meal", date: "2026-05-25", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
];
const summary = buildWeeklyBudgetSummary(data, "2026-05-25");
assert.equal(summary.baseBudget, 14000);
assert.equal(summary.plannedBudget, 14800);
assert.equal(summary.activityCalories, 550);
assert.ok(summary.eatenCalories > 0);
assert.equal(summary.days.find((day) => day.date === "2026-05-30")?.plannedCalories, 2800);
});
test("redistributeWeeklyCalories raises one day and lowers the remaining week gently", () => {
const data = defaultHellthData();
data.goals.calories = 2000;
const plans = redistributeWeeklyCalories(data, "2026-05-30", 800, "2026-05-25");
const saturday = plans.find((plan) => plan.date === "2026-05-30");
const monday = plans.find((plan) => plan.date === "2026-05-25");
assert.equal(saturday?.plannedCalories, 2800);
assert.equal(monday?.plannedCalories, 1866);
assert.equal(saturday?.note, "bewusst höher geplant");
});
test("buildShoppingItemsFromPlan aggregates recipe ingredients and preserves manual items", () => {
const data = defaultHellthData();
data.shoppingItems = [
{ id: "manual", name: "Küchenrolle", category: "Manuell", amountG: 0, purchaseAmount: "1 Packung" },
{ id: "old-egg", foodId: "food-egg", name: "Ei", category: "Protein", amountG: 10, checked: true },
];
data.plannedMeals = [
{ id: "plan-a", date: "2026-05-26", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 1 },
{ id: "plan-b", date: "2026-05-27", mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins", portions: 2 },
];
const items = buildShoppingItemsFromPlan(data, ["2026-05-26"]);
const egg = items.find((item) => item.foodId === "food-egg");
assert.equal(egg?.amountG, 180);
assert.equal(egg?.checked, true);
assert.deepEqual(egg?.recipeIds, ["recipe-eggs"]);
assert.ok(items.some((item) => item.id === "manual"));
});
test("buildHealthStatsSummary creates motivating averages, streaks and weight projection", () => {
const data = defaultHellthData();
data.goals.drinkMl = 2000;
data.goals.protein = 120;
data.goals.weightKg = 100;
data.healthProfile = {
completedAt: "2026-05-01T00:00:00.000Z",
goal: "lose_weight",
age: 40,
heightCm: 180,
currentWeightKg: 110,
targetWeightKg: 100,
activityLevel: "moderate",
pace: "normal",
mealsPerDay: 3,
dietStyle: "mixed",
cookingTime: "medium",
budgetFocus: false,
excludedFoods: "",
biggestChallenge: "",
medicalNote: false,
};
data.activityLogs = [
{ id: "a1", date: "2026-05-25", name: "Spaziergang", minutes: 30, points: 60, calories: 140 },
{ id: "a2", date: "2026-05-26", name: "Laufen", minutes: 40, points: 90, calories: 380 },
];
const summary = buildHealthStatsSummary(
data,
{
days: {
"2026-05-13": { weightKg: 110 },
"2026-05-19": { weightKg: 109 },
"2026-05-26": {
weightKg: 108,
drinks: [{ amountMl: 1000 }, { amountMl: 1000 }],
foods: [{ calories: 1800, protein: 100 }],
},
},
},
"2026-05-26",
);
assert.equal(summary.averages.activityCalories > 0, true);
assert.equal(summary.streaks.waterDays, 1);
assert.equal(summary.streaks.proteinDays, 1);
assert.equal(summary.projection?.kind, "weight");
assert.equal(summary.projection?.targetDate !== undefined, true);
});