export type MealType = "Frühstück" | "Mittagessen" | "Abendessen" | "Snack"; export type PortionUnit = "g" | "ml" | "tsp" | "tbsp" | "piece" | "pack" | "bottle" | "cup" | "slice"; export type FoodPortion = { id: string; label: string; unit: PortionUnit; amount: number; gramsEquivalent: number; }; export type CatalogFood = { id: string; name: string; brand?: string; category: string; kcalPer100g: number; proteinPer100g: number; carbsPer100g: number; fatPer100g: number; servingG: number; preferredUnit?: PortionUnit; densityGPerMl?: number; portions?: FoodPortion[]; favorite?: boolean; source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community"; barcode?: string; availableEverywhere?: boolean; storeIds?: string[]; availabilityTags?: string[]; storeDepartmentIds?: Record; lastPriceByStore?: Record; }; export type StoreDepartment = { id: string; name: string; sortOrder: number; }; export type Store = { id: string; name: string; favorite?: boolean; departments: StoreDepartment[]; }; export type RecipeIngredient = { foodId: string; amountG: number; amount?: number; unit?: PortionUnit; portionId?: string; }; export type Recipe = { id: string; name: string; portions: number; minutes: number; ingredients: RecipeIngredient[]; notes?: string; }; export type RecipeRating = { id: string; recipeId: string; userKey: string; displayName: string; rating: number; comment?: string; createdAt: string; }; export type PlannedMeal = { id: string; date: string; mealType: MealType; recipeId?: string; name: string; portions: number; note?: string; }; export type ShoppingItem = { id: string; foodId?: string; name: string; category: string; amountG: number; purchaseAmount?: string; checked?: boolean; recipeIds?: string[]; }; export type HellthGoals = { drinkMl: number; calories: number; carbs: number; fat: number; protein: number; weightKg: number; fastingHours: number; }; export type HealthGoal = "lose_weight" | "maintain" | "healthier"; export type ActivityLevel = "low" | "moderate" | "active"; export type WeightLossPace = "gentle" | "normal" | "ambitious"; export type HealthProfile = { completedAt: string; goal: HealthGoal; age: number; heightCm: number; currentWeightKg: number; targetWeightKg?: number; activityLevel: ActivityLevel; pace: WeightLossPace; mealsPerDay: number; dietStyle: "mixed" | "vegetarian" | "vegan" | "low_carb" | "flexible"; cookingTime: "low" | "medium" | "high"; budgetFocus: boolean; excludedFoods: string; biggestChallenge: string; medicalNote: boolean; }; export type CoachRecommendation = { id: string; title: string; body: string; priority: "high" | "medium" | "low"; }; export type ReminderKind = "water" | "snack" | "movement"; export type ReminderRule = { enabled: boolean; intervalMinutes: number; startTime: string; endTime: string; message: string; }; export type ReminderSettings = { enabled: boolean; permissionRequestedAt?: string; rules: Record; }; export type ActivityTemplate = { id: string; name: string; minutes: number; points: number; calories: number; category: "mobility" | "cardio" | "strength" | "habit"; icon: string; }; export type ActivityLog = { id: string; templateId?: string; name: string; date: string; minutes: number; points: number; calories?: number; note?: string; }; export type WeeklyCaloriePlan = { date: string; plannedCalories: number; note?: string; }; export type ClubActivityCategory = "walk" | "run" | "mobility" | "strength" | "bike" | "other"; export type ClubScope = "local" | "state" | "nationwide"; export type ClubPrivacyMode = "city" | "state"; export type GermanState = | "Baden-Württemberg" | "Bayern" | "Berlin" | "Brandenburg" | "Bremen" | "Hamburg" | "Hessen" | "Mecklenburg-Vorpommern" | "Niedersachsen" | "Nordrhein-Westfalen" | "Rheinland-Pfalz" | "Saarland" | "Sachsen" | "Sachsen-Anhalt" | "Schleswig-Holstein" | "Thüringen"; export type ClubProfile = { state: GermanState; city?: string; latitude?: number; longitude?: number; privacyMode: ClubPrivacyMode; }; export type ClubActivityParticipant = { userKey: string; displayName: string; joinedAt: string; completedAt?: string; }; export type ClubActivity = { id: string; title: string; hostUserKey: string; hostName: string; category: ClubActivityCategory; scope?: ClubScope; state?: GermanState; city?: string; latitude?: number; longitude?: number; locationLabel?: string; startsAt: string; durationMinutes: number; points: number; note?: string; participants: ClubActivityParticipant[]; }; export type ClubData = { profile: ClubProfile; activities: ClubActivity[]; }; export type HellthData = { stores: Store[]; foods: CatalogFood[]; recipes: Recipe[]; recipeRatings: RecipeRating[]; plannedMeals: PlannedMeal[]; shoppingItems: ShoppingItem[]; goals: HellthGoals; healthProfile?: HealthProfile; coachRecommendations?: CoachRecommendation[]; reminders: ReminderSettings; activityTemplates: ActivityTemplate[]; activityLogs: ActivityLog[]; weeklyCaloriePlans: WeeklyCaloriePlan[]; }; export const HELLTH_DATA_KEY = "hellth-hub-data-v1"; export const HELLTH_CLUB_KEY = "hellth-hub-club-v1"; export const defaultGoals: HellthGoals = { drinkMl: 2500, calories: 2000, carbs: 200, fat: 67, protein: 150, weightKg: 120, fastingHours: 12, }; export const defaultReminderSettings: ReminderSettings = { enabled: false, rules: { water: { enabled: true, intervalMinutes: 90, startTime: "08:00", endTime: "20:00", message: "Zeit für ein Glas Wasser.", }, snack: { enabled: false, intervalMinutes: 240, startTime: "10:00", endTime: "17:00", message: "Prüfe kurz: brauchst du einen geplanten Snack oder reicht die nächste Mahlzeit?", }, movement: { enabled: true, intervalMinutes: 60, startTime: "09:00", endTime: "17:00", message: "Steh kurz auf, streck dich und geh ein paar Schritte.", }, }, }; export const seedActivityTemplates: ActivityTemplate[] = [ { id: "activity-stretch-5", name: "5 Minuten Dehnübungen", minutes: 5, points: 20, calories: 20, category: "mobility", icon: "stretch" }, { id: "activity-desk-walk", name: "Schreibtischpause mit kurzem Spaziergang", minutes: 10, points: 25, calories: 35, category: "habit", icon: "walk" }, { id: "activity-walk-30", name: "30 Minuten Spaziergang", minutes: 30, points: 60, calories: 130, category: "cardio", icon: "walk" }, { id: "activity-run-60", name: "1 Stunde Laufen", minutes: 60, points: 100, calories: 550, category: "cardio", icon: "run" }, { id: "activity-strength-20", name: "20 Minuten Krafttraining", minutes: 20, points: 70, calories: 140, category: "strength", icon: "strength" }, ]; export const clubCategories: Array<{ key: ClubActivityCategory; label: string }> = [ { key: "walk", label: "Spazieren" }, { key: "run", label: "Laufen" }, { key: "mobility", label: "Mobility" }, { key: "strength", label: "Kraft" }, { key: "bike", label: "Rad" }, { key: "other", label: "Sonstiges" }, ]; export const germanStates: GermanState[] = [ "Baden-Württemberg", "Bayern", "Berlin", "Brandenburg", "Bremen", "Hamburg", "Hessen", "Mecklenburg-Vorpommern", "Niedersachsen", "Nordrhein-Westfalen", "Rheinland-Pfalz", "Saarland", "Sachsen", "Sachsen-Anhalt", "Schleswig-Holstein", "Thüringen", ]; export const defaultClubProfile: ClubProfile = { state: "Bayern", city: "Augsburg", privacyMode: "state", }; const lidlDepartments: StoreDepartment[] = [ { id: "lidl-produce", name: "Obst & Gemüse", sortOrder: 1 }, { id: "lidl-bakery", name: "Brot & Backwaren", sortOrder: 2 }, { id: "lidl-cereals", name: "Cerealien", sortOrder: 3 }, { id: "lidl-dairy", name: "Milchprodukte", sortOrder: 4 }, { id: "lidl-meat", name: "Fleisch/Wurst/Käse", sortOrder: 5 }, { id: "lidl-frozen", name: "Tiefkühlung", sortOrder: 6 }, { id: "lidl-drinks", name: "Getränke", sortOrder: 7 }, { id: "lidl-checkout", name: "Kasse", sortOrder: 8 }, ]; function genericDepartments(storeId: string): StoreDepartment[] { return [ { id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 }, { id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 }, { id: `${storeId}-dry`, name: "Trockenware", sortOrder: 3 }, { id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 4 }, { id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 5 }, { id: `${storeId}-frozen`, name: "Tiefkühlung", sortOrder: 6 }, { id: `${storeId}-drinks`, name: "Getränke", sortOrder: 7 }, { id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 }, ]; } export const seedStores: Store[] = [ { id: "lidl", name: "Lidl", favorite: true, departments: lidlDepartments }, { id: "aldi", name: "Aldi", favorite: true, departments: genericDepartments("aldi") }, { id: "edeka", name: "Edeka", favorite: true, departments: genericDepartments("edeka") }, { id: "kaufland", name: "Kaufland", favorite: true, departments: genericDepartments("kaufland") }, { id: "rewe", name: "Rewe", departments: genericDepartments("rewe") }, { id: "dm", name: "DM", departments: genericDepartments("dm") }, { id: "biomarkt", name: "Biomarkt", departments: genericDepartments("biomarkt") }, ]; export const seedFoods: CatalogFood[] = [ { id: "food-apple", name: "Apfel", category: "Obst", kcalPer100g: 52, proteinPer100g: 0.3, carbsPer100g: 14, fatPer100g: 0.2, servingG: 150, preferredUnit: "piece", portions: [{ id: "apple-piece", label: "1 Apfel", unit: "piece", amount: 1, gramsEquivalent: 150 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] }, { id: "food-banana", name: "Banane", category: "Obst", kcalPer100g: 89, proteinPer100g: 1.1, carbsPer100g: 23, fatPer100g: 0.3, servingG: 120, preferredUnit: "piece", portions: [{ id: "banana-piece", label: "1 Banane", unit: "piece", amount: 1, gramsEquivalent: 120 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] }, { id: "food-oats", name: "Haferflocken", category: "Getreide", kcalPer100g: 372, proteinPer100g: 13.5, carbsPer100g: 58.7, fatPer100g: 7, servingG: 50, preferredUnit: "g", favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "vorrat"] }, { id: "food-rice", name: "Reis, trocken", category: "Getreide", kcalPer100g: 350, proteinPer100g: 7.4, carbsPer100g: 78, fatPer100g: 0.6, servingG: 75, source: "seed", availableEverywhere: true, availabilityTags: ["vorrat"] }, { id: "food-potato", name: "Kartoffeln", category: "Gemüse", kcalPer100g: 77, proteinPer100g: 2, carbsPer100g: 17, fatPer100g: 0.1, servingG: 250, source: "seed", availableEverywhere: true, availabilityTags: ["basis", "frisch"] }, { id: "food-egg", name: "Ei", category: "Protein", kcalPer100g: 155, proteinPer100g: 13, carbsPer100g: 1.1, fatPer100g: 11, servingG: 60, preferredUnit: "piece", portions: [{ id: "egg-piece", label: "1 Ei", unit: "piece", amount: 1, gramsEquivalent: 60 }], favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["basis"] }, { id: "food-chicken", name: "Hähnchenbrust", category: "Protein", kcalPer100g: 110, proteinPer100g: 23, carbsPer100g: 0, fatPer100g: 1.5, servingG: 180, favorite: true, source: "seed", availableEverywhere: true, availabilityTags: ["frisch"] }, { id: "food-quark", name: "Magerquark", category: "Milchprodukte", kcalPer100g: 67, proteinPer100g: 12, carbsPer100g: 4, fatPer100g: 0.2, servingG: 250, preferredUnit: "cup", portions: [{ id: "quark-cup", label: "1 Becher", unit: "cup", amount: 1, gramsEquivalent: 500 }], source: "seed", availableEverywhere: true, availabilityTags: ["protein"] }, { id: "food-paprika", name: "Paprika", category: "Gemüse", kcalPer100g: 43, proteinPer100g: 1.3, carbsPer100g: 6.4, fatPer100g: 0.5, servingG: 100, source: "seed", availableEverywhere: true, availabilityTags: ["frisch"] }, { id: "food-tuna", name: "Thunfisch", category: "Protein", kcalPer100g: 116, proteinPer100g: 26, carbsPer100g: 0, fatPer100g: 1, servingG: 120, source: "seed", storeIds: ["kaufland", "edeka", "rewe"], availabilityTags: ["konserve", "protein"] }, { id: "food-greek-yoghurt-kaufland", name: "Griechischer Joghurt 0,1 % Fett", brand: "K-Classic", category: "Milchprodukte", kcalPer100g: 57, proteinPer100g: 10, carbsPer100g: 3.8, fatPer100g: 0.1, servingG: 250, preferredUnit: "cup", portions: [{ id: "greek-yoghurt-cup", label: "1 Becher", unit: "cup", amount: 1, gramsEquivalent: 500 }], source: "seed", storeIds: ["kaufland"], availabilityTags: ["nur kaufland", "protein"] }, { id: "food-gockentoast-gold", name: "Gockentoast Gold", category: "Brot & Backwaren", kcalPer100g: 245, proteinPer100g: 8, carbsPer100g: 45, fatPer100g: 3.5, servingG: 50, preferredUnit: "slice", portions: [{ id: "toast-slice", label: "1 Scheibe", unit: "slice", amount: 1, gramsEquivalent: 25 }, { id: "toast-pack", label: "1 Packung", unit: "pack", amount: 1, gramsEquivalent: 500 }], source: "seed", storeIds: ["edeka", "kaufland", "lidl"], availabilityTags: ["toast", "supermarkt"] }, { id: "food-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: "seed", availableEverywhere: true, availabilityTags: ["basis"] }, { id: "food-cream", name: "Sahne", category: "Milchprodukte", kcalPer100g: 292, proteinPer100g: 2.4, carbsPer100g: 3.2, fatPer100g: 30, servingG: 100, preferredUnit: "ml", densityGPerMl: 1, portions: [{ id: "cream-bottle", label: "1 Flasche", unit: "bottle", amount: 1, gramsEquivalent: 250 }], source: "seed", availableEverywhere: true, availabilityTags: ["kühlung"] }, ]; export const seedRecipes: Recipe[] = [ { id: "recipe-eggs", name: "Eier-Muffins mit Paprika", portions: 2, minutes: 20, ingredients: [ { foodId: "food-egg", amountG: 180 }, { foodId: "food-paprika", amountG: 150 }, { foodId: "food-quark", amountG: 80 }, ], notes: "Gut für Meal Prep, hält 2 Tage im Kühlschrank.", }, { id: "recipe-pita", name: "Mediterrane Thunfisch-Pita", portions: 1, minutes: 20, ingredients: [ { foodId: "food-tuna", amountG: 120 }, { foodId: "food-paprika", amountG: 80 }, ], }, ]; export const seedPlannedMeals: PlannedMeal[] = [ { id: "plan-eggs", date: todayKey(), mealType: "Frühstück", recipeId: "recipe-eggs", name: "Eier-Muffins mit Paprika", portions: 1 }, { id: "plan-pita", date: todayKey(), mealType: "Mittagessen", recipeId: "recipe-pita", name: "Mediterrane Thunfisch-Pita", portions: 1 }, ]; export function todayKey() { return new Date().toISOString().slice(0, 10); } export function uid(prefix = "hh") { return `${prefix}-${Math.random().toString(36).slice(2, 10)}`; } export function defaultHellthData(): HellthData { return { stores: seedStores, foods: seedFoods, recipes: seedRecipes, recipeRatings: [], plannedMeals: seedPlannedMeals, shoppingItems: [], goals: defaultGoals, coachRecommendations: [], reminders: defaultReminderSettings, activityTemplates: seedActivityTemplates, activityLogs: [], weeklyCaloriePlans: [], }; } function scopedStorageKey(baseKey: string, userKey?: string | null) { const normalized = userKey?.trim().toLowerCase(); return normalized ? `${baseKey}:${normalized}` : baseKey; } export function hellthDataStorageKey(userKey?: string | null) { return scopedStorageKey(HELLTH_DATA_KEY, userKey); } function migrateGlobalDataToUserKey(userKey: string) { if (typeof window === "undefined") return; const scopedKey = hellthDataStorageKey(userKey); if (window.localStorage.getItem(scopedKey)) return; const globalData = window.localStorage.getItem(HELLTH_DATA_KEY); if (globalData) { window.localStorage.setItem(scopedKey, globalData); } } export function loadHellthData(userKey?: string | null): HellthData { if (typeof window === "undefined") return defaultHellthData(); try { if (userKey) migrateGlobalDataToUserKey(userKey); const raw = window.localStorage.getItem(hellthDataStorageKey(userKey)); if (!raw) return defaultHellthData(); const parsed = JSON.parse(raw) as Partial; return { stores: parsed.stores?.length ? parsed.stores : seedStores, foods: parsed.foods?.length ? parsed.foods : seedFoods, recipes: parsed.recipes?.length ? parsed.recipes : seedRecipes, recipeRatings: parsed.recipeRatings ?? [], plannedMeals: parsed.plannedMeals ?? seedPlannedMeals, shoppingItems: parsed.shoppingItems ?? [], goals: { ...defaultGoals, ...parsed.goals }, healthProfile: parsed.healthProfile, coachRecommendations: parsed.coachRecommendations ?? [], reminders: { ...defaultReminderSettings, ...parsed.reminders, rules: { water: { ...defaultReminderSettings.rules.water, ...parsed.reminders?.rules?.water }, snack: { ...defaultReminderSettings.rules.snack, ...parsed.reminders?.rules?.snack }, movement: { ...defaultReminderSettings.rules.movement, ...parsed.reminders?.rules?.movement }, }, }, activityTemplates: parsed.activityTemplates?.length ? parsed.activityTemplates : seedActivityTemplates, activityLogs: parsed.activityLogs ?? [], weeklyCaloriePlans: parsed.weeklyCaloriePlans ?? [], }; } catch { return defaultHellthData(); } } export function recipeRatingSummary(recipeId: string, ratings: RecipeRating[]) { const recipeRatings = ratings.filter((rating) => rating.recipeId === recipeId); if (!recipeRatings.length) return { average: 0, count: 0 }; const average = recipeRatings.reduce((sum, rating) => sum + rating.rating, 0) / recipeRatings.length; return { average: Math.round(average * 10) / 10, count: recipeRatings.length, }; } export type WeeklyBudgetDay = { date: string; plannedCalories: number; eatenCalories: number; activityCalories: number; balance: number; note?: string; }; export type WeeklyBudgetSummary = { weekStart: string; dailyGoal: number; baseBudget: number; plannedBudget: number; eatenCalories: number; activityCalories: number; availableCalories: number; balance: number; days: WeeklyBudgetDay[]; }; export type HellthDaySnapshot = { foods?: Array<{ calories?: number; protein?: number }>; drinks?: Array<{ amountMl?: number }>; weightKg?: number; }; export type HellthDayStateSnapshot = { days?: Record; }; export type HealthStatsDay = { date: string; calories: number; plannedCalories: number; activityCalories: number; protein: number; drinkMl: number; weightKg?: number; }; export type HealthProjection = { kind: "weight"; trendKgPerWeek: number; targetDate?: string; message: string; confidence: "low" | "medium"; }; export type HealthStatsSummary = { days: HealthStatsDay[]; week: WeeklyBudgetSummary; averages: { calories: number; protein: number; drinkMl: number; activityCalories: number; }; streaks: { waterDays: number; proteinDays: number; activityDays: number; }; wins: string[]; projection?: HealthProjection; }; 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 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(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 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 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 hasCompletedHealthProfile(userKey?: string | null) { return Boolean(loadHellthData(userKey).healthProfile?.completedAt); } 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 saveHellthData(data: HellthData, userKey?: string | null) { if (typeof window === "undefined") return; window.localStorage.setItem(hellthDataStorageKey(userKey), JSON.stringify(data)); } 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]; } export function defaultClubData(): ClubData { const today = todayKey(); return { profile: defaultClubProfile, activities: [ { id: "club-sebastian-walk", title: "Eine Stunde spazieren gehen", hostUserKey: "seed-sebastian", hostName: "Sebastian", category: "walk", scope: "state", state: "Bayern", city: "Augsburg", locationLabel: "Bayern", startsAt: `${today}T18:00:00`, durationMinutes: 60, points: 100, note: "Lockerer Spaziergang nach Feierabend. Jeder geht für sich, aber wir starten gemeinsam.", participants: [ { userKey: "seed-sebastian", displayName: "Sebastian", joinedAt: `${today}T10:00:00`, }, ], }, { id: "club-mobility", title: "5 Minuten Dehnübungen", hostUserKey: "seed-hellth", hostName: "Hellth Coach", category: "mobility", scope: "nationwide", state: "Bayern", locationLabel: "Deutschlandweit", startsAt: `${today}T15:00:00`, durationMinutes: 5, points: 20, note: "Kurze Schreibtischpause. Matte nicht nötig.", participants: [], }, ], }; } export function loadClubData(): ClubData { if (typeof window === "undefined") return defaultClubData(); try { const raw = window.localStorage.getItem(HELLTH_CLUB_KEY); if (!raw) return defaultClubData(); const parsed = JSON.parse(raw) as Partial; return { profile: { ...defaultClubProfile, ...parsed.profile }, activities: parsed.activities?.length ? parsed.activities : defaultClubData().activities, }; } catch { return defaultClubData(); } } export function saveClubData(data: ClubData) { if (typeof window === "undefined") return; window.localStorage.setItem(HELLTH_CLUB_KEY, JSON.stringify(data)); } export function calcFoodForAmount(food: CatalogFood, amountG: number) { const factor = amountG / 100; return { kcal: Math.round(food.kcalPer100g * factor), protein: Math.round(food.proteinPer100g * factor * 10) / 10, carbs: Math.round(food.carbsPer100g * factor * 10) / 10, fat: Math.round(food.fatPer100g * factor * 10) / 10, }; } export function unitLabel(unit: PortionUnit) { const labels: Record = { g: "g", ml: "ml", tsp: "TL", tbsp: "EL", piece: "Stück", pack: "Packung", bottle: "Flasche", cup: "Becher", slice: "Scheibe", }; return labels[unit]; } export function amountToGrams(food: CatalogFood, amount: number, unit: PortionUnit, portionId?: string) { const portion = portionId ? food.portions?.find((item) => item.id === portionId) : undefined; if (portion) return amount * (portion.gramsEquivalent / Math.max(1, portion.amount)); if (unit === "g") return amount; if (unit === "ml") return amount * (food.densityGPerMl ?? 1); if (unit === "tsp") return amount * 5 * (food.densityGPerMl ?? 1); if (unit === "tbsp") return amount * 15 * (food.densityGPerMl ?? 1); const matchingPortion = food.portions?.find((item) => item.unit === unit); if (matchingPortion) return amount * (matchingPortion.gramsEquivalent / Math.max(1, matchingPortion.amount)); return amount * food.servingG; } export function describeIngredientAmount(ingredient: RecipeIngredient) { if (ingredient.amount && ingredient.unit) { return `${ingredient.amount} ${unitLabel(ingredient.unit)}`; } return `${Math.round(ingredient.amountG)} g`; } export function calcRecipe(recipe: Recipe, foods: CatalogFood[], portions = recipe.portions) { const byId = new Map(foods.map((food) => [food.id, food])); const totals = recipe.ingredients.reduce( (sum, ingredient) => { const food = byId.get(ingredient.foodId); if (!food) return sum; const values = calcFoodForAmount(food, ingredient.amountG); return { kcal: sum.kcal + values.kcal, protein: sum.protein + values.protein, carbs: sum.carbs + values.carbs, fat: sum.fat + values.fat, }; }, { kcal: 0, protein: 0, carbs: 0, fat: 0 }, ); const divisor = Math.max(1, portions); return { kcal: Math.round(totals.kcal / divisor), protein: Math.round((totals.protein / divisor) * 10) / 10, carbs: Math.round((totals.carbs / divisor) * 10) / 10, fat: Math.round((totals.fat / divisor) * 10) / 10, }; } export function recipeMacroDistribution(totals: { protein: number; carbs: number; fat: number }) { const proteinCalories = totals.protein * 4; const carbsCalories = totals.carbs * 4; const fatCalories = totals.fat * 9; const total = Math.max(1, proteinCalories + carbsCalories + fatCalories); return { protein: Math.round((proteinCalories / total) * 100), carbs: Math.round((carbsCalories / total) * 100), fat: Math.round((fatCalories / total) * 100), }; } export function classifyRecipe(recipe: Recipe, foods: CatalogFood[]) { const byId = new Map(foods.map((food) => [food.id, food])); const usedFoods = recipe.ingredients.map((ingredient) => byId.get(ingredient.foodId)).filter(Boolean) as CatalogFood[]; const text = usedFoods.map((food) => `${food.name} ${food.category}`).join(" ").toLowerCase(); const hasMeatOrFish = /fleisch|fisch|hähnchen|huhn|pute|rind|schwein|hack|wurst|speck|salami|schinken|thunfisch|lachs|garnel|shrimp|kabeljau/.test(text); const hasAnimalProduct = hasMeatOrFish || /milch|joghurt|yoghurt|quark|skyr|käse|sahne|butter|ei\b|eier|honig|molke|whey|gelatine/.test(text); const totals = calcRecipe(recipe, foods); const distribution = recipeMacroDistribution(totals); const tags = [ hasMeatOrFish ? "mit Fleisch/Fisch" : "vegetarisch", !hasAnimalProduct ? "vegan" : undefined, totals.protein >= 25 ? "proteinreich" : undefined, totals.kcal <= 500 ? "kalorienbewusst" : undefined, distribution.fat >= 45 ? "fettreich" : undefined, distribution.carbs >= 55 ? "kohlenhydratbetont" : undefined, ].filter(Boolean) as string[]; const criteria = [ { label: "Protein sinnvoll", ok: totals.protein >= 20, detail: `${totals.protein} g pro Portion` }, { label: "Fett moderat", ok: distribution.fat <= 40, detail: `${distribution.fat} % der Makro-Kalorien` }, { label: "Kalorien alltagstauglich", ok: totals.kcal <= 750, detail: `${totals.kcal} kcal pro Portion` }, ]; return { tags, vegetarian: !hasMeatOrFish, vegan: !hasAnimalProduct, distribution, criteria, }; } export function parseNutritionLabel(text: string): Partial { const normalized = text.replace(/,/g, ".").replace(/\s+/g, " "); const read = (patterns: RegExp[]) => { for (const pattern of patterns) { const match = normalized.match(pattern); if (match?.[1]) return Number(match[1]); } return undefined; }; return { kcalPer100g: read([/(\d+(?:\.\d+)?)\s*kcal/i, /energie[^0-9]*(\d+(?:\.\d+)?)/i]), proteinPer100g: read([/(?:eiweiss|eiweiß|protein)[^0-9]*(\d+(?:\.\d+)?)/i]), carbsPer100g: read([/(?:kohlenhydrate|carbohydrate)[^0-9]*(\d+(?:\.\d+)?)/i]), fatPer100g: read([/(?:fett|fat)[^0-9]*(\d+(?:\.\d+)?)/i]), }; }