Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
8.6 KiB
TypeScript
288 lines
8.6 KiB
TypeScript
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,
|
|
ingredients: [
|
|
{ foodId: "tofu", amountG: 200 },
|
|
{ foodId: "oats", amountG: 50 },
|
|
],
|
|
};
|
|
const chickenRecipe: Recipe = {
|
|
id: "chicken",
|
|
name: "Hähnchen Bowl",
|
|
portions: 1,
|
|
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);
|
|
});
|
|
|
|
|