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, extraCalories: planByDate.get(date)?.extraCalories ?? 0, 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(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(); 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]; }