391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
|
|
import { todayKey } from "./constants";
|
||
|
|
import { calcRecipe } from "./nutrition";
|
||
|
|
import type {
|
||
|
|
CatalogFood,
|
||
|
|
CoachRecommendation,
|
||
|
|
HealthProfile,
|
||
|
|
HealthProjection,
|
||
|
|
HealthStatsDay,
|
||
|
|
HealthStatsSummary,
|
||
|
|
HellthData,
|
||
|
|
HellthDayStateSnapshot,
|
||
|
|
HellthGoals,
|
||
|
|
ShoppingItem,
|
||
|
|
Store,
|
||
|
|
WeeklyBudgetSummary,
|
||
|
|
} from "./types";
|
||
|
|
import { uid } from "./constants";
|
||
|
|
|
||
|
|
export function startOfWeek(dateKey: string) {
|
||
|
|
const date = new Date(`${dateKey}T12:00:00`);
|
||
|
|
const day = date.getDay() === 0 ? 7 : date.getDay();
|
||
|
|
date.setDate(date.getDate() - day + 1);
|
||
|
|
return date.toISOString().slice(0, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function weekDates(dateKey: string) {
|
||
|
|
const monday = new Date(`${startOfWeek(dateKey)}T12:00:00`);
|
||
|
|
return Array.from({ length: 7 }, (_, index) => {
|
||
|
|
const date = new Date(monday);
|
||
|
|
date.setDate(monday.getDate() + index);
|
||
|
|
return date.toISOString().slice(0, 10);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function addDateDays(dateKey: string, days: number) {
|
||
|
|
const date = new Date(`${dateKey}T12:00:00`);
|
||
|
|
date.setDate(date.getDate() + days);
|
||
|
|
return date.toISOString().slice(0, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildWeeklyBudgetSummary(data: HellthData, dateKey = todayKey()): WeeklyBudgetSummary {
|
||
|
|
const dates = weekDates(dateKey);
|
||
|
|
const dailyGoal = data.goals.calories;
|
||
|
|
const planByDate = new Map(data.weeklyCaloriePlans.map((plan) => [plan.date, plan]));
|
||
|
|
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||
|
|
|
||
|
|
const days = dates.map((date) => {
|
||
|
|
const plannedCalories = planByDate.get(date)?.plannedCalories ?? dailyGoal;
|
||
|
|
const plannedMeals = data.plannedMeals.filter((meal) => meal.date === date);
|
||
|
|
const eatenCalories = plannedMeals.reduce((sum, meal) => {
|
||
|
|
const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined;
|
||
|
|
if (!recipe) return sum;
|
||
|
|
return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions;
|
||
|
|
}, 0);
|
||
|
|
const activityCalories = data.activityLogs
|
||
|
|
.filter((log) => log.date === date)
|
||
|
|
.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
||
|
|
return {
|
||
|
|
date,
|
||
|
|
plannedCalories,
|
||
|
|
eatenCalories,
|
||
|
|
activityCalories,
|
||
|
|
balance: plannedCalories + activityCalories - eatenCalories,
|
||
|
|
note: planByDate.get(date)?.note,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const plannedBudget = days.reduce((sum, day) => sum + day.plannedCalories, 0);
|
||
|
|
const eatenCalories = days.reduce((sum, day) => sum + day.eatenCalories, 0);
|
||
|
|
const activityCalories = days.reduce((sum, day) => sum + day.activityCalories, 0);
|
||
|
|
const availableCalories = plannedBudget + activityCalories - eatenCalories;
|
||
|
|
|
||
|
|
return {
|
||
|
|
weekStart: dates[0]!,
|
||
|
|
dailyGoal,
|
||
|
|
baseBudget: dailyGoal * 7,
|
||
|
|
plannedBudget,
|
||
|
|
eatenCalories,
|
||
|
|
activityCalories,
|
||
|
|
availableCalories,
|
||
|
|
balance: plannedBudget + activityCalories - eatenCalories,
|
||
|
|
days,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function plannedCaloriesForDate(data: HellthData, date: string) {
|
||
|
|
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||
|
|
return data.plannedMeals
|
||
|
|
.filter((meal) => meal.date === date)
|
||
|
|
.reduce((sum, meal) => {
|
||
|
|
const recipe = meal.recipeId ? recipeById.get(meal.recipeId) : undefined;
|
||
|
|
if (!recipe) return sum;
|
||
|
|
return sum + calcRecipe(recipe, data.foods, recipe.portions).kcal * meal.portions;
|
||
|
|
}, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function latestStreak<T extends HealthStatsDay>(days: T[], predicate: (day: T) => boolean) {
|
||
|
|
let streak = 0;
|
||
|
|
for (const day of [...days].reverse()) {
|
||
|
|
if (!predicate(day)) break;
|
||
|
|
streak += 1;
|
||
|
|
}
|
||
|
|
return streak;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildWeightProjection(
|
||
|
|
data: HellthData,
|
||
|
|
days: HealthStatsDay[],
|
||
|
|
referenceDate: string,
|
||
|
|
): HealthProjection | undefined {
|
||
|
|
const entries = days
|
||
|
|
.filter((day) => day.weightKg !== undefined)
|
||
|
|
.map((day) => ({ date: day.date, weightKg: day.weightKg! }));
|
||
|
|
if (entries.length < 3) return undefined;
|
||
|
|
|
||
|
|
const first = entries[0]!;
|
||
|
|
const last = entries[entries.length - 1]!;
|
||
|
|
const elapsedDays = Math.max(
|
||
|
|
1,
|
||
|
|
Math.round((new Date(`${last.date}T12:00:00`).getTime() - new Date(`${first.date}T12:00:00`).getTime()) / 86_400_000),
|
||
|
|
);
|
||
|
|
if (elapsedDays < 7) return undefined;
|
||
|
|
|
||
|
|
const trendKgPerWeek = Math.round(((last.weightKg - first.weightKg) / elapsedDays) * 7 * 10) / 10;
|
||
|
|
const targetWeight = data.healthProfile?.targetWeightKg ?? data.goals.weightKg;
|
||
|
|
const wantsLowerWeight = targetWeight < last.weightKg;
|
||
|
|
const movingTowardTarget = wantsLowerWeight ? trendKgPerWeek < -0.05 : trendKgPerWeek > 0.05;
|
||
|
|
|
||
|
|
if (!movingTowardTarget) {
|
||
|
|
return {
|
||
|
|
kind: "weight",
|
||
|
|
trendKgPerWeek,
|
||
|
|
confidence: "low",
|
||
|
|
message: "Noch kein stabiler Zieltrend. Das ist normal, einzelne Wochen schwanken stark.",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const kgPerDay = Math.abs(trendKgPerWeek / 7);
|
||
|
|
const remainingKg = Math.abs(last.weightKg - targetWeight);
|
||
|
|
const estimatedDays = Math.ceil(remainingKg / Math.max(0.01, kgPerDay));
|
||
|
|
const targetDate = addDateDays(referenceDate, estimatedDays);
|
||
|
|
|
||
|
|
return {
|
||
|
|
kind: "weight",
|
||
|
|
trendKgPerWeek,
|
||
|
|
targetDate,
|
||
|
|
confidence: elapsedDays >= 14 && entries.length >= 5 ? "medium" : "low",
|
||
|
|
message: `Aktueller Trend: ${Math.abs(trendKgPerWeek).toLocaleString("de-DE")} kg pro Woche in Richtung Ziel.`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildHealthStatsSummary(
|
||
|
|
data: HellthData,
|
||
|
|
dayState: HellthDayStateSnapshot = {},
|
||
|
|
referenceDate = todayKey(),
|
||
|
|
): HealthStatsSummary {
|
||
|
|
const days = Array.from({ length: 14 }, (_, index) => addDateDays(referenceDate, index - 13)).map((date) => {
|
||
|
|
const snapshot = dayState.days?.[date];
|
||
|
|
const loggedCalories = snapshot?.foods?.reduce((sum, food) => sum + (Number(food.calories) || 0), 0) ?? 0;
|
||
|
|
const plannedCalories = plannedCaloriesForDate(data, date);
|
||
|
|
const activityCalories = data.activityLogs
|
||
|
|
.filter((log) => log.date === date)
|
||
|
|
.reduce((sum, log) => sum + (log.calories ?? 0), 0);
|
||
|
|
|
||
|
|
return {
|
||
|
|
date,
|
||
|
|
calories: loggedCalories || (date >= referenceDate ? plannedCalories : 0),
|
||
|
|
plannedCalories,
|
||
|
|
activityCalories,
|
||
|
|
protein: snapshot?.foods?.reduce((sum, food) => sum + (Number(food.protein) || 0), 0) ?? 0,
|
||
|
|
drinkMl: snapshot?.drinks?.reduce((sum, drink) => sum + (Number(drink.amountMl) || 0), 0) ?? 0,
|
||
|
|
weightKg: snapshot?.weightKg,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const relevantDays = days.filter((day) => day.date <= referenceDate);
|
||
|
|
const divisor = Math.max(1, relevantDays.length);
|
||
|
|
const averages = {
|
||
|
|
calories: Math.round(relevantDays.reduce((sum, day) => sum + day.calories, 0) / divisor),
|
||
|
|
protein: Math.round(relevantDays.reduce((sum, day) => sum + day.protein, 0) / divisor),
|
||
|
|
drinkMl: Math.round(relevantDays.reduce((sum, day) => sum + day.drinkMl, 0) / divisor),
|
||
|
|
activityCalories: Math.round(relevantDays.reduce((sum, day) => sum + day.activityCalories, 0) / divisor),
|
||
|
|
};
|
||
|
|
|
||
|
|
const streaks = {
|
||
|
|
waterDays: latestStreak(relevantDays, (day) => day.drinkMl >= data.goals.drinkMl),
|
||
|
|
proteinDays: latestStreak(relevantDays, (day) => day.protein >= data.goals.protein * 0.8),
|
||
|
|
activityDays: latestStreak(relevantDays, (day) => day.activityCalories > 0),
|
||
|
|
};
|
||
|
|
|
||
|
|
const wins = [
|
||
|
|
streaks.waterDays > 0 ? `${streaks.waterDays} Tag(e) Trinkziel in Folge` : undefined,
|
||
|
|
streaks.proteinDays > 0 ? `${streaks.proteinDays} Tag(e) Protein gut getroffen` : undefined,
|
||
|
|
streaks.activityDays > 0 ? `${streaks.activityDays} Tag(e) mit Bewegung in Folge` : undefined,
|
||
|
|
averages.activityCalories > 0 ? `Ø ${averages.activityCalories} kcal Sportbonus pro Tag` : undefined,
|
||
|
|
].filter(Boolean) as string[];
|
||
|
|
|
||
|
|
return {
|
||
|
|
days,
|
||
|
|
week: buildWeeklyBudgetSummary(data, referenceDate),
|
||
|
|
averages,
|
||
|
|
streaks,
|
||
|
|
wins: wins.length ? wins : ["Du sammelst gerade Daten. Jeder Eintrag macht die Auswertung besser."],
|
||
|
|
projection: buildWeightProjection(data, days, referenceDate),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function redistributeWeeklyCalories(
|
||
|
|
data: HellthData,
|
||
|
|
targetDate: string,
|
||
|
|
extraCalories: number,
|
||
|
|
referenceDate = todayKey(),
|
||
|
|
) {
|
||
|
|
const dates = weekDates(referenceDate);
|
||
|
|
const dailyGoal = data.goals.calories;
|
||
|
|
const target = targetDate || referenceDate;
|
||
|
|
const otherDates = dates.filter((date) => date !== target);
|
||
|
|
const savingPerOtherDay = otherDates.length ? Math.ceil(extraCalories / otherDates.length) : 0;
|
||
|
|
return dates.map((date) => ({
|
||
|
|
date,
|
||
|
|
plannedCalories: date === target ? dailyGoal + extraCalories : Math.max(1200, dailyGoal - savingPerOtherDay),
|
||
|
|
note: date === target ? "bewusst höher geplant" : extraCalories > 0 ? "Ausgleichstag" : undefined,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildGoalsFromProfile(profile: HealthProfile): HellthGoals {
|
||
|
|
const weight = Math.max(45, profile.currentWeightKg || 80);
|
||
|
|
const height = Math.max(130, profile.heightCm || 175);
|
||
|
|
const age = Math.max(18, profile.age || 40);
|
||
|
|
const activityFactor =
|
||
|
|
profile.activityLevel === "active" ? 1.55 : profile.activityLevel === "moderate" ? 1.35 : 1.2;
|
||
|
|
|
||
|
|
// Neutral estimate without sex-specific assumptions. Good enough for an onboarding target,
|
||
|
|
// later refined by real weight trend and adherence.
|
||
|
|
const bmr = 10 * weight + 6.25 * height - 5 * age - 80;
|
||
|
|
const maintenance = Math.round(bmr * activityFactor);
|
||
|
|
const deficit =
|
||
|
|
profile.goal === "lose_weight"
|
||
|
|
? profile.pace === "ambitious"
|
||
|
|
? 700
|
||
|
|
: profile.pace === "normal"
|
||
|
|
? 500
|
||
|
|
: 300
|
||
|
|
: 0;
|
||
|
|
const floor = weight > 90 ? 1600 : 1400;
|
||
|
|
const calories = Math.max(floor, maintenance - deficit);
|
||
|
|
const protein = Math.round(Math.min(220, Math.max(90, weight * (profile.goal === "lose_weight" ? 1.6 : 1.3))));
|
||
|
|
const fat = Math.round((calories * 0.3) / 9);
|
||
|
|
const carbs = Math.round((calories - protein * 4 - fat * 9) / 4);
|
||
|
|
|
||
|
|
return {
|
||
|
|
drinkMl: Math.round(Math.min(3500, Math.max(1800, weight * 35)) / 100) * 100,
|
||
|
|
calories,
|
||
|
|
carbs: Math.max(80, carbs),
|
||
|
|
fat,
|
||
|
|
protein,
|
||
|
|
weightKg: profile.targetWeightKg || profile.currentWeightKg,
|
||
|
|
fastingHours: profile.mealsPerDay <= 3 ? 12 : 10,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildCoachRecommendations(profile: HealthProfile, goals: HellthGoals): CoachRecommendation[] {
|
||
|
|
const recommendations: CoachRecommendation[] = [
|
||
|
|
{
|
||
|
|
id: "calorie-deficit",
|
||
|
|
title: profile.goal === "lose_weight" ? "Moderates Defizit halten" : "Stabile Routine aufbauen",
|
||
|
|
body:
|
||
|
|
profile.goal === "lose_weight"
|
||
|
|
? `Starte mit ca. ${goals.calories} kcal pro Tag. Prüfe nach 2 Wochen den Gewichtstrend statt einzelne Tageswerte zu bewerten.`
|
||
|
|
: `Starte mit ca. ${goals.calories} kcal pro Tag und bewerte Energie, Hunger und Gewichtstrend wöchentlich.`,
|
||
|
|
priority: "high",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "protein",
|
||
|
|
title: "Protein als Sattmacher planen",
|
||
|
|
body: `Plane pro Tag etwa ${goals.protein} g Protein ein. Praktisch: jede Hauptmahlzeit bekommt eine klare Proteinquelle.`,
|
||
|
|
priority: "high",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "water",
|
||
|
|
title: "Trinken sichtbar machen",
|
||
|
|
body: `Dein Trinkziel liegt bei ${goals.drinkMl} ml. Starte mit 500 ml bis Mittag und nutze die Wasserkarte als einfache Erinnerung.`,
|
||
|
|
priority: "medium",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
if (profile.biggestChallenge.trim()) {
|
||
|
|
recommendations.push({
|
||
|
|
id: "challenge",
|
||
|
|
title: "Haupt-Hürde zuerst entschärfen",
|
||
|
|
body: `Du hast "${profile.biggestChallenge.trim()}" als Hürde genannt. Plane dafür eine konkrete Standardlösung, z. B. ein schnelles Notfallgericht oder vorbereitete Snacks.`,
|
||
|
|
priority: "high",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (profile.cookingTime === "low") {
|
||
|
|
recommendations.push({
|
||
|
|
id: "simple-meals",
|
||
|
|
title: "Kurze Rezepte bevorzugen",
|
||
|
|
body: "Nutze Rezepte unter 20 Minuten und wiederhole gute Mahlzeiten bewusst. Abnehmen wird leichter, wenn nicht jede Mahlzeit neu entschieden werden muss.",
|
||
|
|
priority: "medium",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (profile.budgetFocus) {
|
||
|
|
recommendations.push({
|
||
|
|
id: "budget",
|
||
|
|
title: "Budgetfreundliche Basics",
|
||
|
|
body: "Haferflocken, Kartoffeln, Reis, Eier, Quark, TK-Gemüse und Hülsenfrüchte eignen sich gut für planbare, günstige Mahlzeiten.",
|
||
|
|
priority: "low",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (profile.medicalNote) {
|
||
|
|
recommendations.unshift({
|
||
|
|
id: "medical",
|
||
|
|
title: "Gesundheitliche Situation beachten",
|
||
|
|
body: "Bei Diagnosen, Medikamenten, Schwangerschaft oder Essstörungs-Vorgeschichte bitte Ziele mit Arzt oder Ernährungsfachkraft abstimmen. Hellth Hub ersetzt keine medizinische Beratung.",
|
||
|
|
priority: "high",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return recommendations;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isFoodAvailableInStore(food: CatalogFood | undefined, storeId: string) {
|
||
|
|
if (!food) return true;
|
||
|
|
if (food.availableEverywhere) return true;
|
||
|
|
return Boolean(food.storeIds?.includes(storeId));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function departmentForFood(food: CatalogFood | undefined, store: Store) {
|
||
|
|
const explicitId = food?.storeDepartmentIds?.[store.id]?.[0];
|
||
|
|
if (explicitId) {
|
||
|
|
const explicit = store.departments.find((department) => department.id === explicitId);
|
||
|
|
if (explicit) return explicit;
|
||
|
|
}
|
||
|
|
|
||
|
|
const category = food?.category.toLowerCase() ?? "";
|
||
|
|
const match = store.departments.find((department) => {
|
||
|
|
const name = department.name.toLowerCase();
|
||
|
|
return (
|
||
|
|
(category.includes("obst") && name.includes("obst")) ||
|
||
|
|
(category.includes("gemüse") && name.includes("gemüse")) ||
|
||
|
|
(category.includes("brot") && name.includes("brot")) ||
|
||
|
|
(category.includes("getreide") && (name.includes("cerealien") || name.includes("trocken"))) ||
|
||
|
|
(category.includes("milch") && name.includes("milch")) ||
|
||
|
|
(category.includes("protein") && (name.includes("protein") || name.includes("fleisch"))) ||
|
||
|
|
(category.includes("öl") && (name.includes("trocken") || name.includes("sonst")))
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
return match ?? store.departments.find((department) => department.name.toLowerCase().includes("sonst")) ?? store.departments.at(-1);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function buildShoppingItemsFromPlan(data: HellthData, dates?: string[]) {
|
||
|
|
const dateSet = dates?.length ? new Set(dates) : null;
|
||
|
|
const foodById = new Map(data.foods.map((food) => [food.id, food]));
|
||
|
|
const recipeById = new Map(data.recipes.map((recipe) => [recipe.id, recipe]));
|
||
|
|
const previousByFoodId = new Map(data.shoppingItems.filter((item) => item.foodId).map((item) => [item.foodId!, item]));
|
||
|
|
const previousManualItems = data.shoppingItems.filter((item) => !item.foodId);
|
||
|
|
const generated = new Map<string, ShoppingItem>();
|
||
|
|
|
||
|
|
for (const plan of data.plannedMeals) {
|
||
|
|
if (dateSet && !dateSet.has(plan.date)) continue;
|
||
|
|
const recipe = plan.recipeId ? recipeById.get(plan.recipeId) : undefined;
|
||
|
|
if (!recipe) continue;
|
||
|
|
|
||
|
|
for (const ingredient of recipe.ingredients) {
|
||
|
|
const food = foodById.get(ingredient.foodId);
|
||
|
|
if (!food) continue;
|
||
|
|
|
||
|
|
const previous = previousByFoodId.get(food.id);
|
||
|
|
const current = generated.get(food.id);
|
||
|
|
const amountG = ingredient.amountG * plan.portions;
|
||
|
|
generated.set(food.id, {
|
||
|
|
id: current?.id ?? previous?.id ?? uid("shop"),
|
||
|
|
foodId: food.id,
|
||
|
|
name: food.name,
|
||
|
|
category: food.category,
|
||
|
|
amountG: (current?.amountG ?? 0) + amountG,
|
||
|
|
purchaseAmount: current?.purchaseAmount ?? previous?.purchaseAmount,
|
||
|
|
checked: current?.checked ?? previous?.checked,
|
||
|
|
recipeIds: Array.from(new Set([...(current?.recipeIds ?? []), recipe.id])),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return [...generated.values(), ...previousManualItems];
|
||
|
|
}
|