1150 lines
42 KiB
TypeScript
1150 lines
42 KiB
TypeScript
|
|
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<string, string[]>;
|
|||
|
|
lastPriceByStore?: Record<string, number>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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<ReminderKind, ReminderRule>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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<HellthData>;
|
|||
|
|
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<string, HellthDaySnapshot>;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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<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 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<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];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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<ClubData>;
|
|||
|
|
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<PortionUnit, string> = {
|
|||
|
|
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<CatalogFood> {
|
|||
|
|
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]),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|