hellth-hub/apps/admin/lib/hellth/stats.ts
Sebastian Mayer 70f59cfd40
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Feature: Flexibles Wochenbudget mit Blocker-Tagen
Pro-Tag-Mehrbedarf statt fester Tageswerte:

- DB: Spalte extraCalories in weekly_calorie_plans (Migration 0018).
- API: neuer Endpoint setWeeklyExtras nimmt die ganze Woche entgegen
  und verteilt eingeplante Mehrkalorien serverseitig. Reine, getestete
  Verteilungslogik distributeWeeklyExtras haelt das Wochenziel konstant
  (Tage mit Blocker = Tagesziel + Extra; uebrige Tage anteilig reduziert,
  Floor 1200). statsSummary liefert extraCalories pro Tag mit.
- 3 neue API-Tests (Verteilung konstant, Floor, Endpoint).
- Wochenbudget-UI: je Tag ein "+kcal"- und ein "Anlass"-Feld plus ein
  einziger "kcal neu verteilen"-Button. Blocker-Tage werden hervorgehoben.
  Das fehleranfaellige Einzel-Datumsformular entfaellt.
- Dashboard nimmt den verteilten Plan-Wert des Tages als Basis-Tagesziel
  (z. B. 1.740 nach Ausgleich, 2.800 an einem Blocker-Tag) und zeigt einen
  Hinweis. Behebt zugleich den Sync: Wochenbudget-Aenderungen invalidieren
  jetzt alle statsSummary-Varianten, sodass das Dashboard sofort mitzieht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:30:29 +02:00

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